From a02b1ba4c50d8551274080852f127d383e21427b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Dec 2022 17:14:13 +0100 Subject: [PATCH 1/4] accept `id` as a prop where it is currently hardcoded (React) Continuation of #2020 Co-authored-by: Olivier Louvignes --- .../src/components/combobox/combobox.tsx | 35 +++---- .../components/description/description.tsx | 5 +- .../src/components/dialog/dialog.tsx | 35 ++++--- .../src/components/disclosure/disclosure.tsx | 56 +++++------ .../src/components/label/label.tsx | 5 +- .../src/components/listbox/listbox.tsx | 23 +++-- .../src/components/menu/menu.tsx | 12 +-- .../src/components/popover/popover.test.tsx | 10 +- .../src/components/popover/popover.tsx | 92 ++++++++++--------- .../components/radio-group/radio-group.tsx | 13 ++- .../src/components/switch/switch.tsx | 3 +- .../src/components/tabs/tabs.tsx | 12 +-- 12 files changed, 152 insertions(+), 149 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 0d5f573b3..8732d915c 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -657,7 +657,6 @@ interface InputRenderPropArg { disabled: boolean } type InputPropsWeControl = - | 'id' | 'role' | 'aria-labelledby' | 'aria-expanded' @@ -678,7 +677,13 @@ let Input = forwardRefWithAs(function Input< }, ref: Ref ) { - let { value, onChange, displayValue, type = 'text', ...theirProps } = props + let { + id = `headlessui-combobox-input-${useId()}`, + onChange, + displayValue, + type = 'text', + ...theirProps + } = props let data = useData('Combobox.Input') let actions = useActions('Combobox.Input') @@ -686,7 +691,6 @@ let Input = forwardRefWithAs(function Input< let isTyping = useRef(false) - let id = `headlessui-combobox-input-${useId()}` let d = useDisposables() // When a `displayValue` prop is given, we should use it to transform the current selected @@ -931,7 +935,6 @@ interface ButtonRenderPropArg { value: any } type ButtonPropsWeControl = - | 'id' | 'type' | 'tabIndex' | 'aria-haspopup' @@ -949,8 +952,7 @@ let Button = forwardRefWithAs(function Button) => { @@ -1017,7 +1019,6 @@ let Button = forwardRefWithAs(function Button( props: Props, ref: Ref ) { + let { id = `headlessui-combobox-label-${useId()}`, ...theirProps } = props let data = useData('Combobox.Label') - let id = `headlessui-combobox-label-${useId()}` let actions = useActions('Combobox.Label') let labelRef = useSyncRefs(data.labelRef, ref) @@ -1068,7 +1069,6 @@ let Label = forwardRefWithAs(function Label ) { - let { hold = false, ...theirProps } = props + let { id = `headlessui-combobox-options-${useId()}`, hold = false, ...theirProps } = props let data = useData('Combobox.Options') let optionsRef = useSyncRefs(data.optionsRef, ref) - let id = `headlessui-combobox-options-${useId()}` - let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { @@ -1179,7 +1176,7 @@ interface OptionRenderPropArg { selected: boolean disabled: boolean } -type ComboboxOptionPropsWeControl = 'id' | 'role' | 'tabIndex' | 'aria-disabled' | 'aria-selected' +type ComboboxOptionPropsWeControl = 'role' | 'tabIndex' | 'aria-disabled' | 'aria-selected' let Option = forwardRefWithAs(function Option< TTag extends ElementType = typeof DEFAULT_OPTION_TAG, @@ -1193,11 +1190,15 @@ let Option = forwardRefWithAs(function Option< }, ref: Ref ) { - let { disabled = false, value, ...theirProps } = props + let { + id = `headlessui-combobox-option-${useId()}`, + disabled = false, + value, + ...theirProps + } = props let data = useData('Combobox.Option') let actions = useActions('Combobox.Option') - let id = `headlessui-combobox-option-${useId()}` let active = data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false diff --git a/packages/@headlessui-react/src/components/description/description.tsx b/packages/@headlessui-react/src/components/description/description.tsx index eb2885866..4303dd47a 100644 --- a/packages/@headlessui-react/src/components/description/description.tsx +++ b/packages/@headlessui-react/src/components/description/description.tsx @@ -91,14 +91,13 @@ let DEFAULT_DESCRIPTION_TAG = 'p' as const export let Description = forwardRefWithAs(function Description< TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG ->(props: Props, ref: Ref) { +>(props: Props, ref: Ref) { + let { id = `headlessui-description-${useId()}`, ...theirProps } = props let context = useDescriptionContext() - let id = `headlessui-description-${useId()}` let descriptionRef = useSyncRefs(ref) useIsoMorphicEffect(() => context.register(id), [id, context.register]) - let theirProps = props let ourProps = { ref: descriptionRef, ...context.props, id } return render({ diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 5d70ca5aa..cde791268 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -140,7 +140,7 @@ let DEFAULT_DIALOG_TAG = 'div' as const interface DialogRenderPropArg { open: boolean } -type DialogPropsWeControl = 'id' | 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby' +type DialogPropsWeControl = 'role' | 'aria-modal' | 'aria-describedby' | 'aria-labelledby' let DialogRenderFeatures = Features.RenderStrategy | Features.Static @@ -156,7 +156,14 @@ let DialogRoot = forwardRefWithAs(function Dialog< }, ref: Ref ) { - let { open, onClose, initialFocus, __demoMode = false, ...theirProps } = props + let { + id = `headlessui-dialog-${useId()}`, + open, + onClose, + initialFocus, + __demoMode = false, + ...theirProps + } = props let [nestedDialogCount, setNestedDialogCount] = useState(0) let usesOpenClosedState = useOpenClosed() @@ -295,8 +302,6 @@ let DialogRoot = forwardRefWithAs(function Dialog< let [describedby, DescriptionProvider] = useDescriptions() - let id = `headlessui-dialog-${useId()}` - let contextBag = useMemo>( () => [{ dialogState, close, setTitleId }, state], [dialogState, state, close, setTitleId] @@ -381,16 +386,15 @@ let DEFAULT_OVERLAY_TAG = 'div' as const interface OverlayRenderPropArg { open: boolean } -type OverlayPropsWeControl = 'id' | 'aria-hidden' | 'onClick' +type OverlayPropsWeControl = 'aria-hidden' | 'onClick' let Overlay = forwardRefWithAs(function Overlay< TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG >(props: Props, ref: Ref) { + let { id = `headlessui-dialog-overlay-${useId()}`, ...theirProps } = props let [{ dialogState, close }] = useDialogContext('Dialog.Overlay') let overlayRef = useSyncRefs(ref) - let id = `headlessui-dialog-overlay-${useId()}` - let handleClick = useEvent((event: ReactMouseEvent) => { if (event.target !== event.currentTarget) return if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() @@ -404,7 +408,6 @@ let Overlay = forwardRefWithAs(function Overlay< [dialogState] ) - let theirProps = props let ourProps = { ref: overlayRef, id, @@ -427,16 +430,15 @@ let DEFAULT_BACKDROP_TAG = 'div' as const interface BackdropRenderPropArg { open: boolean } -type BackdropPropsWeControl = 'id' | 'aria-hidden' | 'onClick' +type BackdropPropsWeControl = 'aria-hidden' | 'onClick' let Backdrop = forwardRefWithAs(function Backdrop< TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG >(props: Props, ref: Ref) { + let { id = `headlessui-dialog-backdrop-${useId()}`, ...theirProps } = props let [{ dialogState }, state] = useDialogContext('Dialog.Backdrop') let backdropRef = useSyncRefs(ref) - let id = `headlessui-dialog-backdrop-${useId()}` - useEffect(() => { if (state.panelRef.current === null) { throw new Error( @@ -450,7 +452,6 @@ let Backdrop = forwardRefWithAs(function Backdrop< [dialogState] ) - let theirProps = props let ourProps = { ref: backdropRef, id, @@ -483,11 +484,10 @@ let Panel = forwardRefWithAs(function Panel, ref: Ref ) { + let { id = `headlessui-dialog-panel-${useId()}`, ...theirProps } = props let [{ dialogState }, state] = useDialogContext('Dialog.Panel') let panelRef = useSyncRefs(ref, state.panelRef) - let id = `headlessui-dialog-panel-${useId()}` - let slot = useMemo( () => ({ open: dialogState === DialogStates.Open }), [dialogState] @@ -499,7 +499,6 @@ let Panel = forwardRefWithAs(function Panel( - props: Props, + props: Props, ref: Ref ) { + let { id = `headlessui-dialog-title-${useId()}`, ...theirProps } = props let [{ dialogState, setTitleId }] = useDialogContext('Dialog.Title') - let id = `headlessui-dialog-title-${useId()}` let titleRef = useSyncRefs(ref) useEffect(() => { @@ -542,7 +540,6 @@ let Title = forwardRefWithAs(function Title panelRef: MutableRefObject - buttonId: string - panelId: string + buttonId: string | null + panelId: string | null } enum ActionTypes { @@ -61,8 +61,8 @@ enum ActionTypes { type Actions = | { type: ActionTypes.ToggleDisclosure } | { type: ActionTypes.CloseDisclosure } - | { type: ActionTypes.SetButtonId; buttonId: string } - | { type: ActionTypes.SetPanelId; panelId: string } + | { type: ActionTypes.SetButtonId; buttonId: string | null } + | { type: ActionTypes.SetPanelId; panelId: string | null } | { type: ActionTypes.LinkPanel } | { type: ActionTypes.UnlinkPanel } @@ -157,8 +157,6 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure< ref: Ref ) { let { defaultOpen = false, ...theirProps } = props - let buttonId = `headlessui-disclosure-button-${useId()}` - let panelId = `headlessui-disclosure-panel-${useId()}` let internalDisclosureRef = useRef(null) let disclosureRef = useSyncRefs( ref, @@ -180,18 +178,16 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure< linkedPanel: false, buttonRef, panelRef, - buttonId, - panelId, + buttonId: null, + panelId: null, } as StateDefinition) - let [{ disclosureState }, dispatch] = reducerBag - - useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch]) - useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch]) + let [{ disclosureState, buttonId }, dispatch] = reducerBag let close = useEvent((focusableElement?: HTMLElement | MutableRefObject) => { dispatch({ type: ActionTypes.CloseDisclosure }) let ownerDocument = getOwnerDocument(internalDisclosureRef) if (!ownerDocument) return + if (!buttonId) return let restoreElement = (() => { if (!focusableElement) return ownerDocument.getElementById(buttonId) @@ -243,18 +239,13 @@ let DEFAULT_BUTTON_TAG = 'button' as const interface ButtonRenderPropArg { open: boolean } -type ButtonPropsWeControl = - | 'id' - | 'type' - | 'aria-expanded' - | 'aria-controls' - | 'onKeyDown' - | 'onClick' +type ButtonPropsWeControl = 'type' | 'aria-expanded' | 'aria-controls' | 'onKeyDown' | 'onClick' let Button = forwardRefWithAs(function Button( props: Props, ref: Ref ) { + let { id = `headlessui-disclosure-button-${useId()}`, ...theirProps } = props let [state, dispatch] = useDisclosureContext('Disclosure.Button') let panelContext = useDisclosurePanelContext() let isWithinPanel = panelContext === null ? false : panelContext === state.panelId @@ -262,6 +253,15 @@ let Button = forwardRefWithAs(function Button(null) let buttonRef = useSyncRefs(internalButtonRef, ref, !isWithinPanel ? state.buttonRef : null) + useEffect(() => { + if (isWithinPanel) return + + dispatch({ type: ActionTypes.SetButtonId, buttonId: id }) + return () => { + dispatch({ type: ActionTypes.SetButtonId, buttonId: null }) + } + }, [id, dispatch, isWithinPanel]) + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { if (isWithinPanel) { if (state.disclosureState === DisclosureStates.Closed) return @@ -316,12 +316,11 @@ let Button = forwardRefWithAs(function Button) => void } -type PanelPropsWeControl = 'id' let PanelRenderFeatures = Features.RenderStrategy | Features.Static let Panel = forwardRefWithAs(function Panel( - props: Props & - PropsForFeatures, + props: Props & PropsForFeatures, ref: Ref ) { + let { id = `headlessui-disclosure-panel-${useId()}`, ...theirProps } = props let [state, dispatch] = useDisclosureContext('Disclosure.Panel') let { close } = useDisclosureAPIContext('Disclosure.Panel') @@ -364,6 +362,13 @@ let Panel = forwardRefWithAs(function Panel { + dispatch({ type: ActionTypes.SetPanelId, panelId: id }) + return () => { + dispatch({ type: ActionTypes.SetPanelId, panelId: null }) + } + }, [id, dispatch]) + let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { @@ -378,10 +383,9 @@ let Panel = forwardRefWithAs(function Panel( - props: Props & { + props: Props & { passive?: boolean }, ref: Ref ) { - let { passive = false, ...theirProps } = props + let { id = `headlessui-label-${useId()}`, passive = false, ...theirProps } = props let context = useLabelContext() - let id = `headlessui-label-${useId()}` let labelRef = useSyncRefs(ref) useIsoMorphicEffect(() => context.register(id), [id, context.register]) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index f78a7231a..de41860e7 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -578,7 +578,6 @@ interface ButtonRenderPropArg { value: any } type ButtonPropsWeControl = - | 'id' | 'type' | 'aria-haspopup' | 'aria-controls' @@ -592,11 +591,11 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { + let { id = `headlessui-listbox-button-${useId()}`, ...theirProps } = props let data = useData('Listbox.Button') let actions = useActions('Listbox.Button') let buttonRef = useSyncRefs(data.buttonRef, ref) - let id = `headlessui-listbox-button-${useId()}` let d = useDisposables() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { @@ -658,7 +657,7 @@ let Button = forwardRefWithAs(function Button( props: Props, ref: Ref ) { + let { id = `headlessui-listbox-label-${useId()}`, ...theirProps } = props let data = useData('Listbox.Label') - let id = `headlessui-listbox-label-${useId()}` let actions = useActions('Listbox.Label') let labelRef = useSyncRefs(data.labelRef, ref) @@ -708,7 +707,6 @@ let Label = forwardRefWithAs(function Label ({ open: data.listboxState === ListboxStates.Open, disabled: data.disabled }), [data] ) - let theirProps = props let ourProps = { ref: labelRef, id, onClick: handleClick } return render({ @@ -730,7 +728,6 @@ type OptionsPropsWeControl = | 'aria-activedescendant' | 'aria-labelledby' | 'aria-orientation' - | 'id' | 'onKeyDown' | 'role' | 'tabIndex' @@ -744,11 +741,11 @@ let Options = forwardRefWithAs(function Options< PropsForFeatures, ref: Ref ) { + let { id = `headlessui-listbox-options-${useId()}`, ...theirProps } = props let data = useData('Listbox.Options') let actions = useActions('Listbox.Options') let optionsRef = useSyncRefs(data.optionsRef, ref) - let id = `headlessui-listbox-options-${useId()}` let d = useDisposables() let searchDisposables = useDisposables() @@ -850,7 +847,6 @@ let Options = forwardRefWithAs(function Options< [data] ) - let theirProps = props let ourProps = { 'aria-activedescendant': data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id, @@ -884,7 +880,6 @@ interface OptionRenderPropArg { disabled: boolean } type ListboxOptionPropsWeControl = - | 'id' | 'role' | 'tabIndex' | 'aria-disabled' @@ -907,11 +902,15 @@ let Option = forwardRefWithAs(function Option< }, ref: Ref ) { - let { disabled = false, value, ...theirProps } = props + let { + id = `headlessui-listbox-option-${useId()}`, + disabled = false, + value, + ...theirProps + } = props let data = useData('Listbox.Option') let actions = useActions('Listbox.Option') - let id = `headlessui-listbox-option-${useId()}` let active = data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 014a8b1f2..f9ec37dab 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -299,7 +299,6 @@ interface ButtonRenderPropArg { open: boolean } type ButtonPropsWeControl = - | 'id' | 'type' | 'aria-haspopup' | 'aria-controls' @@ -311,10 +310,10 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { + let { id = `headlessui-menu-button-${useId()}`, ...theirProps } = props let [state, dispatch] = useMenuContext('Menu.Button') let buttonRef = useSyncRefs(state.buttonRef, ref) - let id = `headlessui-menu-button-${useId()}` let d = useDisposables() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { @@ -366,7 +365,6 @@ let Button = forwardRefWithAs(function Button ({ open: state.menuState === MenuStates.Open }), [state] ) - let theirProps = props let ourProps = { ref: buttonRef, id, @@ -397,7 +395,6 @@ interface ItemsRenderPropArg { type ItemsPropsWeControl = | 'aria-activedescendant' | 'aria-labelledby' - | 'id' | 'onKeyDown' | 'role' | 'tabIndex' @@ -409,11 +406,11 @@ let Items = forwardRefWithAs(function Items, ref: Ref ) { + let { id = `headlessui-menu-items-${useId()}`, ...theirProps } = props let [state, dispatch] = useMenuContext('Menu.Items') let itemsRef = useSyncRefs(state.itemsRef, ref) let ownerDocument = useOwnerDocument(state.itemsRef) - let id = `headlessui-menu-items-${useId()}` let searchDisposables = useDisposables() let usesOpenClosedState = useOpenClosed() @@ -538,7 +535,6 @@ let Items = forwardRefWithAs(function Items void } type MenuItemPropsWeControl = - | 'id' | 'role' | 'tabIndex' | 'aria-disabled' @@ -587,9 +582,8 @@ let Item = forwardRefWithAs(function Item ) { - let { disabled = false, ...theirProps } = props + let { id = `headlessui-menu-item-${useId()}`, disabled = false, ...theirProps } = props let [state, dispatch] = useMenuContext('Menu.Item') - let id = `headlessui-menu-item-${useId()}` let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false let internalItemRef = useRef(null) let itemRef = useSyncRefs(ref, internalItemRef) diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx index 5ba49c4e8..9ed734dde 100644 --- a/packages/@headlessui-react/src/components/popover/popover.test.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx @@ -859,7 +859,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) // Close popover @@ -925,7 +925,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) // Close popover @@ -1835,7 +1835,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) }) ) @@ -1897,7 +1897,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) // Close popover @@ -2043,7 +2043,7 @@ describe('Mouse interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) }) ) diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index c413817a0..b8b57ed4c 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -44,6 +44,7 @@ import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { useEvent } from '../../hooks/use-event' import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab-direction' import { microTask } from '../../utils/micro-task' +import { useLatestValue } from '../../hooks/use-latest-value' type MouseEvent = Parameters>[0] @@ -55,12 +56,12 @@ enum PopoverStates { interface StateDefinition { popoverState: PopoverStates - buttons: HTMLElement[] + buttons: string[] button: HTMLElement | null - buttonId: string + buttonId: string | null panel: HTMLElement | null - panelId: string + panelId: string | null beforePanelSentinel: MutableRefObject afterPanelSentinel: MutableRefObject @@ -80,9 +81,9 @@ type Actions = | { type: ActionTypes.TogglePopover } | { type: ActionTypes.ClosePopover } | { type: ActionTypes.SetButton; button: HTMLElement | null } - | { type: ActionTypes.SetButtonId; buttonId: string } + | { type: ActionTypes.SetButtonId; buttonId: string | null } | { type: ActionTypes.SetPanel; panel: HTMLElement | null } - | { type: ActionTypes.SetPanelId; panelId: string } + | { type: ActionTypes.SetPanelId; panelId: string | null } let reducers: { [P in ActionTypes]: ( @@ -170,8 +171,8 @@ function usePopoverPanelContext() { } interface PopoverRegisterBag { - buttonId: string - panelId: string + buttonId: MutableRefObject + panelId: MutableRefObject close(): void } function stateReducer(state: StateDefinition, action: Actions) { @@ -191,8 +192,6 @@ interface PopoverRenderPropArg { let PopoverRoot = forwardRefWithAs(function Popover< TTag extends ElementType = typeof DEFAULT_POPOVER_TAG >(props: Props, ref: Ref) { - let buttonId = `headlessui-popover-button-${useId()}` - let panelId = `headlessui-popover-panel-${useId()}` let internalPopoverRef = useRef(null) let popoverRef = useSyncRefs( ref, @@ -205,20 +204,19 @@ let PopoverRoot = forwardRefWithAs(function Popover< popoverState: PopoverStates.Closed, buttons: [], button: null, - buttonId, + buttonId: null, panel: null, - panelId, + panelId: null, beforePanelSentinel: createRef(), afterPanelSentinel: createRef(), } as StateDefinition) - let [{ popoverState, button, panel, beforePanelSentinel, afterPanelSentinel }, dispatch] = - reducerBag + let [ + { popoverState, button, buttonId, panel, panelId, beforePanelSentinel, afterPanelSentinel }, + dispatch, + ] = reducerBag let ownerDocument = useOwnerDocument(internalPopoverRef.current ?? button) - useEffect(() => dispatch({ type: ActionTypes.SetButtonId, buttonId }), [buttonId, dispatch]) - useEffect(() => dispatch({ type: ActionTypes.SetPanelId, panelId }), [panelId, dispatch]) - let isPortalled = useMemo(() => { if (!button) return false if (!panel) return false @@ -254,9 +252,16 @@ let PopoverRoot = forwardRefWithAs(function Popover< return false }, [button, panel]) + let buttonIdRef = useLatestValue(buttonId) + let panelIdRef = useLatestValue(panelId) + let registerBag = useMemo( - () => ({ buttonId, panelId, close: () => dispatch({ type: ActionTypes.ClosePopover }) }), - [buttonId, panelId, dispatch] + () => ({ + buttonId: buttonIdRef, + panelId: panelIdRef, + close: () => dispatch({ type: ActionTypes.ClosePopover }), + }), + [buttonIdRef, panelIdRef, dispatch] ) let groupContext = usePopoverGroupContext() @@ -367,18 +372,13 @@ let DEFAULT_BUTTON_TAG = 'button' as const interface ButtonRenderPropArg { open: boolean } -type ButtonPropsWeControl = - | 'id' - | 'type' - | 'aria-expanded' - | 'aria-controls' - | 'onKeyDown' - | 'onClick' +type ButtonPropsWeControl = 'type' | 'aria-expanded' | 'aria-controls' | 'onKeyDown' | 'onClick' let Button = forwardRefWithAs(function Button( props: Props, ref: Ref ) { + let { id = `headlessui-popover-button-${useId()}`, ...theirProps } = props let [state, dispatch] = usePopoverContext('Popover.Button') let { isPortalled } = usePopoverAPIContext('Popover.Button') let internalButtonRef = useRef(null) @@ -391,7 +391,14 @@ let Button = forwardRefWithAs(function Button { + if (isWithinPanel) return + dispatch({ type: ActionTypes.SetButtonId, buttonId: id }) + return () => { + dispatch({ type: ActionTypes.SetButtonId, buttonId: null }) + } + }, [id, dispatch]) + let buttonRef = useSyncRefs( internalButtonRef, ref, @@ -436,12 +443,12 @@ let Button = forwardRefWithAs(function Button(() => ({ open: visible }), [visible]) let type = useResolveButtonType(props, internalButtonRef) - let theirProps = props let ourProps = isWithinPanel ? { ref: withinPanelButtonRef, @@ -559,7 +565,7 @@ let DEFAULT_OVERLAY_TAG = 'div' as const interface OverlayRenderPropArg { open: boolean } -type OverlayPropsWeControl = 'id' | 'aria-hidden' | 'onClick' +type OverlayPropsWeControl = 'aria-hidden' | 'onClick' let OverlayRenderFeatures = Features.RenderStrategy | Features.Static @@ -570,11 +576,10 @@ let Overlay = forwardRefWithAs(function Overlay< PropsForFeatures, ref: Ref ) { + let { id = `headlessui-popover-overlay-${useId()}`, ...theirProps } = props let [{ popoverState }, dispatch] = usePopoverContext('Popover.Overlay') let overlayRef = useSyncRefs(ref) - let id = `headlessui-popover-overlay-${useId()}` - let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { @@ -594,7 +599,6 @@ let Overlay = forwardRefWithAs(function Overlay< [popoverState] ) - let theirProps = props let ourProps = { ref: overlayRef, id, @@ -620,7 +624,7 @@ interface PanelRenderPropArg { open: boolean close: (focusableElement?: HTMLElement | MutableRefObject) => void } -type PanelPropsWeControl = 'id' | 'onKeyDown' +type PanelPropsWeControl = 'onKeyDown' let PanelRenderFeatures = Features.RenderStrategy | Features.Static @@ -631,7 +635,7 @@ let Panel = forwardRefWithAs(function Panel ) { - let { focus = false, ...theirProps } = props + let { id = `headlessui-popover-panel-${useId()}`, focus = false, ...theirProps } = props let [state, dispatch] = usePopoverContext('Popover.Panel') let { close, isPortalled } = usePopoverAPIContext('Popover.Panel') @@ -645,6 +649,13 @@ let Panel = forwardRefWithAs(function Panel { + dispatch({ type: ActionTypes.SetPanelId, panelId: id }) + return () => { + dispatch({ type: ActionTypes.SetPanelId, panelId: null }) + } + }, [id, dispatch]) + let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { @@ -831,10 +842,9 @@ let Panel = forwardRefWithAs(function Panel( - props: Props, + props: Props, ref: Ref ) { let internalGroupRef = useRef(null) @@ -868,15 +878,15 @@ let Group = forwardRefWithAs(function Group { return ( - ownerDocument!.getElementById(bag.buttonId)?.contains(element) || - ownerDocument!.getElementById(bag.panelId)?.contains(element) + ownerDocument!.getElementById(bag.buttonId.current!)?.contains(element) || + ownerDocument!.getElementById(bag.panelId.current!)?.contains(element) ) }) }) let closeOthers = useEvent((buttonId: string) => { for (let popover of popovers) { - if (popover.buttonId !== buttonId) popover.close() + if (popover.buttonId.current !== buttonId) popover.close() } }) diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index 493d62bcc..97f7f0678 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -131,7 +131,7 @@ let DEFAULT_RADIO_GROUP_TAG = 'div' as const interface RadioGroupRenderPropArg { value: TType } -type RadioGroupPropsWeControl = 'role' | 'aria-labelledby' | 'aria-describedby' | 'id' +type RadioGroupPropsWeControl = 'role' | 'aria-labelledby' | 'aria-describedby' let RadioGroupRoot = forwardRefWithAs(function RadioGroup< TTag extends ElementType = typeof DEFAULT_RADIO_GROUP_TAG, @@ -152,6 +152,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< ref: Ref ) { let { + id = `headlessui-radiogroup-${useId()}`, value: controlledValue, defaultValue, name, @@ -172,7 +173,6 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< let options = state.options as unknown as Option[] let [labelledby, LabelProvider] = useLabels() let [describedby, DescriptionProvider] = useDescriptions() - let id = `headlessui-radiogroup-${useId()}` let internalRadioGroupRef = useRef(null) let radioGroupRef = useSyncRefs(internalRadioGroupRef, ref) @@ -372,7 +372,6 @@ interface OptionRenderPropArg { } type RadioPropsWeControl = | 'aria-checked' - | 'id' | 'onBlur' | 'onClick' | 'onFocus' @@ -392,15 +391,19 @@ let Option = forwardRefWithAs(function Option< }, ref: Ref ) { + let { + id = `headlessui-radiogroup-option-${useId()}`, + value, + disabled = false, + ...theirProps + } = props let internalOptionRef = useRef(null) let optionRef = useSyncRefs(internalOptionRef, ref) - let id = `headlessui-radiogroup-option-${useId()}` let [labelledby, LabelProvider] = useLabels() let [describedby, DescriptionProvider] = useDescriptions() let { addFlag, removeFlag, hasFlag } = useFlags(OptionState.Empty) - let { value, disabled = false, ...theirProps } = props let propsRef = useLatestValue({ value, disabled }) let data = useData('RadioGroup.Option') diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index 0ba1e3cb7..69e0a380e 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -88,7 +88,6 @@ interface SwitchRenderPropArg { checked: boolean } type SwitchPropsWeControl = - | 'id' | 'role' | 'tabIndex' | 'aria-checked' @@ -115,6 +114,7 @@ let SwitchRoot = forwardRefWithAs(function Switch< ref: Ref ) { let { + id = `headlessui-switch-${useId()}`, checked: controlledChecked, defaultChecked = false, onChange: controlledOnChange, @@ -122,7 +122,6 @@ let SwitchRoot = forwardRefWithAs(function Switch< value, ...theirProps } = props - let id = `headlessui-switch-${useId()}` let groupContext = useContext(GroupContext) let internalSwitchRef = useRef(null) let switchRef = useSyncRefs( diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index eb97906e7..30201e762 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -312,13 +312,13 @@ let DEFAULT_TAB_TAG = 'button' as const interface TabRenderPropArg { selected: boolean } -type TabPropsWeControl = 'id' | 'role' | 'type' | 'aria-controls' | 'aria-selected' | 'tabIndex' +type TabPropsWeControl = 'role' | 'type' | 'aria-controls' | 'aria-selected' | 'tabIndex' let TabRoot = forwardRefWithAs(function Tab( props: Props, ref: Ref ) { - let id = `headlessui-tabs-tab-${useId()}` + let { id = `headlessui-tabs-tab-${useId()}`, ...theirProps } = props let { orientation, activation, selectedIndex, tabs, panels } = useData('Tab') let actions = useActions('Tab') @@ -416,7 +416,6 @@ let TabRoot = forwardRefWithAs(function Tab ({ selected }), [selected]) - let theirProps = props let ourProps = { ref: tabRef, onKeyDown: handleKeyDown, @@ -473,7 +472,7 @@ let DEFAULT_PANEL_TAG = 'div' as const interface PanelRenderPropArg { selected: boolean } -type PanelPropsWeControl = 'id' | 'role' | 'aria-labelledby' | 'tabIndex' +type PanelPropsWeControl = 'role' | 'aria-labelledby' | 'tabIndex' let PanelRenderFeatures = Features.RenderStrategy | Features.Static let Panel = forwardRefWithAs(function Panel( @@ -481,11 +480,11 @@ let Panel = forwardRefWithAs(function Panel, ref: Ref ) { + let { id = `headlessui-tabs-panel-${useId()}`, ...theirProps } = props let { selectedIndex, tabs, panels } = useData('Tab.Panel') let actions = useActions('Tab.Panel') let SSRContext = useSSRTabsCounter('Tab.Panel') - let id = `headlessui-tabs-panel-${useId()}` let internalPanelRef = useRef(null) let panelRef = useSyncRefs(internalPanelRef, ref) @@ -501,7 +500,6 @@ let Panel = forwardRefWithAs(function Panel ({ selected }), [selected]) - let theirProps = props let ourProps = { ref: panelRef, id, @@ -510,7 +508,7 @@ let Panel = forwardRefWithAs(function Panel } From 289752d8dc3c3cc0252cb9db21b59c8cb80fb449 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Dec 2022 18:22:26 +0100 Subject: [PATCH 2/4] accept `id` as a prop where it is currently hardcoded (Vue) --- .../src/components/combobox/combobox.ts | 16 ++-- .../src/components/description/description.ts | 6 +- .../src/components/dialog/dialog.ts | 23 +++-- .../src/components/disclosure/disclosure.ts | 55 ++++++++---- .../src/components/label/label.ts | 6 +- .../src/components/listbox/listbox.ts | 39 +++++---- .../src/components/menu/menu.ts | 24 +++--- .../src/components/popover/popover.test.ts | 10 +-- .../src/components/popover/popover.ts | 85 ++++++++++++------- .../src/components/radio-group/radio-group.ts | 15 ++-- .../src/components/switch/switch.ts | 4 +- .../src/components/tabs/tabs.ts | 10 ++- 12 files changed, 170 insertions(+), 123 deletions(-) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index d9786c975..2e5b12f85 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -492,10 +492,12 @@ export let Combobox = defineComponent({ export let ComboboxLabel = defineComponent({ name: 'ComboboxLabel', - props: { as: { type: [Object, String], default: 'label' } }, + props: { + as: { type: [Object, String], default: 'label' }, + id: { type: String, default: () => `headlessui-combobox-label-${useId()}` }, + }, setup(props, { attrs, slots }) { let api = useComboboxContext('ComboboxLabel') - let id = `headlessui-combobox-label-${useId()}` function handleClick() { dom(api.inputRef)?.focus({ preventScroll: true }) @@ -507,8 +509,8 @@ export let ComboboxLabel = defineComponent({ disabled: api.disabled.value, } + let { id, ...theirProps } = props let ourProps = { id, ref: api.labelRef, onClick: handleClick } - let theirProps = props return render({ ourProps, @@ -528,10 +530,10 @@ export let ComboboxButton = defineComponent({ name: 'ComboboxButton', props: { as: { type: [Object, String], default: 'button' }, + id: { type: String, default: () => `headlessui-combobox-button-${useId()}` }, }, setup(props, { attrs, slots, expose }) { let api = useComboboxContext('ComboboxButton') - let id = `headlessui-combobox-button-${useId()}` expose({ el: api.buttonRef, $el: api.buttonRef }) @@ -597,6 +599,7 @@ export let ComboboxButton = defineComponent({ disabled: api.disabled.value, value: api.value.value, } + let { id, ...theirProps } = props let ourProps = { ref: api.buttonRef, id, @@ -612,7 +615,6 @@ export let ComboboxButton = defineComponent({ onKeydown: handleKeydown, onClick: handleClick, } - let theirProps = props return render({ ourProps, @@ -636,13 +638,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()}` }, }, emits: { change: (_value: Event & { target: HTMLInputElement }) => true, }, setup(props, { emit, attrs, slots, expose }) { let api = useComboboxContext('ComboboxInput') - let id = `headlessui-combobox-input-${useId()}` let isTyping = { value: false } @@ -869,6 +871,7 @@ export let ComboboxInput = defineComponent({ return () => { let slot = { open: api.comboboxState.value === ComboboxStates.Open } + let { id, displayValue, ...theirProps } = props let ourProps = { 'aria-controls': api.optionsRef.value?.id, 'aria-expanded': api.disabled.value @@ -893,7 +896,6 @@ export let ComboboxInput = defineComponent({ ref: api.inputRef, defaultValue: defaultValue.value, } - let theirProps = omit(props, ['displayValue']) return render({ ourProps, diff --git a/packages/@headlessui-vue/src/components/description/description.ts b/packages/@headlessui-vue/src/components/description/description.ts index 99f1dc7f8..b68046f13 100644 --- a/packages/@headlessui-vue/src/components/description/description.ts +++ b/packages/@headlessui-vue/src/components/description/description.ts @@ -69,16 +69,16 @@ export let Description = defineComponent({ name: 'Description', props: { as: { type: [Object, String], default: 'p' }, + id: { type: String, default: () => `headlessui-description-${useId()}` }, }, setup(myProps, { attrs, slots }) { let context = useDescriptionContext() - let id = `headlessui-description-${useId()}` - onMounted(() => onUnmounted(context.register(id))) + onMounted(() => onUnmounted(context.register(myProps.id))) return () => { let { name = 'Description', slot = ref({}), props = {} } = context - let theirProps = myProps + let { 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 2be47816f..d59767b71 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -77,6 +77,7 @@ 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()}` }, }, emits: { close: (_close: boolean) => true }, setup(props, { emit, attrs, slots, expose }) { @@ -165,8 +166,6 @@ export let Dialog = defineComponent({ slot: computed(() => ({ open: open.value })), }) - let id = `headlessui-dialog-${useId()}` - let titleId = ref(null) let api = { @@ -284,6 +283,7 @@ export let Dialog = defineComponent({ }) return () => { + let { id, 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. @@ -295,7 +295,6 @@ export let Dialog = defineComponent({ 'aria-labelledby': titleId.value, 'aria-describedby': describedby.value, } - let { open: _, initialFocus, ...theirProps } = props let slot = { open: dialogState.value === DialogStates.Open } @@ -342,10 +341,10 @@ export let DialogOverlay = defineComponent({ name: 'DialogOverlay', props: { as: { type: [Object, String], default: 'div' }, + id: { type: String, default: () => `headlessui-dialog-overlay-${useId()}` }, }, setup(props, { attrs, slots }) { let api = useDialogContext('DialogOverlay') - let id = `headlessui-dialog-overlay-${useId()}` function handleClick(event: MouseEvent) { if (event.target !== event.currentTarget) return @@ -355,12 +354,12 @@ export let DialogOverlay = defineComponent({ } return () => { + let { id, ...theirProps } = props let ourProps = { id, 'aria-hidden': true, onClick: handleClick, } - let theirProps = props return render({ ourProps, @@ -380,11 +379,11 @@ export let DialogBackdrop = defineComponent({ name: 'DialogBackdrop', props: { as: { type: [Object, String], default: 'div' }, + id: { type: String, default: () => `headlessui-dialog-backdrop-${useId()}` }, }, inheritAttrs: false, setup(props, { attrs, slots, expose }) { let api = useDialogContext('DialogBackdrop') - let id = `headlessui-dialog-backdrop-${useId()}` let internalBackdropRef = ref(null) expose({ el: internalBackdropRef, $el: internalBackdropRef }) @@ -398,7 +397,7 @@ export let DialogBackdrop = defineComponent({ }) return () => { - let theirProps = props + let { id, ...theirProps } = props let ourProps = { id, ref: internalBackdropRef, @@ -427,10 +426,10 @@ export let DialogPanel = defineComponent({ name: 'DialogPanel', props: { as: { type: [Object, String], default: 'div' }, + id: { type: String, default: () => `headlessui-dialog-panel-${useId()}` }, }, setup(props, { attrs, slots, expose }) { let api = useDialogContext('DialogPanel') - let id = `headlessui-dialog-panel-${useId()}` expose({ el: api.panelRef, $el: api.panelRef }) @@ -439,12 +438,12 @@ export let DialogPanel = defineComponent({ } return () => { + let { id, ...theirProps } = props let ourProps = { id, ref: api.panelRef, onClick: handleClick, } - let theirProps = props return render({ ourProps, @@ -464,19 +463,19 @@ export let DialogTitle = defineComponent({ name: 'DialogTitle', props: { as: { type: [Object, String], default: 'h2' }, + id: { type: String, default: () => `headlessui-dialog-title-${useId()}` }, }, setup(props, { attrs, slots }) { let api = useDialogContext('DialogTitle') - let id = `headlessui-dialog-title-${useId()}` onMounted(() => { - api.setTitleId(id) + api.setTitleId(props.id) onUnmounted(() => api.setTitleId(null)) }) return () => { + let { id, ...theirProps } = props let ourProps = { id } - let theirProps = props return render({ ourProps, diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts index 49f8a4d58..210105ec9 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.ts @@ -8,6 +8,8 @@ import { Ref, computed, watchEffect, + onMounted, + onUnmounted, } from 'vue' import { Keys } from '../../keyboard' @@ -27,9 +29,9 @@ interface StateDefinition { // State disclosureState: Ref panel: Ref - panelId: string + panelId: Ref button: Ref - buttonId: string + buttonId: Ref // State mutators toggleDisclosure(): void @@ -53,7 +55,7 @@ function useDisclosureContext(component: string) { return context } -let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey +let DisclosurePanelContext = Symbol('DisclosurePanelContext') as InjectionKey> function useDisclosurePanelContext() { return inject(DisclosurePanelContext, null) } @@ -67,9 +69,6 @@ export let Disclosure = defineComponent({ defaultOpen: { type: [Boolean], default: false }, }, setup(props, { slots, attrs }) { - let buttonId = `headlessui-disclosure-button-${useId()}` - let panelId = `headlessui-disclosure-panel-${useId()}` - let disclosureState = ref( props.defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed ) @@ -77,8 +76,8 @@ export let Disclosure = defineComponent({ let buttonRef = ref(null) let api = { - buttonId, - panelId, + buttonId: ref(null), + panelId: ref(null), disclosureState, panel: panelRef, button: buttonRef, @@ -139,18 +138,28 @@ export let DisclosureButton = defineComponent({ props: { as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, + id: { type: String, default: () => `headlessui-disclosure-button-${useId()}` }, }, setup(props, { attrs, slots, expose }) { let api = useDisclosureContext('DisclosureButton') + onMounted(() => { + api.buttonId.value = props.id + }) + onUnmounted(() => { + api.buttonId.value = null + }) + let panelContext = useDisclosurePanelContext() - let isWithinPanel = panelContext === null ? false : panelContext === api.panelId + let isWithinPanel = computed(() => + panelContext === null ? false : panelContext.value === api.panelId.value + ) let internalButtonRef = ref(null) expose({ el: internalButtonRef, $el: internalButtonRef }) - if (!isWithinPanel) { + if (!isWithinPanel.value) { watchEffect(() => { api.button.value = internalButtonRef.value }) @@ -164,7 +173,7 @@ export let DisclosureButton = defineComponent({ function handleClick() { if (props.disabled) return - if (isWithinPanel) { + if (isWithinPanel.value) { api.toggleDisclosure() dom(api.button)?.focus() } else { @@ -174,7 +183,7 @@ export let DisclosureButton = defineComponent({ function handleKeyDown(event: KeyboardEvent) { if (props.disabled) return - if (isWithinPanel) { + if (isWithinPanel.value) { switch (event.key) { case Keys.Space: case Keys.Enter: @@ -208,7 +217,8 @@ export let DisclosureButton = defineComponent({ return () => { let slot = { open: api.disclosureState.value === DisclosureStates.Open } - let ourProps = isWithinPanel + let { id, ...theirProps } = props + let ourProps = isWithinPanel.value ? { ref: internalButtonRef, type: type.value, @@ -216,13 +226,13 @@ export let DisclosureButton = defineComponent({ onKeydown: handleKeyDown, } : { - id: api.buttonId, + id, ref: internalButtonRef, type: type.value, 'aria-expanded': props.disabled ? undefined : api.disclosureState.value === DisclosureStates.Open, - 'aria-controls': dom(api.panel) ? api.panelId : undefined, + 'aria-controls': dom(api.panel) ? api.panelId.value : undefined, disabled: props.disabled ? true : undefined, onClick: handleClick, onKeydown: handleKeyDown, @@ -231,7 +241,7 @@ export let DisclosureButton = defineComponent({ return render({ ourProps, - theirProps: props, + theirProps, slot, attrs, slots, @@ -249,10 +259,18 @@ export let DisclosurePanel = defineComponent({ as: { type: [Object, String], default: 'div' }, static: { type: Boolean, default: false }, unmount: { type: Boolean, default: true }, + id: { type: String, default: () => `headlessui-disclosure-panel-${useId()}` }, }, setup(props, { attrs, slots, expose }) { let api = useDisclosureContext('DisclosurePanel') + onMounted(() => { + api.panelId.value = props.id + }) + onUnmounted(() => { + api.panelId.value = null + }) + expose({ el: api.panel, $el: api.panel }) provide(DisclosurePanelContext, api.panelId) @@ -268,11 +286,12 @@ export let DisclosurePanel = defineComponent({ return () => { let slot = { open: api.disclosureState.value === DisclosureStates.Open, close: api.close } - let ourProps = { id: api.panelId, ref: api.panel } + let { id, ...theirProps } = props + let ourProps = { id, ref: api.panel } return render({ ourProps, - theirProps: props, + theirProps, slot, attrs, slots, diff --git a/packages/@headlessui-vue/src/components/label/label.ts b/packages/@headlessui-vue/src/components/label/label.ts index 7edb1b9ca..ad5372c9c 100644 --- a/packages/@headlessui-vue/src/components/label/label.ts +++ b/packages/@headlessui-vue/src/components/label/label.ts @@ -68,16 +68,16 @@ export let Label = defineComponent({ props: { as: { type: [Object, String], default: 'label' }, passive: { type: [Boolean], default: false }, + id: { type: String, default: () => `headlessui-label-${useId()}` }, }, setup(myProps, { slots, attrs }) { let context = useLabelContext() - let id = `headlessui-label-${useId()}` - onMounted(() => onUnmounted(context.register(id))) + onMounted(() => onUnmounted(context.register(myProps.id))) return () => { let { name = 'Label', slot = {}, props = {} } = context - let { passive, ...theirProps } = myProps + let { 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 864456233..30d1c828c 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -405,10 +405,12 @@ export let Listbox = defineComponent({ export let ListboxLabel = defineComponent({ name: 'ListboxLabel', - props: { as: { type: [Object, String], default: 'label' } }, + props: { + as: { type: [Object, String], default: 'label' }, + id: { type: String, default: () => `headlessui-listbox-label-${useId()}` }, + }, setup(props, { attrs, slots }) { let api = useListboxContext('ListboxLabel') - let id = `headlessui-listbox-label-${useId()}` function handleClick() { dom(api.buttonRef)?.focus({ preventScroll: true }) @@ -419,11 +421,12 @@ export let ListboxLabel = defineComponent({ open: api.listboxState.value === ListboxStates.Open, disabled: api.disabled.value, } + let { id, ...theirProps } = props let ourProps = { id, ref: api.labelRef, onClick: handleClick } return render({ ourProps, - theirProps: props, + theirProps, slot, attrs, slots, @@ -439,10 +442,10 @@ export let ListboxButton = defineComponent({ name: 'ListboxButton', props: { as: { type: [Object, String], default: 'button' }, + id: { type: String, default: () => `headlessui-listbox-button-${useId()}` }, }, setup(props, { attrs, slots, expose }) { let api = useListboxContext('ListboxButton') - let id = `headlessui-listbox-button-${useId()}` expose({ el: api.buttonRef, $el: api.buttonRef }) @@ -507,6 +510,7 @@ export let ListboxButton = defineComponent({ value: api.value.value, } + let { id, ...theirProps } = props let ourProps = { ref: api.buttonRef, id, @@ -525,7 +529,7 @@ export let ListboxButton = defineComponent({ return render({ ourProps, - theirProps: props, + theirProps, slot, attrs, slots, @@ -543,10 +547,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()}` }, }, setup(props, { attrs, slots, expose }) { let api = useListboxContext('ListboxOptions') - let id = `headlessui-listbox-options-${useId()}` let searchDebounce = ref | null>(null) expose({ el: api.optionsRef, $el: api.optionsRef }) @@ -635,6 +639,7 @@ export let ListboxOptions = defineComponent({ return () => { let slot = { open: api.listboxState.value === ListboxStates.Open } + let { id, ...theirProps } = props let ourProps = { 'aria-activedescendant': api.activeOptionIndex.value === null @@ -649,7 +654,6 @@ export let ListboxOptions = defineComponent({ tabIndex: 0, ref: api.optionsRef, } - let theirProps = props return render({ ourProps, @@ -671,17 +675,17 @@ export let ListboxOption = defineComponent({ as: { type: [Object, String], default: 'li' }, value: { type: [Object, String, Number, Boolean] }, disabled: { type: Boolean, default: false }, + id: { type: String, default: () => `headlessui-listbox.option-${useId()}` }, }, setup(props, { slots, attrs, expose }) { let api = useListboxContext('ListboxOption') - let id = `headlessui-listbox-option-${useId()}` let internalOptionRef = ref(null) expose({ el: internalOptionRef, $el: internalOptionRef }) let active = computed(() => { return api.activeOptionIndex.value !== null - ? api.options.value[api.activeOptionIndex.value].id === id + ? api.options.value[api.activeOptionIndex.value].id === props.id : false }) @@ -702,7 +706,7 @@ export let ListboxOption = defineComponent({ return ( api.options.value.find((option) => currentValues.some((value) => api.compare(toRaw(value), toRaw(option.dataRef.value))) - )?.id === id + )?.id === props.id ) }, [ValueMode.Single]: () => selected.value, @@ -720,8 +724,8 @@ export let ListboxOption = defineComponent({ if (textValue !== undefined) dataRef.value.textValue = textValue }) - onMounted(() => api.registerOption(id, dataRef)) - onUnmounted(() => api.unregisterOption(id)) + onMounted(() => api.registerOption(props.id, dataRef)) + onUnmounted(() => api.unregisterOption(props.id)) onMounted(() => { watch( @@ -732,10 +736,10 @@ export let ListboxOption = defineComponent({ match(api.mode.value, { [ValueMode.Multi]: () => { - if (isFirstSelected.value) api.goToOption(Focus.Specific, id) + if (isFirstSelected.value) api.goToOption(Focus.Specific, props.id) }, [ValueMode.Single]: () => { - api.goToOption(Focus.Specific, id) + api.goToOption(Focus.Specific, props.id) }, }) }, @@ -761,13 +765,13 @@ export let ListboxOption = defineComponent({ function handleFocus() { if (props.disabled) return api.goToOption(Focus.Nothing) - api.goToOption(Focus.Specific, id) + api.goToOption(Focus.Specific, props.id) } function handleMove() { if (props.disabled) return if (active.value) return - api.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) + api.goToOption(Focus.Specific, props.id, ActivationTrigger.Pointer) } function handleLeave() { @@ -779,6 +783,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 ourProps = { id, ref: internalOptionRef, @@ -800,7 +805,7 @@ export let ListboxOption = defineComponent({ return render({ ourProps, - theirProps: omit(props, ['value', 'disabled']), + theirProps, slot, attrs, slots, diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 8790f4c2b..b58dce9df 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -245,10 +245,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()}` }, }, setup(props, { attrs, slots, expose }) { let api = useMenuContext('MenuButton') - let id = `headlessui-menu-button-${useId()}` expose({ el: api.buttonRef, $el: api.buttonRef }) @@ -310,6 +310,8 @@ export let MenuButton = defineComponent({ return () => { let slot = { open: api.menuState.value === MenuStates.Open } + + let { id, ...theirProps } = props let ourProps = { ref: api.buttonRef, id, @@ -321,7 +323,6 @@ export let MenuButton = defineComponent({ onKeyup: handleKeyUp, onClick: handleClick, } - let theirProps = props return render({ ourProps, @@ -341,10 +342,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()}` }, }, setup(props, { attrs, slots, expose }) { let api = useMenuContext('MenuItems') - let id = `headlessui-menu-items-${useId()}` let searchDebounce = ref | null>(null) expose({ el: api.itemsRef, $el: api.itemsRef }) @@ -460,6 +461,7 @@ export let MenuItems = defineComponent({ return () => { let slot = { open: api.menuState.value === MenuStates.Open } + let { id, ...theirProps } = props let ourProps = { 'aria-activedescendant': api.activeItemIndex.value === null @@ -474,8 +476,6 @@ export let MenuItems = defineComponent({ ref: api.itemsRef, } - let theirProps = props - return render({ ourProps, theirProps, @@ -495,17 +495,17 @@ export let MenuItem = defineComponent({ props: { as: { type: [Object, String], default: 'template' }, disabled: { type: Boolean, default: false }, + id: { type: String, default: () => `headlessui-menu-item-${useId()}` }, }, setup(props, { slots, attrs, expose }) { let api = useMenuContext('MenuItem') - let id = `headlessui-menu-item-${useId()}` let internalItemRef = ref(null) expose({ el: internalItemRef, $el: internalItemRef }) let active = computed(() => { return api.activeItemIndex.value !== null - ? api.items.value[api.activeItemIndex.value].id === id + ? api.items.value[api.activeItemIndex.value].id === props.id : false }) @@ -519,8 +519,8 @@ export let MenuItem = defineComponent({ if (textValue !== undefined) dataRef.value.textValue = textValue }) - onMounted(() => api.registerItem(id, dataRef)) - onUnmounted(() => api.unregisterItem(id)) + onMounted(() => api.registerItem(props.id, dataRef)) + onUnmounted(() => api.unregisterItem(props.id)) watchEffect(() => { if (api.menuState.value !== MenuStates.Open) return @@ -537,13 +537,13 @@ export let MenuItem = defineComponent({ function handleFocus() { if (props.disabled) return api.goToItem(Focus.Nothing) - api.goToItem(Focus.Specific, id) + api.goToItem(Focus.Specific, props.id) } function handleMove() { if (props.disabled) return if (active.value) return - api.goToItem(Focus.Specific, id, ActivationTrigger.Pointer) + api.goToItem(Focus.Specific, props.id, ActivationTrigger.Pointer) } function handleLeave() { @@ -555,6 +555,7 @@ export let MenuItem = defineComponent({ return () => { let { disabled } = props let slot = { active: active.value, disabled, close: api.closeMenu } + let { id, ...theirProps } = props let ourProps = { id, ref: internalItemRef, @@ -568,7 +569,6 @@ export let MenuItem = defineComponent({ onPointerleave: handleLeave, onMouseleave: handleLeave, } - let theirProps = props return render({ ourProps, diff --git a/packages/@headlessui-vue/src/components/popover/popover.test.ts b/packages/@headlessui-vue/src/components/popover/popover.test.ts index 4c25e766e..126cfb57e 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.test.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.test.ts @@ -862,7 +862,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) // Close popover @@ -932,7 +932,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) // Close popover @@ -1892,7 +1892,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) }) ) @@ -1958,7 +1958,7 @@ describe('Keyboard interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) // Close popover @@ -2112,7 +2112,7 @@ describe('Mouse interactions', () => { assertPopoverButton({ state: PopoverState.Visible }) assertPopoverPanel({ state: PopoverState.Visible, - attributes: { id: 'headlessui-popover-panel-2' }, + attributes: { id: 'headlessui-popover-panel-3' }, }) }) ) diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index a5840b65c..b5bb8ca3b 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -6,7 +6,10 @@ import { inject, provide, ref, + shallowRef, watchEffect, + onMounted, + onUnmounted, // Types InjectionKey, @@ -14,7 +17,7 @@ import { } from 'vue' import { match } from '../../utils/match' -import { render, omit, Features } from '../../utils/render' +import { render, Features } from '../../utils/render' import { useId } from '../../hooks/use-id' import { Keys } from '../../keyboard' import { @@ -43,9 +46,9 @@ interface StateDefinition { // State popoverState: Ref button: Ref - buttonId: string + buttonId: Ref panel: Ref - panelId: string + panelId: Ref isPortalled: Ref @@ -82,14 +85,14 @@ function usePopoverGroupContext() { return inject(PopoverGroupContext, null) } -let PopoverPanelContext = Symbol('PopoverPanelContext') as InjectionKey +let PopoverPanelContext = Symbol('PopoverPanelContext') as InjectionKey> function usePopoverPanelContext() { return inject(PopoverPanelContext, null) } interface PopoverRegisterBag { - buttonId: string - panelId: string + buttonId: Ref + panelId: Ref close(): void } @@ -101,9 +104,6 @@ export let Popover = defineComponent({ as: { type: [Object, String], default: 'div' }, }, setup(props, { slots, attrs, expose }) { - let buttonId = `headlessui-popover-button-${useId()}` - let panelId = `headlessui-popover-panel-${useId()}` - let internalPopoverRef = ref(null) expose({ el: internalPopoverRef, $el: internalPopoverRef }) @@ -150,8 +150,8 @@ export let Popover = defineComponent({ let api = { popoverState, - buttonId, - panelId, + buttonId: ref(null), + panelId: ref(null), panel, button, isPortalled, @@ -193,8 +193,8 @@ export let Popover = defineComponent({ ) let registerBag = { - buttonId, - panelId, + buttonId: api.buttonId, + panelId: api.panelId, close() { api.closePopover() }, @@ -267,6 +267,7 @@ export let PopoverButton = defineComponent({ props: { as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, + id: { type: String, default: () => `headlessui-popover-button-${useId()}` }, }, inheritAttrs: false, setup(props, { attrs, slots, expose }) { @@ -275,16 +276,25 @@ export let PopoverButton = defineComponent({ expose({ el: api.button, $el: api.button }) + onMounted(() => { + api.buttonId.value = props.id + }) + onUnmounted(() => { + api.buttonId.value = null + }) + let groupContext = usePopoverGroupContext() let closeOthers = groupContext?.closeOthers let panelContext = usePopoverPanelContext() - let isWithinPanel = panelContext === null ? false : panelContext === api.panelId + let isWithinPanel = computed(() => + panelContext === null ? false : panelContext.value === api.panelId.value + ) let elementRef = ref(null) let sentinelId = `headlessui-focus-sentinel-${useId()}` - if (!isWithinPanel) { + if (!isWithinPanel.value) { watchEffect(() => { api.button.value = elementRef.value }) @@ -296,7 +306,7 @@ export let PopoverButton = defineComponent({ ) function handleKeyDown(event: KeyboardEvent) { - if (isWithinPanel) { + if (isWithinPanel.value) { if (api.popoverState.value === PopoverStates.Closed) return switch (event.key) { case Keys.Space: @@ -314,12 +324,13 @@ export let PopoverButton = defineComponent({ case Keys.Enter: event.preventDefault() // Prevent triggering a *click* event event.stopPropagation() - if (api.popoverState.value === PopoverStates.Closed) closeOthers?.(api.buttonId) + if (api.popoverState.value === PopoverStates.Closed) closeOthers?.(api.buttonId.value!) api.togglePopover() break case Keys.Escape: - if (api.popoverState.value !== PopoverStates.Open) return closeOthers?.(api.buttonId) + if (api.popoverState.value !== PopoverStates.Open) + return closeOthers?.(api.buttonId.value!) if (!dom(api.button)) return if ( ownerDocument.value?.activeElement && @@ -335,7 +346,7 @@ export let PopoverButton = defineComponent({ } function handleKeyUp(event: KeyboardEvent) { - if (isWithinPanel) return + if (isWithinPanel.value) return if (event.key === Keys.Space) { // Required for firefox, event.preventDefault() in handleKeyDown for // the Space key doesn't cancel the handleKeyUp, which in turn @@ -346,13 +357,13 @@ export let PopoverButton = defineComponent({ function handleClick(event: MouseEvent) { if (props.disabled) return - if (isWithinPanel) { + if (isWithinPanel.value) { api.closePopover() dom(api.button)?.focus() // Re-focus the original opening Button } else { event.preventDefault() event.stopPropagation() - if (api.popoverState.value === PopoverStates.Closed) closeOthers?.(api.buttonId) + if (api.popoverState.value === PopoverStates.Closed) closeOthers?.(api.buttonId.value!) api.togglePopover() dom(api.button)?.focus() } @@ -366,7 +377,8 @@ export let PopoverButton = defineComponent({ return () => { let visible = api.popoverState.value === PopoverStates.Open let slot = { open: visible } - let ourProps = isWithinPanel + let { id, ...theirProps } = props + let ourProps = isWithinPanel.value ? { ref: elementRef, type: type.value, @@ -375,12 +387,12 @@ export let PopoverButton = defineComponent({ } : { ref: elementRef, - id: api.buttonId, + id, type: type.value, 'aria-expanded': props.disabled ? undefined : api.popoverState.value === PopoverStates.Open, - 'aria-controls': dom(api.panel) ? api.panelId : undefined, + 'aria-controls': dom(api.panel) ? api.panelId.value : undefined, disabled: props.disabled ? true : undefined, onKeydown: handleKeyDown, onKeyup: handleKeyUp, @@ -411,14 +423,14 @@ export let PopoverButton = defineComponent({ return h(Fragment, [ render({ ourProps, - theirProps: { ...attrs, ...props }, + theirProps: { ...attrs, ...theirProps }, slot, attrs: attrs, slots: slots, name: 'PopoverButton', }), visible && - !isWithinPanel && + !isWithinPanel.value && api.isPortalled.value && h(Hidden, { id: sentinelId, @@ -489,6 +501,7 @@ 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()}` }, }, inheritAttrs: false, setup(props, { attrs, slots, expose }) { @@ -501,6 +514,13 @@ export let PopoverPanel = defineComponent({ expose({ el: api.panel, $el: api.panel }) + onMounted(() => { + api.panelId.value = props.id + }) + onUnmounted(() => { + api.panelId.value = null + }) + provide(PopoverPanelContext, api.panelId) // Move focus within panel @@ -632,9 +652,10 @@ export let PopoverPanel = defineComponent({ close: api.close, } + let { id, focus: _focus, ...theirProps } = props let ourProps = { ref: api.panel, - id: api.panelId, + id, onKeydown: handleKeyDown, onFocusout: focus && api.popoverState.value === PopoverStates.Open ? handleBlur : undefined, tabIndex: -1, @@ -642,7 +663,7 @@ export let PopoverPanel = defineComponent({ return render({ ourProps, - theirProps: { ...attrs, ...omit(props, ['focus']) }, + theirProps: { ...attrs, ...theirProps }, attrs, slot, slots: { @@ -690,7 +711,7 @@ export let PopoverGroup = defineComponent({ }, setup(props, { attrs, slots, expose }) { let groupRef = ref(null) - let popovers = ref([]) + let popovers = shallowRef([]) let ownerDocument = computed(() => getOwnerDocument(groupRef)) expose({ el: groupRef, $el: groupRef }) @@ -717,15 +738,15 @@ export let PopoverGroup = defineComponent({ // Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal. return popovers.value.some((bag) => { return ( - owner!.getElementById(bag.buttonId)?.contains(element) || - owner!.getElementById(bag.panelId)?.contains(element) + owner!.getElementById(bag.buttonId.value!)?.contains(element) || + owner!.getElementById(bag.panelId.value!)?.contains(element) ) }) } function closeOthers(buttonId: string) { for (let popover of popovers.value) { - if (popover.buttonId !== buttonId) popover.close() + if (popover.buttonId.value !== buttonId) popover.close() } } 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 1a1c890ca..fca774149 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -81,6 +81,7 @@ export let RadioGroup = defineComponent({ modelValue: { type: [Object, String, Number, Boolean], default: undefined }, defaultValue: { type: [Object, String, Number, Boolean], default: undefined }, name: { type: String, optional: true }, + id: { type: String, default: () => `headlessui-radiogroup-${useId()}` }, }, inheritAttrs: false, setup(props, { emit, attrs, slots, expose }) { @@ -215,8 +216,6 @@ export let RadioGroup = defineComponent({ } } - let id = `headlessui-radiogroup-${useId()}` - let form = computed(() => dom(radioGroupRef)?.closest('form')) onMounted(() => { watch( @@ -240,7 +239,7 @@ export let RadioGroup = defineComponent({ }) return () => { - let { disabled, name, ...theirProps } = props + let { disabled, name, id, ...theirProps } = props let ourProps = { ref: radioGroupRef, @@ -295,10 +294,10 @@ 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()}` }, }, setup(props, { attrs, slots, expose }) { let api = useRadioGroupContext('RadioGroupOption') - let id = `headlessui-radiogroup-option-${useId()}` let labelledby = useLabels({ name: 'RadioGroupLabel' }) let describedby = useDescriptions({ name: 'RadioGroupDescription' }) @@ -308,10 +307,10 @@ export let RadioGroupOption = defineComponent({ expose({ el: optionRef, $el: optionRef }) - onMounted(() => api.registerOption({ id, element: optionRef, propsRef })) - onUnmounted(() => api.unregisterOption(id)) + onMounted(() => api.registerOption({ id: props.id, element: optionRef, propsRef })) + onUnmounted(() => api.unregisterOption(props.id)) - let isFirstOption = computed(() => api.firstOption.value?.id === id) + let isFirstOption = computed(() => api.firstOption.value?.id === props.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 +336,7 @@ export let RadioGroupOption = defineComponent({ } return () => { - let theirProps = omit(props, ['value', 'disabled']) + let { id, 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 b88858bbe..7d9a3589e 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.ts +++ b/packages/@headlessui-vue/src/components/switch/switch.ts @@ -75,11 +75,11 @@ export let Switch = defineComponent({ defaultChecked: { type: Boolean, optional: true }, name: { type: String, optional: true }, value: { type: String, optional: true }, + id: { type: String, default: () => `headlessui-switch-${useId()}` }, }, inheritAttrs: false, setup(props, { emit, attrs, slots, expose }) { let api = inject(GroupContext, null) - let id = `headlessui-switch-${useId()}` let [checked, theirOnChange] = useControllable( computed(() => props.modelValue), @@ -141,7 +141,7 @@ export let Switch = defineComponent({ }) return () => { - let { name, value, ...theirProps } = props + let { id, name, value, ...theirProps } = props let slot = { checked: checked.value } let ourProps = { id, diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index b45c33e99..4cc35ed10 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -219,10 +219,10 @@ export let Tab = defineComponent({ props: { as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, + id: { type: String, default: () => `headlessui-tabs-tab-${useId()}` }, }, setup(props, { attrs, slots, expose }) { let api = useTabsContext('Tab') - let id = `headlessui-tabs-tab-${useId()}` let internalTabRef = ref(null) @@ -321,6 +321,7 @@ export let Tab = defineComponent({ return () => { let slot = { selected: selected.value } + let { id, ...theirProps } = props let ourProps = { ref: internalTabRef, onKeydown: handleKeyDown, @@ -337,7 +338,7 @@ export let Tab = defineComponent({ return render({ ourProps, - theirProps: props, + theirProps, slot, attrs, slots, @@ -378,10 +379,10 @@ 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()}` }, }, setup(props, { attrs, slots, expose }) { let api = useTabsContext('TabPanel') - let id = `headlessui-tabs-panel-${useId()}` let internalPanelRef = ref(null) @@ -395,6 +396,7 @@ export let TabPanel = defineComponent({ return () => { let slot = { selected: selected.value } + let { id, ...theirProps } = props let ourProps = { ref: internalPanelRef, id, @@ -409,7 +411,7 @@ export let TabPanel = defineComponent({ return render({ ourProps, - theirProps: props, + theirProps, slot, attrs, slots, From 07439de8de7955b6db18d13b7280a0d463ba6c6f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Dec 2022 19:09:23 +0100 Subject: [PATCH 3/4] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + packages/@headlessui-vue/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 5ac195ce8..209b4e00e 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve syncing of the `Combobox.Input` value ([#2042](https://github.com/tailwindlabs/headlessui/pull/2042)) - Fix crash when using `multiple` mode without `value` prop (uncontrolled) for `Listbox` and `Combobox` components ([#2058](https://github.com/tailwindlabs/headlessui/pull/2058)) - Apply `enter` and `enterFrom` classes in SSR for `Transition` component ([#2059](https://github.com/tailwindlabs/headlessui/pull/2059)) +- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060)) ## [1.7.4] - 2022-11-03 diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 3bb627ba8..dae8f18c6 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `shift+home` and `shift+end` works as expected in the `ComboboxInput` component ([#2024](https://github.com/tailwindlabs/headlessui/pull/2024)) - Improve syncing of the `ComboboxInput` value ([#2042](https://github.com/tailwindlabs/headlessui/pull/2042)) - Fix crash when using `multiple` mode without `value` prop (uncontrolled) for `Listbox` and `Combobox` components ([#2058](https://github.com/tailwindlabs/headlessui/pull/2058)) +- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060)) ## [1.7.4] - 2022-11-03 From 4af9221a3065a0ad1b41691faa41edb7af601a3e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Dec 2022 23:53:14 +0100 Subject: [PATCH 4/4] apply React's hook rules --- .../src/components/combobox/combobox.tsx | 15 ++++++++++----- .../src/components/description/description.tsx | 3 ++- .../src/components/dialog/dialog.tsx | 15 ++++++++++----- .../src/components/disclosure/disclosure.tsx | 6 ++++-- .../src/components/label/label.tsx | 3 ++- .../src/components/listbox/listbox.tsx | 12 ++++++++---- .../src/components/menu/menu.tsx | 9 ++++++--- .../src/components/popover/popover.tsx | 9 ++++++--- .../src/components/radio-group/radio-group.tsx | 6 ++++-- .../src/components/switch/switch.tsx | 3 ++- .../src/components/tabs/tabs.tsx | 6 ++++-- 11 files changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 8732d915c..ea2309838 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -677,8 +677,9 @@ let Input = forwardRefWithAs(function Input< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-combobox-input-${useId()}`, + id = `headlessui-combobox-input-${internalId}`, onChange, displayValue, type = 'text', @@ -952,7 +953,8 @@ let Button = forwardRefWithAs(function Button) => { @@ -1055,7 +1057,8 @@ let Label = forwardRefWithAs(function Label, ref: Ref ) { - let { id = `headlessui-combobox-label-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-combobox-label-${internalId}`, ...theirProps } = props let data = useData('Combobox.Label') let actions = useActions('Combobox.Label') let labelRef = useSyncRefs(data.labelRef, ref) @@ -1105,7 +1108,8 @@ let Options = forwardRefWithAs(function Options< }, ref: Ref ) { - let { id = `headlessui-combobox-options-${useId()}`, hold = false, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-combobox-options-${internalId}`, hold = false, ...theirProps } = props let data = useData('Combobox.Options') let optionsRef = useSyncRefs(data.optionsRef, ref) @@ -1190,8 +1194,9 @@ let Option = forwardRefWithAs(function Option< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-combobox-option-${useId()}`, + id = `headlessui-combobox-option-${internalId}`, disabled = false, value, ...theirProps diff --git a/packages/@headlessui-react/src/components/description/description.tsx b/packages/@headlessui-react/src/components/description/description.tsx index 4303dd47a..1924fbabc 100644 --- a/packages/@headlessui-react/src/components/description/description.tsx +++ b/packages/@headlessui-react/src/components/description/description.tsx @@ -92,7 +92,8 @@ let DEFAULT_DESCRIPTION_TAG = 'p' as const export let Description = forwardRefWithAs(function Description< TTag extends ElementType = typeof DEFAULT_DESCRIPTION_TAG >(props: Props, ref: Ref) { - let { id = `headlessui-description-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-description-${internalId}`, ...theirProps } = props let context = useDescriptionContext() let descriptionRef = useSyncRefs(ref) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index cde791268..f346bf76c 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -156,8 +156,9 @@ let DialogRoot = forwardRefWithAs(function Dialog< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-dialog-${useId()}`, + id = `headlessui-dialog-${internalId}`, open, onClose, initialFocus, @@ -391,7 +392,8 @@ type OverlayPropsWeControl = 'aria-hidden' | 'onClick' let Overlay = forwardRefWithAs(function Overlay< TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG >(props: Props, ref: Ref) { - let { id = `headlessui-dialog-overlay-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-dialog-overlay-${internalId}`, ...theirProps } = props let [{ dialogState, close }] = useDialogContext('Dialog.Overlay') let overlayRef = useSyncRefs(ref) @@ -435,7 +437,8 @@ type BackdropPropsWeControl = 'aria-hidden' | 'onClick' let Backdrop = forwardRefWithAs(function Backdrop< TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG >(props: Props, ref: Ref) { - let { id = `headlessui-dialog-backdrop-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-dialog-backdrop-${internalId}`, ...theirProps } = props let [{ dialogState }, state] = useDialogContext('Dialog.Backdrop') let backdropRef = useSyncRefs(ref) @@ -484,7 +487,8 @@ let Panel = forwardRefWithAs(function Panel, ref: Ref ) { - let { id = `headlessui-dialog-panel-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-dialog-panel-${internalId}`, ...theirProps } = props let [{ dialogState }, state] = useDialogContext('Dialog.Panel') let panelRef = useSyncRefs(ref, state.panelRef) @@ -525,7 +529,8 @@ let Title = forwardRefWithAs(function Title, ref: Ref ) { - let { id = `headlessui-dialog-title-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-dialog-title-${internalId}`, ...theirProps } = props let [{ dialogState, setTitleId }] = useDialogContext('Dialog.Title') let titleRef = useSyncRefs(ref) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index de966223f..221175f00 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -245,7 +245,8 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let { id = `headlessui-disclosure-button-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-disclosure-button-${internalId}`, ...theirProps } = props let [state, dispatch] = useDisclosureContext('Disclosure.Button') let panelContext = useDisclosurePanelContext() let isWithinPanel = panelContext === null ? false : panelContext === state.panelId @@ -354,7 +355,8 @@ let Panel = forwardRefWithAs(function Panel & PropsForFeatures, ref: Ref ) { - let { id = `headlessui-disclosure-panel-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-disclosure-panel-${internalId}`, ...theirProps } = props let [state, dispatch] = useDisclosureContext('Disclosure.Panel') let { close } = useDisclosureAPIContext('Disclosure.Panel') diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index 685e6d625..2873449b9 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -88,7 +88,8 @@ export let Label = forwardRefWithAs(function Label< }, ref: Ref ) { - let { id = `headlessui-label-${useId()}`, passive = false, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-label-${internalId}`, passive = false, ...theirProps } = props let context = useLabelContext() let labelRef = useSyncRefs(ref) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index de41860e7..6311113af 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -591,7 +591,8 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let { id = `headlessui-listbox-button-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-listbox-button-${internalId}`, ...theirProps } = props let data = useData('Listbox.Button') let actions = useActions('Listbox.Button') let buttonRef = useSyncRefs(data.buttonRef, ref) @@ -694,7 +695,8 @@ let Label = forwardRefWithAs(function Label, ref: Ref ) { - let { id = `headlessui-listbox-label-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-listbox-label-${internalId}`, ...theirProps } = props let data = useData('Listbox.Label') let actions = useActions('Listbox.Label') let labelRef = useSyncRefs(data.labelRef, ref) @@ -741,7 +743,8 @@ let Options = forwardRefWithAs(function Options< PropsForFeatures, ref: Ref ) { - let { id = `headlessui-listbox-options-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-listbox-options-${internalId}`, ...theirProps } = props let data = useData('Listbox.Options') let actions = useActions('Listbox.Options') let optionsRef = useSyncRefs(data.optionsRef, ref) @@ -902,8 +905,9 @@ let Option = forwardRefWithAs(function Option< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-listbox-option-${useId()}`, + id = `headlessui-listbox-option-${internalId}`, disabled = false, value, ...theirProps diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index f9ec37dab..b94fbdaf4 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -310,7 +310,8 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let { id = `headlessui-menu-button-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-menu-button-${internalId}`, ...theirProps } = props let [state, dispatch] = useMenuContext('Menu.Button') let buttonRef = useSyncRefs(state.buttonRef, ref) @@ -406,7 +407,8 @@ let Items = forwardRefWithAs(function Items, ref: Ref ) { - let { id = `headlessui-menu-items-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-menu-items-${internalId}`, ...theirProps } = props let [state, dispatch] = useMenuContext('Menu.Items') let itemsRef = useSyncRefs(state.itemsRef, ref) let ownerDocument = useOwnerDocument(state.itemsRef) @@ -582,7 +584,8 @@ let Item = forwardRefWithAs(function Item ) { - let { id = `headlessui-menu-item-${useId()}`, disabled = false, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-menu-item-${internalId}`, disabled = false, ...theirProps } = props let [state, dispatch] = useMenuContext('Menu.Item') let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false let internalItemRef = useRef(null) diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index b8b57ed4c..17f74bb61 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -378,7 +378,8 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let { id = `headlessui-popover-button-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-popover-button-${internalId}`, ...theirProps } = props let [state, dispatch] = usePopoverContext('Popover.Button') let { isPortalled } = usePopoverAPIContext('Popover.Button') let internalButtonRef = useRef(null) @@ -576,7 +577,8 @@ let Overlay = forwardRefWithAs(function Overlay< PropsForFeatures, ref: Ref ) { - let { id = `headlessui-popover-overlay-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-popover-overlay-${internalId}`, ...theirProps } = props let [{ popoverState }, dispatch] = usePopoverContext('Popover.Overlay') let overlayRef = useSyncRefs(ref) @@ -635,7 +637,8 @@ let Panel = forwardRefWithAs(function Panel ) { - let { id = `headlessui-popover-panel-${useId()}`, focus = false, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-popover-panel-${internalId}`, focus = false, ...theirProps } = props let [state, dispatch] = usePopoverContext('Popover.Panel') let { close, isPortalled } = usePopoverAPIContext('Popover.Panel') diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index 97f7f0678..c1af35c08 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -151,8 +151,9 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-radiogroup-${useId()}`, + id = `headlessui-radiogroup-${internalId}`, value: controlledValue, defaultValue, name, @@ -391,8 +392,9 @@ let Option = forwardRefWithAs(function Option< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-radiogroup-option-${useId()}`, + id = `headlessui-radiogroup-option-${internalId}`, value, disabled = false, ...theirProps diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index 69e0a380e..9e0728583 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -113,8 +113,9 @@ let SwitchRoot = forwardRefWithAs(function Switch< }, ref: Ref ) { + let internalId = useId() let { - id = `headlessui-switch-${useId()}`, + id = `headlessui-switch-${internalId}`, checked: controlledChecked, defaultChecked = false, onChange: controlledOnChange, diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 30201e762..0493e5abe 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -318,7 +318,8 @@ let TabRoot = forwardRefWithAs(function Tab, ref: Ref ) { - let { id = `headlessui-tabs-tab-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-tabs-tab-${internalId}`, ...theirProps } = props let { orientation, activation, selectedIndex, tabs, panels } = useData('Tab') let actions = useActions('Tab') @@ -480,7 +481,8 @@ let Panel = forwardRefWithAs(function Panel, ref: Ref ) { - let { id = `headlessui-tabs-panel-${useId()}`, ...theirProps } = props + let internalId = useId() + let { id = `headlessui-tabs-panel-${internalId}`, ...theirProps } = props let { selectedIndex, tabs, panels } = useData('Tab.Panel') let actions = useActions('Tab.Panel') let SSRContext = useSSRTabsCounter('Tab.Panel')