diff --git a/packages/@headlessui-vue/src/components/description/description.test.ts b/packages/@headlessui-vue/src/components/description/description.test.ts index f42e178..fe1458f 100644 --- a/packages/@headlessui-vue/src/components/description/description.test.ts +++ b/packages/@headlessui-vue/src/components/description/description.test.ts @@ -1,10 +1,12 @@ -import { defineComponent, h, nextTick } from 'vue' +import { defineComponent, h, nextTick, ref } from 'vue' import prettier from 'prettier' import { render } from '../../test-utils/vue-testing-library' import { Description, useDescriptions } from './description' import { html } from '../../test-utils/html' +import { click } from '../../test-utils/interactions' +import { getByText } from '../../test-utils/accessibility-assertions' function format(input: Element | string) { let contents = (typeof input === 'string' ? input : (input as HTMLElement).outerHTML).trim() @@ -36,18 +38,14 @@ function renderTemplate(input: string | Partial { +it('should be possible to use useDescriptions without using a Description', async () => { let { container } = renderTemplate({ render() { - return h('div', [ - h(this.DescriptionProvider, () => [ - h('div', { 'aria-describedby': this.describedby }, ['No description']), - ]), - ]) + return h('div', [h('div', { 'aria-describedby': this.describedby }, ['No description'])]) }, setup() { - let [describedby, DescriptionProvider] = useDescriptions() - return { describedby, DescriptionProvider } + let describedby = useDescriptions() + return { describedby } }, }) @@ -60,21 +58,19 @@ it('should be possible to use a DescriptionProvider without using a Description' ) }) -it('should be possible to use a DescriptionProvider and a single Description, and have them linked', async () => { +it('should be possible to use useDescriptions and a single Description, and have them linked', async () => { let { container } = renderTemplate({ render() { return h('div', [ - h(this.DescriptionProvider, () => [ - h('div', { 'aria-describedby': this.describedby }, [ - h(Description, () => 'I am a description'), - h('span', 'Contents'), - ]), + h('div', { 'aria-describedby': this.describedby }, [ + h(Description, () => 'I am a description'), + h('span', 'Contents'), ]), ]) }, setup() { - let [describedby, DescriptionProvider] = useDescriptions() - return { describedby, DescriptionProvider } + let describedby = useDescriptions() + return { describedby } }, }) @@ -94,22 +90,20 @@ it('should be possible to use a DescriptionProvider and a single Description, an ) }) -it('should be possible to use a DescriptionProvider and multiple Description components, and have them linked', async () => { +it('should be possible to use useDescriptions and multiple Description components, and have them linked', async () => { let { container } = renderTemplate({ render() { return h('div', [ - h(this.DescriptionProvider, () => [ - h('div', { 'aria-describedby': this.describedby }, [ - h(Description, () => 'I am a description'), - h('span', 'Contents'), - h(Description, () => 'I am also a description'), - ]), + h('div', { 'aria-describedby': this.describedby }, [ + h(Description, () => 'I am a description'), + h('span', 'Contents'), + h(Description, () => 'I am also a description'), ]), ]) }, setup() { - let [describedby, DescriptionProvider] = useDescriptions() - return { describedby, DescriptionProvider } + let describedby = useDescriptions() + return { describedby } }, }) @@ -131,3 +125,47 @@ it('should be possible to use a DescriptionProvider and multiple Description com `) ) }) + +it('should be possible to update a prop from the parent and it should reflect in the Description component', async () => { + let { container } = renderTemplate({ + render() { + return h('div', [ + h('div', { 'aria-describedby': this.describedby }, [ + h(Description, () => 'I am a description'), + h('button', { onClick: () => this.count++ }, '+1'), + ]), + ]) + }, + setup() { + let count = ref(0) + let describedby = useDescriptions({ props: { 'data-count': count } }) + return { count, describedby } + }, + }) + + await new Promise(nextTick) + + expect(format(container.firstElementChild)).toEqual( + format(html` +
+

+ I am a description +

+ +
+ `) + ) + + await click(getByText('+1')) + + expect(format(container.firstElementChild)).toEqual( + format(html` +
+

+ I am a description +

+ +
+ `) + ) +}) diff --git a/packages/@headlessui-vue/src/components/description/description.ts b/packages/@headlessui-vue/src/components/description/description.ts index eca11ea..d4968fd 100644 --- a/packages/@headlessui-vue/src/components/description/description.ts +++ b/packages/@headlessui-vue/src/components/description/description.ts @@ -6,11 +6,11 @@ import { onUnmounted, provide, ref, + unref, // Types ComputedRef, InjectionKey, - Ref, } from 'vue' import { useId } from '../../hooks/use-id' @@ -20,9 +20,9 @@ import { render } from '../../utils/render' let DescriptionContext = Symbol('DescriptionContext') as InjectionKey<{ register(value: string): () => void - slot: Ref> - name: Ref - props: Ref> + slot: Record + name: string + props: Record }> function useDescriptionContext() { @@ -33,42 +33,33 @@ function useDescriptionContext() { return context } -export function useDescriptions(): [ - ComputedRef, - ReturnType -] { +export function useDescriptions({ + slot = {}, + name = 'Description', + props = {}, +}: { + slot?: Record + name?: string + props?: Record +} = {}): ComputedRef { let descriptionIds = ref([]) - return [ - // The actual id's as string or undefined. - computed(() => (descriptionIds.value.length > 0 ? descriptionIds.value.join(' ') : undefined)), + function register(value: string) { + descriptionIds.value.push(value) - // The provider component - defineComponent({ - name: 'DescriptionProvider', - props: ['slot', 'name', 'props'], - setup(props, { slots }) { - function register(value: string) { - descriptionIds.value.push(value) + return () => { + let idx = descriptionIds.value.indexOf(value) + if (idx === -1) return + descriptionIds.value.splice(idx, 1) + } + } - return () => { - let idx = descriptionIds.value.indexOf(value) - if (idx === -1) return - descriptionIds.value.splice(idx, 1) - } - } + provide(DescriptionContext, { register, slot, name, props }) - provide(DescriptionContext, { - register, - slot: computed(() => props.slot), - name: computed(() => props.name), - props: computed(() => props.props), - }) - - return () => slots.default!() - }, - }), - ] + // The actual id's as string or undefined. + return computed(() => + descriptionIds.value.length > 0 ? descriptionIds.value.join(' ') : undefined + ) } // --- @@ -79,23 +70,30 @@ export let Description = defineComponent({ as: { type: [Object, String], default: 'p' }, }, render() { + let { name = 'Description', slot = {}, props = {} } = this.context let passThroughProps = this.$props - let propsWeControl = { ...this.props, id: this.id } + let propsWeControl = { + ...Object.entries(props).reduce( + (acc, [key, value]) => Object.assign(acc, { [key]: unref(value) }), + {} + ), + id: this.id, + } return render({ - props: { ...this.props, ...passThroughProps, ...propsWeControl }, - slot: this.slot || {}, + props: { ...passThroughProps, ...propsWeControl }, + slot, attrs: this.$attrs, slots: this.$slots, - name: this.name || 'Description', + name, }) }, setup() { - let { register, slot, name, props } = useDescriptionContext() + let context = useDescriptionContext() let id = `headlessui-description-${useId()}` - onMounted(() => onUnmounted(register(id))) + onMounted(() => onUnmounted(context.register(id))) - return { id, slot, name, props } + return { id, context } }, }) diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index c0d64f7..d1eecfd 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -109,17 +109,15 @@ export let Dialog = defineComponent({ h(Portal, {}, () => [ h(PortalGroup, { target: this.dialogRef }, () => [ h(ForcePortalRoot, { force: false }, () => [ - h(this.DescriptionProvider, { slot }, () => [ - render({ - props: { ...passThroughProps, ...propsWeControl }, - slot, - attrs: this.$attrs, - slots: this.$slots, - visible: open, - features: Features.RenderStrategy | Features.Static, - name: 'Dialog', - }), - ]), + render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + visible: open, + features: Features.RenderStrategy | Features.Static, + name: 'Dialog', + }), ]), ]), ]), @@ -182,7 +180,10 @@ export let Dialog = defineComponent({ useFocusTrap(containers, enabled, focusTrapOptions) useInertOthers(internalDialogRef, enabled) - let [describedby, DescriptionProvider] = useDescriptions() + let describedby = useDescriptions({ + name: 'DialogDescription', + slot: { open: props.open }, + }) let titleId = ref(null) @@ -271,7 +272,6 @@ export let Dialog = defineComponent({ dialogState, titleId, describedby, - DescriptionProvider, } }, }) diff --git a/packages/@headlessui-vue/src/components/label/label.test.ts b/packages/@headlessui-vue/src/components/label/label.test.ts index 0192994..291c4f8 100644 --- a/packages/@headlessui-vue/src/components/label/label.test.ts +++ b/packages/@headlessui-vue/src/components/label/label.test.ts @@ -1,10 +1,12 @@ -import { defineComponent, h, nextTick } from 'vue' +import { defineComponent, h, nextTick, ref } from 'vue' import prettier from 'prettier' import { render } from '../../test-utils/vue-testing-library' import { Label, useLabels } from './label' import { html } from '../../test-utils/html' +import { click } from '../../test-utils/interactions' +import { getByText } from '../../test-utils/accessibility-assertions' function format(input: Element | string) { let contents = (typeof input === 'string' ? input : (input as HTMLElement).outerHTML).trim() @@ -36,18 +38,14 @@ function renderTemplate(input: string | Partial { +it('should be possible to use useLabels without using a Label', async () => { let { container } = renderTemplate({ render() { - return h('div', [ - h(this.LabelProvider, () => [ - h('div', { 'aria-labelledby': this.labelledby }, ['No label']), - ]), - ]) + return h('div', [h('div', { 'aria-labelledby': this.labelledby }, ['No label'])]) }, setup() { - let [labelledby, LabelProvider] = useLabels() - return { labelledby, LabelProvider } + let labelledby = useLabels() + return { labelledby } }, }) @@ -60,21 +58,19 @@ it('should be possible to use a LabelProvider without using a Label', async () = ) }) -it('should be possible to use a LabelProvider and a single Label, and have them linked', async () => { +it('should be possible to use useLabels and a single Label, and have them linked', async () => { let { container } = renderTemplate({ render() { return h('div', [ - h(this.LabelProvider, () => [ - h('div', { 'aria-labelledby': this.labelledby }, [ - h(Label, () => 'I am a label'), - h('span', 'Contents'), - ]), + h('div', { 'aria-labelledby': this.labelledby }, [ + h(Label, () => 'I am a label'), + h('span', 'Contents'), ]), ]) }, setup() { - let [labelledby, LabelProvider] = useLabels() - return { labelledby, LabelProvider } + let labelledby = useLabels() + return { labelledby } }, }) @@ -94,22 +90,20 @@ it('should be possible to use a LabelProvider and a single Label, and have them ) }) -it('should be possible to use a LabelProvider and multiple Label components, and have them linked', async () => { +it('should be possible to use useLabels and multiple Label components, and have them linked', async () => { let { container } = renderTemplate({ render() { return h('div', [ - h(this.LabelProvider, () => [ - h('div', { 'aria-labelledby': this.labelledby }, [ - h(Label, () => 'I am a label'), - h('span', 'Contents'), - h(Label, () => 'I am also a label'), - ]), + h('div', { 'aria-labelledby': this.labelledby }, [ + h(Label, () => 'I am a label'), + h('span', 'Contents'), + h(Label, () => 'I am also a label'), ]), ]) }, setup() { - let [labelledby, LabelProvider] = useLabels() - return { labelledby, LabelProvider } + let labelledby = useLabels() + return { labelledby } }, }) @@ -131,3 +125,47 @@ it('should be possible to use a LabelProvider and multiple Label components, and `) ) }) + +it('should be possible to update a prop from the parent and it should reflect in the Label component', async () => { + let { container } = renderTemplate({ + render() { + return h('div', [ + h('div', { 'aria-labelledby': this.labelledby }, [ + h(Label, () => 'I am a label'), + h('button', { onClick: () => this.count++ }, '+1'), + ]), + ]) + }, + setup() { + let count = ref(0) + let labelledby = useLabels({ props: { 'data-count': count } }) + return { count, labelledby } + }, + }) + + await new Promise(nextTick) + + expect(format(container.firstElementChild)).toEqual( + format(html` +
+ + +
+ `) + ) + + await click(getByText('+1')) + + expect(format(container.firstElementChild)).toEqual( + format(html` +
+ + +
+ `) + ) +}) diff --git a/packages/@headlessui-vue/src/components/label/label.ts b/packages/@headlessui-vue/src/components/label/label.ts index 09cdcb5..bde54c2 100644 --- a/packages/@headlessui-vue/src/components/label/label.ts +++ b/packages/@headlessui-vue/src/components/label/label.ts @@ -6,11 +6,11 @@ import { onUnmounted, provide, ref, + unref, // Types ComputedRef, InjectionKey, - Ref, } from 'vue' import { useId } from '../../hooks/use-id' @@ -20,9 +20,9 @@ import { render } from '../../utils/render' let LabelContext = Symbol('LabelContext') as InjectionKey<{ register(value: string): () => void - slot: Ref> - name: Ref - props: Ref> + slot: Record + name: string + props: Record }> function useLabelContext() { @@ -35,44 +35,30 @@ function useLabelContext() { return context } -export function useLabels(): [ComputedRef, ReturnType] { +export function useLabels({ + slot = {}, + name = 'Label', + props = {}, +}: { + slot?: Record + name?: string + props?: Record +} = {}): ComputedRef { let labelIds = ref([]) + function register(value: string) { + labelIds.value.push(value) - return [ - // The actual id's as string or undefined. - computed(() => (labelIds.value.length > 0 ? labelIds.value.join(' ') : undefined)), + return () => { + let idx = labelIds.value.indexOf(value) + if (idx === -1) return + labelIds.value.splice(idx, 1) + } + } - // The provider component - // @ts-expect-error The DefineComponent of Vue is just too confusing - defineComponent({ - name: 'LabelProvider', - props: { - slot: { type: Object, default: undefined }, - name: { type: String, default: undefined }, - props: { type: Object, default: undefined }, - }, - setup(props, { slots }) { - function register(value: string) { - labelIds.value.push(value) + provide(LabelContext, { register, slot, name, props }) - return () => { - let idx = labelIds.value.indexOf(value) - if (idx === -1) return - labelIds.value.splice(idx, 1) - } - } - - provide(LabelContext, { - register, - slot: computed(() => props.slot), - name: computed(() => props.name), - props: computed(() => props.props), - }) - - return () => slots.default!() - }, - }), - ] + // The actual id's as string or undefined. + return computed(() => (labelIds.value.length > 0 ? labelIds.value.join(' ') : undefined)) } // --- @@ -84,8 +70,15 @@ export let Label = defineComponent({ clickable: { type: [Boolean], default: false }, }, render() { + let { name = 'Label', slot = {}, props = {} } = this.context let { clickable, ...passThroughProps } = this.$props - let propsWeControl = { ...this.props, id: this.id } + let propsWeControl = { + ...Object.entries(props).reduce( + (acc, [key, value]) => Object.assign(acc, { [key]: unref(value) }), + {} + ), + id: this.id, + } let allProps = { ...passThroughProps, ...propsWeControl } // @ts-expect-error props are dynamic via context, some components will @@ -94,18 +87,18 @@ export let Label = defineComponent({ return render({ props: allProps, - slot: this.slot || {}, + slot, attrs: this.$attrs, slots: this.$slots, - name: this.name || 'Label', + name, }) }, setup() { - let { register, slot, name, props } = useLabelContext() + let context = useLabelContext() let id = `headlessui-label-${useId()}` - onMounted(() => onUnmounted(register(id))) + onMounted(() => onUnmounted(context.register(id))) - return { id, slot, name, props } + return { id, context } }, }) diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts index f867577..8c371f9 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -1,7 +1,6 @@ import { computed, defineComponent, - h, inject, onMounted, onUnmounted, @@ -60,7 +59,6 @@ function useRadioGroupContext(component: string) { export let RadioGroup = defineComponent({ name: 'RadioGroup', emits: ['update:modelValue'], - inheritAttrs: false, // Manually handling this props: { as: { type: [Object, String], default: 'div' }, disabled: { type: [Boolean], default: false }, @@ -70,9 +68,6 @@ export let RadioGroup = defineComponent({ let { modelValue, disabled, ...passThroughProps } = this.$props let propsWeControl = { - // Manually passthrough the attributes, because Vue can't automatically pass - // it to the underlying div because of all the wrapper components below. - ...this.$attrs, ref: 'el', id: this.id, role: 'radiogroup', @@ -81,23 +76,19 @@ export let RadioGroup = defineComponent({ onKeydown: this.handleKeyDown, } - return h(this.DescriptionProvider, () => [ - h(this.LabelProvider, () => [ - render({ - props: { ...passThroughProps, ...propsWeControl }, - slot: {}, - attrs: this.$attrs, - slots: this.$slots, - name: 'RadioGroup', - }), - ]), - ]) + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot: {}, + attrs: this.$attrs, + slots: this.$slots, + name: 'RadioGroup', + }) }, setup(props, { emit }) { let radioGroupRef = ref(null) let options = ref([]) - let [labelledby, LabelProvider] = useLabels() - let [describedby, DescriptionProvider] = useDescriptions() + let labelledby = useLabels({ name: 'RadioGroupLabel' }) + let describedby = useDescriptions({ name: 'RadioGroupDescription' }) let value = computed(() => props.modelValue) @@ -214,8 +205,6 @@ export let RadioGroup = defineComponent({ describedby, el: radioGroupRef, handleKeyDown, - LabelProvider, - DescriptionProvider, } }, }) @@ -229,7 +218,6 @@ enum OptionState { export let RadioGroupOption = defineComponent({ name: 'RadioGroupOption', - inheritAttrs: false, // Manually handling this props: { as: { type: [Object, String], default: 'div' }, value: { type: [Object, String] }, @@ -251,9 +239,6 @@ export let RadioGroupOption = defineComponent({ let slot = { checked: this.checked, active: Boolean(this.state & OptionState.Active) } let propsWeControl = { - // Manually passthrough the attributes, because Vue can't automatically pass - // it to the underlying div because of all the wrapper components below. - ...this.$attrs, id: this.id, ref: 'el', role: 'radio', @@ -267,23 +252,19 @@ export let RadioGroupOption = defineComponent({ onBlur: this.handleBlur, } - return h(this.DescriptionProvider, () => [ - h(this.LabelProvider, () => [ - render({ - props: { ...passThroughProps, ...propsWeControl }, - slot, - attrs: this.$attrs, - slots: this.$slots, - name: 'RadioGroupOption', - }), - ]), - ]) + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + name: 'RadioGroupOption', + }) }, setup(props) { let api = useRadioGroupContext('RadioGroupOption') let id = `headlessui-radiogroup-option-${useId()}` - let [labelledby, LabelProvider] = useLabels() - let [describedby, DescriptionProvider] = useDescriptions() + let labelledby = useLabels({ name: 'RadioGroupLabel' }) + let describedby = useDescriptions({ name: 'RadioGroupDescription' }) let optionRef = ref(null) let propsRef = computed(() => ({ value: props.value })) @@ -298,8 +279,6 @@ export let RadioGroupOption = defineComponent({ labelledby, describedby, state, - LabelProvider, - DescriptionProvider, checked: computed(() => toRaw(api.value.value) === toRaw(props.value)), handleClick() { let value = props.value diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts index ba29216..8304be0 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.ts +++ b/packages/@headlessui-vue/src/components/switch/switch.ts @@ -1,6 +1,5 @@ import { defineComponent, - h, inject, provide, ref, @@ -30,49 +29,28 @@ let GroupContext = Symbol('GroupContext') as InjectionKey export let SwitchGroup = defineComponent({ name: 'SwitchGroup', - inheritAttrs: false, // Manually handling this props: { as: { type: [Object, String], default: 'template' }, }, setup(props, { slots, attrs }) { let switchRef = ref(null) - let [labelledby, LabelProvider] = useLabels() - let [describedby, DescriptionProvider] = useDescriptions() + let labelledby = useLabels({ + name: 'SwitchLabel', + props: { + onClick() { + if (!switchRef.value) return + switchRef.value.click() + switchRef.value.focus({ preventScroll: true }) + }, + }, + }) + let describedby = useDescriptions({ name: 'SwitchDescription' }) let api = { switchRef, labelledby, describedby } provide(GroupContext, api) - return () => - h(DescriptionProvider, { name: 'SwitchDescription' }, () => [ - h( - LabelProvider, - { - name: 'SwitchLabel', - props: { - onClick() { - if (!switchRef.value) return - switchRef.value.click() - switchRef.value.focus({ preventScroll: true }) - }, - }, - }, - () => [ - render({ - props: { - // Manually passthrough the attributes, because Vue can't automatically pass - // it to the underlying div because of all the wrapper components below. - ...attrs, - ...props, - }, - slot: {}, - slots, - attrs, - name: 'SwitchGroup', - }), - ] - ), - ]) + return () => render({ props, slot: {}, slots, attrs, name: 'SwitchGroup' }) }, })