diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index bec824c78c..ff1d81f309 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -779,9 +779,10 @@ export let ComboboxLabel = defineComponent({ name: 'ComboboxLabel', props: { as: { type: [Object, String], default: 'label' }, - id: { type: String, default: () => `headlessui-combobox-label-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots }) { + let id = props.id ?? `headlessui-combobox-label-${useId()}` let api = useComboboxContext('ComboboxLabel') function handleClick() { @@ -794,7 +795,7 @@ export let ComboboxLabel = defineComponent({ disabled: api.disabled.value, } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id, ref: api.labelRef, onClick: handleClick } return render({ @@ -815,9 +816,10 @@ export let ComboboxButton = defineComponent({ name: 'ComboboxButton', props: { as: { type: [Object, String], default: 'button' }, - id: { type: String, default: () => `headlessui-combobox-button-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-combobox-button-${useId()}` let api = useComboboxContext('ComboboxButton') expose({ el: api.buttonRef, $el: api.buttonRef }) @@ -884,7 +886,7 @@ export let ComboboxButton = defineComponent({ disabled: api.disabled.value, value: api.value.value, } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { ref: api.buttonRef, id, @@ -921,12 +923,13 @@ export let ComboboxInput = defineComponent({ unmount: { type: Boolean, default: true }, displayValue: { type: Function as PropType<(item: unknown) => string> }, defaultValue: { type: String, default: undefined }, - id: { type: String, default: () => `headlessui-combobox-input-${useId()}` }, + id: { type: String, default: null }, }, emits: { change: (_value: Event & { target: HTMLInputElement }) => true, }, setup(props, { emit, attrs, slots, expose }) { + let id = props.id ?? `headlessui-combobox-input-${useId()}` let api = useComboboxContext('ComboboxInput') let ownerDocument = computed(() => getOwnerDocument(dom(api.inputRef))) @@ -1284,7 +1287,7 @@ export let ComboboxInput = defineComponent({ return () => { let slot = { open: api.comboboxState.value === ComboboxStates.Open } - let { id, displayValue, onChange: _onChange, ...theirProps } = props + let { displayValue, onChange: _onChange, ...theirProps } = props let ourProps = { 'aria-controls': api.optionsRef.value?.id, 'aria-expanded': api.comboboxState.value === ComboboxStates.Open, diff --git a/packages/@headlessui-vue/src/components/description/description.ts b/packages/@headlessui-vue/src/components/description/description.ts index 5bf9673670..4e95e2f11e 100644 --- a/packages/@headlessui-vue/src/components/description/description.ts +++ b/packages/@headlessui-vue/src/components/description/description.ts @@ -66,16 +66,18 @@ export let Description = defineComponent({ name: 'Description', props: { as: { type: [Object, String], default: 'p' }, - id: { type: String, default: () => `headlessui-description-${useId()}` }, + id: { type: String, default: null }, }, setup(myProps, { attrs, slots }) { + let id = myProps.id ?? `headlessui-description-${useId()}` let context = useDescriptionContext() - onMounted(() => onUnmounted(context.register(myProps.id))) + onMounted(() => onUnmounted(context.register(id))) return () => { let { name = 'Description', slot = ref({}), props = {} } = context - let { id, ...theirProps } = myProps + let { id: _id, ...theirProps } = myProps + let ourProps = { ...Object.entries(props).reduce( (acc, [key, value]) => Object.assign(acc, { [key]: unref(value) }), diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 564c521f6f..ed8c2a0d18 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -73,11 +73,12 @@ export let Dialog = defineComponent({ unmount: { type: Boolean, default: true }, open: { type: [Boolean, String], default: Missing }, initialFocus: { type: Object as PropType, default: null }, - id: { type: String, default: () => `headlessui-dialog-${useId()}` }, + id: { type: String, default: null }, role: { type: String as PropType<'dialog' | 'alertdialog'>, default: 'dialog' }, }, emits: { close: (_close: boolean) => true }, setup(props, { emit, attrs, slots, expose }) { + let id = props.id ?? `headlessui-dialog-${useId()}` let ready = ref(false) onMounted(() => { ready.value = true @@ -292,7 +293,7 @@ export let Dialog = defineComponent({ }) return () => { - let { id, open: _, initialFocus, ...theirProps } = props + let { open: _, initialFocus, ...theirProps } = props let ourProps = { // Manually passthrough the attributes, because Vue can't automatically pass // it to the underlying div because of all the wrapper components below. @@ -352,9 +353,10 @@ export let DialogOverlay = defineComponent({ name: 'DialogOverlay', props: { as: { type: [Object, String], default: 'div' }, - id: { type: String, default: () => `headlessui-dialog-overlay-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots }) { + let id = props.id ?? `headlessui-dialog-overlay-${useId()}` let api = useDialogContext('DialogOverlay') function handleClick(event: MouseEvent) { @@ -365,7 +367,7 @@ export let DialogOverlay = defineComponent({ } return () => { - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id, 'aria-hidden': true, @@ -390,10 +392,11 @@ export let DialogBackdrop = defineComponent({ name: 'DialogBackdrop', props: { as: { type: [Object, String], default: 'div' }, - id: { type: String, default: () => `headlessui-dialog-backdrop-${useId()}` }, + id: { type: String, default: null }, }, inheritAttrs: false, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-dialog-backdrop-${useId()}` let api = useDialogContext('DialogBackdrop') let internalBackdropRef = ref(null) @@ -408,7 +411,7 @@ export let DialogBackdrop = defineComponent({ }) return () => { - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id, ref: internalBackdropRef, @@ -437,9 +440,10 @@ export let DialogPanel = defineComponent({ name: 'DialogPanel', props: { as: { type: [Object, String], default: 'div' }, - id: { type: String, default: () => `headlessui-dialog-panel-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-dialog-panel-${useId()}` let api = useDialogContext('DialogPanel') expose({ el: api.panelRef, $el: api.panelRef }) @@ -449,7 +453,7 @@ export let DialogPanel = defineComponent({ } return () => { - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id, ref: api.panelRef, @@ -474,18 +478,19 @@ export let DialogTitle = defineComponent({ name: 'DialogTitle', props: { as: { type: [Object, String], default: 'h2' }, - id: { type: String, default: () => `headlessui-dialog-title-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots }) { + let id = props.id ?? `headlessui-dialog-title-${useId()}` let api = useDialogContext('DialogTitle') onMounted(() => { - api.setTitleId(props.id) + api.setTitleId(id) onUnmounted(() => api.setTitleId(null)) }) return () => { - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id } return render({ diff --git a/packages/@headlessui-vue/src/components/label/label.ts b/packages/@headlessui-vue/src/components/label/label.ts index 59bdc771d6..1f7cf5919d 100644 --- a/packages/@headlessui-vue/src/components/label/label.ts +++ b/packages/@headlessui-vue/src/components/label/label.ts @@ -65,16 +65,17 @@ export let Label = defineComponent({ props: { as: { type: [Object, String], default: 'label' }, passive: { type: [Boolean], default: false }, - id: { type: String, default: () => `headlessui-label-${useId()}` }, + id: { type: String, default: null }, }, setup(myProps, { slots, attrs }) { + let id = myProps.id ?? `headlessui-label-${useId()}` let context = useLabelContext() - onMounted(() => onUnmounted(context.register(myProps.id))) + onMounted(() => onUnmounted(context.register(id))) return () => { let { name = 'Label', slot = {}, props = {} } = context - let { id, passive, ...theirProps } = myProps + let { id: _id, passive, ...theirProps } = myProps let ourProps = { ...Object.entries(props).reduce( (acc, [key, value]) => Object.assign(acc, { [key]: unref(value) }), diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 1bc8e40bf1..d797ec3e15 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -426,9 +426,10 @@ export let ListboxLabel = defineComponent({ name: 'ListboxLabel', props: { as: { type: [Object, String], default: 'label' }, - id: { type: String, default: () => `headlessui-listbox-label-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots }) { + let id = props.id ?? `headlessui-listbox-label-${useId()}` let api = useListboxContext('ListboxLabel') function handleClick() { @@ -440,7 +441,7 @@ export let ListboxLabel = defineComponent({ open: api.listboxState.value === ListboxStates.Open, disabled: api.disabled.value, } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id, ref: api.labelRef, onClick: handleClick } return render({ @@ -461,9 +462,10 @@ export let ListboxButton = defineComponent({ name: 'ListboxButton', props: { as: { type: [Object, String], default: 'button' }, - id: { type: String, default: () => `headlessui-listbox-button-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-listbox-button-${useId()}` let api = useListboxContext('ListboxButton') expose({ el: api.buttonRef, $el: api.buttonRef }) @@ -529,7 +531,7 @@ export let ListboxButton = defineComponent({ value: api.value.value, } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { ref: api.buttonRef, id, @@ -564,9 +566,10 @@ export let ListboxOptions = defineComponent({ as: { type: [Object, String], default: 'ul' }, static: { type: Boolean, default: false }, unmount: { type: Boolean, default: true }, - id: { type: String, default: () => `headlessui-listbox-options-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-listbox-options-${useId()}` let api = useListboxContext('ListboxOptions') let searchDebounce = ref | null>(null) @@ -656,7 +659,7 @@ export let ListboxOptions = defineComponent({ return () => { let slot = { open: api.listboxState.value === ListboxStates.Open } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { 'aria-activedescendant': api.activeOptionIndex.value === null @@ -696,9 +699,10 @@ export let ListboxOption = defineComponent({ >, }, disabled: { type: Boolean, default: false }, - id: { type: String, default: () => `headlessui-listbox.option-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { slots, attrs, expose }) { + let id = props.id ?? `headlessui-listbox-option-${useId()}` let api = useListboxContext('ListboxOption') let internalOptionRef = ref(null) @@ -706,7 +710,7 @@ export let ListboxOption = defineComponent({ let active = computed(() => { return api.activeOptionIndex.value !== null - ? api.options.value[api.activeOptionIndex.value].id === props.id + ? api.options.value[api.activeOptionIndex.value].id === id : false }) @@ -727,7 +731,7 @@ export let ListboxOption = defineComponent({ return ( api.options.value.find((option) => currentValues.some((value) => api.compare(toRaw(value), toRaw(option.dataRef.value))) - )?.id === props.id + )?.id === id ) }, [ValueMode.Single]: () => selected.value, @@ -744,8 +748,8 @@ export let ListboxOption = defineComponent({ domRef: internalOptionRef, })) - onMounted(() => api.registerOption(props.id, dataRef)) - onUnmounted(() => api.unregisterOption(props.id)) + onMounted(() => api.registerOption(id, dataRef)) + onUnmounted(() => api.unregisterOption(id)) onMounted(() => { watch( @@ -756,10 +760,10 @@ export let ListboxOption = defineComponent({ match(api.mode.value, { [ValueMode.Multi]: () => { - if (isFirstSelected.value) api.goToOption(Focus.Specific, props.id) + if (isFirstSelected.value) api.goToOption(Focus.Specific, id) }, [ValueMode.Single]: () => { - api.goToOption(Focus.Specific, props.id) + api.goToOption(Focus.Specific, id) }, }) }, @@ -785,7 +789,7 @@ export let ListboxOption = defineComponent({ function handleFocus() { if (props.disabled) return api.goToOption(Focus.Nothing) - api.goToOption(Focus.Specific, props.id) + api.goToOption(Focus.Specific, id) } let pointer = useTrackedPointer() @@ -798,7 +802,7 @@ export let ListboxOption = defineComponent({ if (!pointer.wasMoved(evt)) return if (props.disabled) return if (active.value) return - api.goToOption(Focus.Specific, props.id, ActivationTrigger.Pointer) + api.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) } function handleLeave(evt: PointerEvent) { @@ -811,7 +815,7 @@ export let ListboxOption = defineComponent({ return () => { let { disabled } = props let slot = { active: active.value, selected: selected.value, disabled } - let { id, value: _value, disabled: _disabled, ...theirProps } = props + let { value: _value, disabled: _disabled, ...theirProps } = props let ourProps = { id, ref: internalOptionRef, diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index d7cf180bf0..d2a83bbd68 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -247,9 +247,10 @@ export let MenuButton = defineComponent({ props: { disabled: { type: Boolean, default: false }, as: { type: [Object, String], default: 'button' }, - id: { type: String, default: () => `headlessui-menu-button-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-menu-button-${useId()}` let api = useMenuContext('MenuButton') expose({ el: api.buttonRef, $el: api.buttonRef }) @@ -313,7 +314,7 @@ export let MenuButton = defineComponent({ return () => { let slot = { open: api.menuState.value === MenuStates.Open } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { ref: api.buttonRef, id, @@ -344,9 +345,10 @@ export let MenuItems = defineComponent({ as: { type: [Object, String], default: 'div' }, static: { type: Boolean, default: false }, unmount: { type: Boolean, default: true }, - id: { type: String, default: () => `headlessui-menu-items-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-menu-items-${useId()}` let api = useMenuContext('MenuItems') let searchDebounce = ref | null>(null) @@ -463,7 +465,7 @@ export let MenuItems = defineComponent({ return () => { let slot = { open: api.menuState.value === MenuStates.Open } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { 'aria-activedescendant': api.activeItemIndex.value === null @@ -498,9 +500,10 @@ export let MenuItem = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: Boolean, default: false }, - id: { type: String, default: () => `headlessui-menu-item-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { slots, attrs, expose }) { + let id = props.id ?? `headlessui-menu-item-${useId()}` let api = useMenuContext('MenuItem') let internalItemRef = ref(null) @@ -508,7 +511,7 @@ export let MenuItem = defineComponent({ let active = computed(() => { return api.activeItemIndex.value !== null - ? api.items.value[api.activeItemIndex.value].id === props.id + ? api.items.value[api.activeItemIndex.value].id === id : false }) @@ -521,8 +524,8 @@ export let MenuItem = defineComponent({ domRef: internalItemRef, })) - onMounted(() => api.registerItem(props.id, dataRef)) - onUnmounted(() => api.unregisterItem(props.id)) + onMounted(() => api.registerItem(id, dataRef)) + onUnmounted(() => api.unregisterItem(id)) watchEffect(() => { if (api.menuState.value !== MenuStates.Open) return @@ -539,7 +542,7 @@ export let MenuItem = defineComponent({ function handleFocus() { if (props.disabled) return api.goToItem(Focus.Nothing) - api.goToItem(Focus.Specific, props.id) + api.goToItem(Focus.Specific, id) } let pointer = useTrackedPointer() @@ -552,7 +555,7 @@ export let MenuItem = defineComponent({ if (!pointer.wasMoved(evt)) return if (props.disabled) return if (active.value) return - api.goToItem(Focus.Specific, props.id, ActivationTrigger.Pointer) + api.goToItem(Focus.Specific, id, ActivationTrigger.Pointer) } function handleLeave(evt: PointerEvent) { @@ -565,7 +568,7 @@ export let MenuItem = defineComponent({ return () => { let { disabled } = props let slot = { active: active.value, disabled, close: api.closeMenu } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { id, ref: internalItemRef, diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index e14e5f8e73..da07a5e241 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -284,17 +284,18 @@ export let PopoverButton = defineComponent({ props: { as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, - id: { type: String, default: () => `headlessui-popover-button-${useId()}` }, + id: { type: String, default: null }, }, inheritAttrs: false, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-popover-button-${useId()}` let api = usePopoverContext('PopoverButton') let ownerDocument = computed(() => getOwnerDocument(api.button)) expose({ el: api.button, $el: api.button }) onMounted(() => { - api.buttonId.value = props.id + api.buttonId.value = id }) onUnmounted(() => { api.buttonId.value = null @@ -431,7 +432,7 @@ export let PopoverButton = defineComponent({ return () => { let visible = api.popoverState.value === PopoverStates.Open let slot = { open: visible } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = isWithinPanel.value ? { ref: elementRef, @@ -534,10 +535,11 @@ export let PopoverPanel = defineComponent({ static: { type: Boolean, default: false }, unmount: { type: Boolean, default: true }, focus: { type: Boolean, default: false }, - id: { type: String, default: () => `headlessui-popover-panel-${useId()}` }, + id: { type: String, default: null }, }, inheritAttrs: false, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-popover-panel-${useId()}` let { focus } = props let api = usePopoverContext('PopoverPanel') let ownerDocument = computed(() => getOwnerDocument(api.panel)) @@ -548,7 +550,7 @@ export let PopoverPanel = defineComponent({ expose({ el: api.panel, $el: api.panel }) onMounted(() => { - api.panelId.value = props.id + api.panelId.value = id }) onUnmounted(() => { api.panelId.value = null @@ -694,7 +696,7 @@ export let PopoverPanel = defineComponent({ close: api.close, } - let { id, focus: _focus, ...theirProps } = props + let { focus: _focus, ...theirProps } = props let ourProps = { ref: api.panel, id, 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 5479780a23..00aeb472ae 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -80,10 +80,12 @@ export let RadioGroup = defineComponent({ defaultValue: { type: [Object, String, Number, Boolean], default: undefined }, form: { type: String, optional: true }, name: { type: String, optional: true }, - id: { type: String, default: () => `headlessui-radiogroup-${useId()}` }, + id: { type: String, default: null }, }, inheritAttrs: false, setup(props, { emit, attrs, slots, expose }) { + let id = props.id ?? `headlessui-radiogroup-${useId()}` + let radioGroupRef = ref(null) let options = ref([]) let labelledby = useLabels({ name: 'RadioGroupLabel' }) @@ -238,7 +240,7 @@ export let RadioGroup = defineComponent({ }) return () => { - let { disabled, name, id, form, ...theirProps } = props + let { disabled, name, form, ...theirProps } = props let ourProps = { ref: radioGroupRef, @@ -294,9 +296,11 @@ export let RadioGroupOption = defineComponent({ as: { type: [Object, String], default: 'div' }, value: { type: [Object, String, Number, Boolean] }, disabled: { type: Boolean, default: false }, - id: { type: String, default: () => `headlessui-radiogroup-option-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-radiogroup-option-${useId()}` + let api = useRadioGroupContext('RadioGroupOption') let labelledby = useLabels({ name: 'RadioGroupLabel' }) let describedby = useDescriptions({ name: 'RadioGroupDescription' }) @@ -308,10 +312,10 @@ export let RadioGroupOption = defineComponent({ expose({ el: optionRef, $el: optionRef }) let element = computed(() => dom(optionRef)) - onMounted(() => api.registerOption({ id: props.id, element, propsRef })) - onUnmounted(() => api.unregisterOption(props.id)) + onMounted(() => api.registerOption({ id, element, propsRef })) + onUnmounted(() => api.unregisterOption(id)) - let isFirstOption = computed(() => api.firstOption.value?.id === props.id) + let isFirstOption = computed(() => api.firstOption.value?.id === id) let disabled = computed(() => api.disabled.value || props.disabled) let checked = computed(() => api.compare(toRaw(api.value.value), toRaw(props.value))) let tabIndex = computed(() => { @@ -337,7 +341,7 @@ export let RadioGroupOption = defineComponent({ } return () => { - let { id, value: _value, disabled: _disabled, ...theirProps } = props + let { value: _value, disabled: _disabled, ...theirProps } = props let slot = { checked: checked.value, diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts index 2fc60960a5..7613fc26fc 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.ts +++ b/packages/@headlessui-vue/src/components/switch/switch.ts @@ -77,10 +77,12 @@ export let Switch = defineComponent({ form: { type: String, optional: true }, name: { type: String, optional: true }, value: { type: String, optional: true }, - id: { type: String, default: () => `headlessui-switch-${useId()}` }, + id: { type: String, default: null }, }, inheritAttrs: false, setup(props, { emit, attrs, slots, expose }) { + let id = props.id ?? `headlessui-switch-${useId()}` + let api = inject(GroupContext, null) let [checked, theirOnChange] = useControllable( @@ -143,7 +145,7 @@ export let Switch = defineComponent({ }) return () => { - let { id, name, value, form, ...theirProps } = props + let { name, value, form, ...theirProps } = props let slot = { checked: checked.value } let ourProps = { id, diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts index bfe94b0c1b..da045125da 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ssr.test.ts @@ -1,10 +1,9 @@ import { defineComponent } from 'vue' +import { provideUseId } from '../../hooks/use-id' import { html } from '../../test-utils/html' import { renderHydrate, renderSSR } from '../../test-utils/ssr' import { Tab, TabGroup, TabList, TabPanel, TabPanels } from './tabs' -jest.mock('../../hooks/use-id') - beforeAll(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) @@ -12,8 +11,23 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) +let uniqueId = 0 + +beforeEach(() => { + uniqueId = 0 +}) + let Example = defineComponent({ - components: { TabGroup, TabList, Tab, TabPanels, TabPanel }, + components: { + TabGroup, + TabList, + Tab, + TabPanels, + TabPanel, + }, + + setup: () => provideUseId(() => `custom-${++uniqueId}`), + template: html` @@ -39,6 +53,18 @@ describe('Rendering', () => { expect(contents).toContain(`Content 1`) expect(contents).not.toContain(`Content 2`) expect(contents).not.toContain(`Content 3`) + + // Make sure our custom IDs are being used + let tabs = Array.from(document.body.querySelectorAll('[role=tab]')) + let panels = Array.from(document.body.querySelectorAll('[role=tabpanel]')) + + expect(tabs[0]).toHaveAttribute('id', 'headlessui-tabs-tab-custom-1') + expect(tabs[1]).toHaveAttribute('id', 'headlessui-tabs-tab-custom-2') + expect(tabs[2]).toHaveAttribute('id', 'headlessui-tabs-tab-custom-3') + + expect(panels[0]).toHaveAttribute('id', 'headlessui-tabs-panel-custom-4') + expect(panels[1]).toHaveAttribute('id', 'headlessui-tabs-panel-custom-5') + expect(panels[2]).toHaveAttribute('id', 'headlessui-tabs-panel-custom-6') }) it('should be possible to server side render the defaultIndex Tab and Panel', async () => { @@ -57,6 +83,18 @@ describe('Rendering', () => { expect(contents).toContain(`Content 1`) expect(contents).not.toContain(`Content 2`) expect(contents).not.toContain(`Content 3`) + + // Make sure our custom IDs are being used even after hydration + let tabs = Array.from(document.body.querySelectorAll('[role=tab]')) + let panels = Array.from(document.body.querySelectorAll('[role=tabpanel]')) + + expect(tabs[0]).toHaveAttribute('id', 'headlessui-tabs-tab-custom-1') + expect(tabs[1]).toHaveAttribute('id', 'headlessui-tabs-tab-custom-2') + expect(tabs[2]).toHaveAttribute('id', 'headlessui-tabs-tab-custom-3') + + expect(panels[0]).toHaveAttribute('id', 'headlessui-tabs-panel-custom-4') + expect(panels[1]).toHaveAttribute('id', 'headlessui-tabs-panel-custom-5') + expect(panels[2]).toHaveAttribute('id', 'headlessui-tabs-panel-custom-6') }) it('should be possible to server side render the defaultIndex Tab and Panel', async () => { diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index ec50dab353..5916e865fb 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -306,9 +306,11 @@ export let Tab = defineComponent({ props: { as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, - id: { type: String, default: () => `headlessui-tabs-tab-${useId()}` }, + id: { type: String, default: null }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-tabs-tab-${useId()}` + let api = useTabsContext('Tab') let internalTabRef = ref(null) @@ -322,8 +324,8 @@ export let Tab = defineComponent({ // Note: there's a divergence here between React and Vue. Vue can work with `indexOf` implementation while React on the server can't. let mySSRIndex = computed(() => { if (SSRContext.value) { - let mySSRIndex = SSRContext.value.tabs.indexOf(props.id) - if (mySSRIndex === -1) return SSRContext.value.tabs.push(props.id) - 1 + let mySSRIndex = SSRContext.value.tabs.indexOf(id) + if (mySSRIndex === -1) return SSRContext.value.tabs.push(id) - 1 return mySSRIndex } @@ -424,7 +426,7 @@ export let Tab = defineComponent({ return () => { let slot = { selected: selected.value } - let { id, ...theirProps } = props + let { ...theirProps } = props let ourProps = { ref: internalTabRef, onKeydown: handleKeyDown, @@ -482,10 +484,12 @@ export let TabPanel = defineComponent({ as: { type: [Object, String], default: 'div' }, static: { type: Boolean, default: false }, unmount: { type: Boolean, default: true }, - id: { type: String, default: () => `headlessui-tabs-panel-${useId()}` }, + id: { type: String, default: null }, tabIndex: { type: Number, default: 0 }, }, setup(props, { attrs, slots, expose }) { + let id = props.id ?? `headlessui-tabs-panel-${useId()}` + let api = useTabsContext('TabPanel') let internalPanelRef = ref(null) @@ -498,8 +502,8 @@ export let TabPanel = defineComponent({ let SSRContext = inject(TabsSSRContext)! let mySSRIndex = computed(() => { if (SSRContext.value) { - let mySSRIndex = SSRContext.value.panels.indexOf(props.id) - if (mySSRIndex === -1) return SSRContext.value.panels.push(props.id) - 1 + let mySSRIndex = SSRContext.value.panels.indexOf(id) + if (mySSRIndex === -1) return SSRContext.value.panels.push(id) - 1 return mySSRIndex } @@ -515,7 +519,7 @@ export let TabPanel = defineComponent({ return () => { let slot = { selected: selected.value } - let { id, tabIndex, ...theirProps } = props + let { tabIndex, ...theirProps } = props let ourProps = { ref: internalPanelRef, id, diff --git a/packages/@headlessui-vue/src/hooks/use-id.ts b/packages/@headlessui-vue/src/hooks/use-id.ts index 2c6f6ef5a9..3d0847b733 100644 --- a/packages/@headlessui-vue/src/hooks/use-id.ts +++ b/packages/@headlessui-vue/src/hooks/use-id.ts @@ -1,8 +1,24 @@ -let id = 0 -function generateId() { - return ++id -} +import { inject, InjectionKey, provide } from 'vue' + +let GENERATE_ID: InjectionKey<() => string> = Symbol('headlessui.useid') + +let globalId = 0 export function useId() { + let generateId = inject(GENERATE_ID, () => { + return `${++globalId}` + }) + return generateId() } + +/** + * This function is allows users to provide a custom id generator + * as a workaround for the lack of stable SSR IDs in Vue 3.x + * + * This lets users of Nuxt swap in the Nuxt `useId` function + * which is stable across SSR and client. + */ +export function provideUseId(fn: () => string) { + provide(GENERATE_ID, fn) +} diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index 607f559a34..cac617f63f 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -10,6 +10,9 @@ it('should expose the correct components', () => { 'Portal', 'PortalGroup', + // provideUseId + 'provideUseId', + // Combobox 'Combobox', 'ComboboxLabel', diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts index 4cae2904e9..46ec5b78a0 100644 --- a/packages/@headlessui-vue/src/index.ts +++ b/packages/@headlessui-vue/src/index.ts @@ -10,3 +10,4 @@ export * from './components/radio-group/radio-group' export * from './components/switch/switch' export * from './components/tabs/tabs' export * from './components/transitions/transition' +export { provideUseId } from './hooks/use-id'