Skip to content

Commit

Permalink
refactor(primitive): slots and asChild (#217)
Browse files Browse the repository at this point in the history
  • Loading branch information
productdevbook authored Jul 18, 2023
1 parent 90fc4bd commit 82fd8d3
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 9 deletions.
103 changes: 102 additions & 1 deletion packages/core/primitive/src/primitive.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, test } from 'vitest'
import { mount } from '@vue/test-utils'
import { Primitive } from './index'

Expand Down Expand Up @@ -248,4 +248,105 @@ describe('Primitive', () => {
await element.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})

test('asChild prop', () => {
const wrapper = mount(Primitive.div, {
props: {
asChild: true,
},
slots: {
default: 'Hello',
},
})

expect(wrapper.html()).toBe('Hello')
})

test('asChild with attr', () => {
const wrapper = mount(Primitive.div, {
props: {
asChild: true,
},
attrs: {
id: 'test',
class: 'text-red-500',
},
slots: {
default: '<div>Oku</div>',
},
})
expect(wrapper.html()).toBe('<div id="test" class="text-red-500">Oku</div>')
})

test('asChild with props', () => {
const wrapper = mount(Primitive.div, {
props: {
asChild: true,
disabled: true,
},
attrs: {
id: 'test',
class: 'text-red-500',
},
slots: {
default: '<div>Oku</div>',
},
})
expect(wrapper.html()).toBe('<div id="test" class="text-red-500" disabled="true">Oku</div>')
})

test('asChild with 2 children', () => {
const wrapper = () => mount(Primitive.div, {
props: {
asChild: true,
},
slots: {
default: `
<div>Oku</div>
<div>Oku</div>
`,
},
})

expect(() => wrapper()).toThrowError(/Detected an invalid children/)
})

test('asChild with 2 children and attrs', () => {
const wrapper = () => mount(Primitive.div, {
props: {
asChild: true,
disabled: true,
},
attrs: {
id: 'test',
class: 'text-red-500',
},
slots: {
default: `
<div>Oku</div>
<div>Oku</div>
`,
},
})

expect(() => wrapper()).toThrowError(/Detected an invalid children/)
})

test('asChild with default 3 children', () => {
const wrapper = () => mount(Primitive.div, {
props: {
asChild: true,
disabled: true,
disabled2: true,
},
slots: {
default: `
<div>Oku</div>
<Hello>Oku</Hello>
<Another>Oku</Another>
`,
},
})
expect(() => wrapper()).toThrowError(/Detected an invalid children/)
})
})
88 changes: 80 additions & 8 deletions packages/core/primitive/src/primitive.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// TODO: IntrinsicElementAttributes vue 3.3 add
// same inspiration and resource https://github.com/chakra-ui/ark/blob/main/packages/vue/src/factory.tsx

import type {
ComponentPublicInstance,
DefineComponent,
FunctionalComponent,
IntrinsicElementAttributes,
} from 'vue'
import { defineComponent, h, onMounted } from 'vue'
import { cloneVNode, defineComponent, getCurrentInstance, h, mergeProps, onMounted } from 'vue'
import { isValidVNodeElement, renderSlotFragments } from './utils'

/* -------------------------------------------------------------------------------------------------
* Primitive
* ----------------------------------------------------------------------------------------------- */
const NODES = [
'a',
'button',
Expand Down Expand Up @@ -71,14 +70,87 @@ const Primitive = NODES.reduce((primitive, node) => {
asChild: Boolean,
},
setup(props, { attrs, slots }) {
const instance = getCurrentInstance()

onMounted(() => {
(window as any)[Symbol.for('oku-ui')] = true
})
const Tag: any = props.asChild ? 'slot' : node

return () => {
const _slots = slots.default?.()
return props.asChild ? _slots : h(Tag, { ...attrs }, _slots)
if (!props.asChild) {
return () => h(Tag, { ...attrs }, {
default: () => slots.default && slots.default(),
})
}
else {
return () => {
let children = slots.default?.()
children = renderSlotFragments(children || [])

if (Object.keys(attrs).length > 0) {
const [firstChild, ...otherChildren] = children
if (!isValidVNodeElement(firstChild) || otherChildren.length > 0) {
const componentName = instance?.parent?.type.name
? `<${instance.parent.type.name} />`
: 'component'
throw new Error(
[
`Detected an invalid children for \`${componentName}\` with \`asChild\` prop.`,
'',
'Note: All components accepting `asChild` expect only one direct child of valid VNode type.',
'You can apply a few solutions:',
[
'Provide a single child element so that we can forward the props onto that element.',
'Ensure the first child is an actual element instead of a raw text node or comment node.',
]
.map(line => ` - ${line}`)
.join('\n'),
].join('\n'),
)
}

const mergedProps = mergeProps(firstChild.props ?? {}, attrs)
const cloned = cloneVNode(firstChild, mergedProps)
// Explicitly override props starting with `on`.
// It seems cloneVNode from Vue doesn't like overriding `onXXX` props. So
// we have to do it manually.
for (const prop in mergedProps) {
if (prop.startsWith('on')) {
cloned.props ||= {}
cloned.props[prop] = mergedProps[prop]
}
}
return cloned
}
else if (Array.isArray(children)) {
if (children.length === 1) {
return children[0]
}
else {
const componentName = instance?.parent?.type.name
? `<${instance.parent.type.name} />`
: 'component'
throw new Error(
[
`Detected an invalid children for \`${componentName}\` with \`asChild\` prop.`,
'',
'Note: All components accepting `asChild` expect only one direct child of valid VNode type.',
'You can apply a few solutions:',
[
'Provide a single child element so that we can forward the props onto that element.',
'Ensure the first child is an actual element instead of a raw text node or comment node.',
]
.map(line => ` - ${line}`)
.join('\n'),
].join('\n'),
)
}
}
else {
// No children.
return null
}
}
}
},
})
Expand Down
44 changes: 44 additions & 0 deletions packages/core/primitive/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// same inspiration and resource https://github.com/chakra-ui/ark/blob/main/packages/vue/src/factory.tsx

import type { VNode } from 'vue'
import { Fragment } from 'vue'

/**
* Checks whether a given VNode is a render-vialble element.
*/
export function isValidVNodeElement(input: any): boolean {
return (
input
&& (typeof input.type === 'string'
|| typeof input.type === 'object'
|| typeof input.type === 'function')
)
}

/**
* When you create a component and pass a <slot />, Vue wraps
* the contents of <slot /> inside a <Fragment /> component and assigns
* the <slot /> VNode a type of Fragment.
*
* So why are we flattening here? Vue renders VNodes from the leaf
* nodes going up to the root. In other words, when executing the render function
* of each component, it executes the child render functions first before the parents.
*
* This means that at any components render function execution context, all it's children
* VNodes should have already been rendered -- and that includes any slots! :D
*
* In the cases where we pass in a component with slots to the `asChild` component,
* we shall need to flatten those slot fragment VNodes so as to extract all it's children VNodes
* to correctly apply the props and event listeners from the with as child components.
*
* We do this recursively to ensure that all first child slots that contain fragments in their descendants are rendered into VNodes before passing events.
* to the first actual element VNode.
*/
export function renderSlotFragments(children: VNode[]): VNode[] {
return children.flatMap((child) => {
if (child.type === Fragment)
return renderSlotFragments(child.children as VNode[])

return [child]
})
}

0 comments on commit 82fd8d3

Please sign in to comment.