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.
This commit is contained in:
Matthew McPeak 2026-07-13 18:07:45 -04:00
parent d56ccce2fe
commit dc3aa2c816
3 changed files with 83 additions and 0 deletions

View file

@ -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)
}
},
})

View file

@ -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<string, unknown>))
}
},
})

View file

@ -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()
})
})