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:
parent
d56ccce2fe
commit
dc3aa2c816
3 changed files with 83 additions and 0 deletions
21
packages/library/src/lib/primitive.ts
Normal file
21
packages/library/src/lib/primitive.ts
Normal 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
17
packages/library/src/lib/slot.ts
Normal file
17
packages/library/src/lib/slot.ts
Normal 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>))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
45
packages/library/tests/primitive.spec.ts
Normal file
45
packages/library/tests/primitive.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue