From dc3aa2c8167f5b9f592a490471a983ad12b19749 Mon Sep 17 00:00:00 2001 From: Matthew L McPeak Date: Mon, 13 Jul 2026 18:07:45 -0400 Subject: [PATCH] feat: add Primitive/Slot polymorphic-rendering utility First piece of the reka-ui removal: a small asChild/Slot implementation to replace reka-ui's Primitive component. --- packages/library/src/lib/primitive.ts | 21 +++++++++++ packages/library/src/lib/slot.ts | 17 +++++++++ packages/library/tests/primitive.spec.ts | 45 ++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 packages/library/src/lib/primitive.ts create mode 100644 packages/library/src/lib/slot.ts create mode 100644 packages/library/tests/primitive.spec.ts diff --git a/packages/library/src/lib/primitive.ts b/packages/library/src/lib/primitive.ts new file mode 100644 index 0000000..60a9697 --- /dev/null +++ b/packages/library/src/lib/primitive.ts @@ -0,0 +1,21 @@ +import type { Component } from 'vue' +import { defineComponent, h } from 'vue' +import Slot from './slot' + +export const Primitive = defineComponent({ + name: 'Primitive', + inheritAttrs: false, + props: { + as: { + type: [String, Object, Function] as unknown as () => string | Component, + default: 'div', + }, + asChild: { type: Boolean, default: false }, + }, + setup(props, { slots, attrs }) { + return () => { + const Tag = props.asChild ? Slot : props.as + return h(Tag, attrs, slots) + } + }, +}) diff --git a/packages/library/src/lib/slot.ts b/packages/library/src/lib/slot.ts new file mode 100644 index 0000000..e1b53d9 --- /dev/null +++ b/packages/library/src/lib/slot.ts @@ -0,0 +1,17 @@ +import type { VNode } from 'vue' +import { cloneVNode, defineComponent, mergeProps } from 'vue' + +export default defineComponent({ + name: 'Slot', + inheritAttrs: false, + setup(_, { slots, attrs }) { + return () => { + const children = slots.default?.() ?? [] + if (children.length !== 1) { + throw new Error('Slot requires exactly one child element') + } + const child = children[0] as VNode + return cloneVNode(child, mergeProps(attrs, (child.props ?? {}) as Record)) + } + }, +}) diff --git a/packages/library/tests/primitive.spec.ts b/packages/library/tests/primitive.spec.ts new file mode 100644 index 0000000..7658264 --- /dev/null +++ b/packages/library/tests/primitive.spec.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { h } from 'vue' +import { mount } from '@vue/test-utils' +import { Primitive } from '../src/lib/primitive' + +describe('Primitive', () => { + it('renders the "as" tag with forwarded attrs when asChild is false', () => { + const wrapper = mount(Primitive, { + props: { as: 'a' }, + attrs: { href: '/somewhere', class: 'link' }, + slots: { default: () => 'Go' }, + }) + const el = wrapper.get('a') + expect(el.attributes('href')).toBe('/somewhere') + expect(el.classes()).toContain('link') + expect(el.text()).toBe('Go') + }) + + it('clones the single child and merges attrs onto it when asChild is true', async () => { + let clicked = false + const wrapper = mount(Primitive, { + props: { asChild: true }, + attrs: { class: 'from-parent', 'data-slot': 'button', onClick: () => { clicked = true } }, + slots: { + default: () => h('button', { class: 'from-child', type: 'button' }, 'Click'), + }, + }) + const el = wrapper.get('button') + expect(el.attributes('data-slot')).toBe('button') + expect(el.attributes('type')).toBe('button') + expect(el.classes()).toContain('from-parent') + expect(el.classes()).toContain('from-child') + await el.trigger('click') + expect(clicked).toBe(true) + }) + + it('throws if asChild is true with zero or multiple children', () => { + expect(() => + mount(Primitive, { + props: { asChild: true }, + slots: { default: () => [h('span', 'a'), h('span', 'b')] }, + }), + ).toThrow() + }) +})