diff --git a/CHANGELOG.md b/CHANGELOG.md index b354073ba6..8c40c37e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure `DialogPanel` exposes its ref ([#1404](https://github.com/tailwindlabs/headlessui/pull/1404)) - Ignore `Escape` when event got prevented in `Dialog` component ([#1424](https://github.com/tailwindlabs/headlessui/pull/1424)) - Improve `FocusTrap` behaviour ([#1432](https://github.com/tailwindlabs/headlessui/pull/1432)) +- Simplify `Popover` Tab logic by using sentinel nodes instead of keydown event interception ([#1440](https://github.com/tailwindlabs/headlessui/pull/1440)) ## [Unreleased - @headlessui/react] @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix closing of `Popover.Panel` in React 18 ([#1409](https://github.com/tailwindlabs/headlessui/pull/1409)) - Ignore `Escape` when event got prevented in `Dialog` component ([#1424](https://github.com/tailwindlabs/headlessui/pull/1424)) - Improve `FocusTrap` behaviour ([#1432](https://github.com/tailwindlabs/headlessui/pull/1432)) +- Simplify `Popover` Tab logic by using sentinel nodes instead of keydown event interception ([#1440](https://github.com/tailwindlabs/headlessui/pull/1440)) ## [@headlessui/react@1.6.1] - 2022-05-03 diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index e9c70f9ca2..d8f1a79af7 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -1,5 +1,6 @@ import React, { createContext, + createRef, useCallback, useContext, useEffect, @@ -14,8 +15,8 @@ import React, { ElementType, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, - Ref, MutableRefObject, + Ref, } from 'react' import { Props } from '../../types' @@ -29,7 +30,6 @@ import { getFocusableElements, Focus, focusIn, - FocusResult, isFocusableElement, FocusableMode, } from '../../utils/focus-management' @@ -39,6 +39,10 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { getOwnerDocument } from '../../utils/owner' import { useOwnerDocument } from '../../hooks/use-owner' import { useEventListener } from '../../hooks/use-event-listener' +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' enum PopoverStates { Open, @@ -52,6 +56,9 @@ interface StateDefinition { buttonId: string panel: HTMLElement | null panelId: string + + beforePanelSentinel: MutableRefObject + afterPanelSentinel: MutableRefObject } enum ActionTypes { @@ -122,6 +129,7 @@ function usePopoverContext(component: string) { let PopoverAPIContext = createContext<{ close(focusableElement?: HTMLElement | MutableRefObject): void + isPortalled: boolean } | null>(null) PopoverAPIContext.displayName = 'PopoverAPIContext' @@ -186,12 +194,28 @@ let PopoverRoot = forwardRefWithAs(function Popover< buttonId, panel: null, panelId, + beforePanelSentinel: createRef(), + afterPanelSentinel: createRef(), } as StateDefinition) - let [{ popoverState, button, panel }, dispatch] = reducerBag + let [{ popoverState, button, panel, beforePanelSentinel, afterPanelSentinel }, dispatch] = + reducerBag 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 + + for (let root of document.querySelectorAll('body > *')) { + if (Number(root?.contains(button)) ^ Number(root?.contains(panel))) { + return true + } + } + + return false + }, [button, panel]) + let registerBag = useMemo( () => ({ buttonId, panelId, close: () => dispatch({ type: ActionTypes.ClosePopover }) }), [buttonId, panelId, dispatch] @@ -214,11 +238,13 @@ let PopoverRoot = forwardRefWithAs(function Popover< useEventListener( ownerDocument?.defaultView, 'focus', - () => { + (event) => { if (popoverState !== PopoverStates.Open) return if (isFocusWithinPopoverGroup()) return if (!button) return if (!panel) return + if (beforePanelSentinel.current?.contains?.(event.target as HTMLElement)) return + if (afterPanelSentinel.current?.contains?.(event.target as HTMLElement)) return dispatch({ type: ActionTypes.ClosePopover }) }, @@ -254,7 +280,10 @@ let PopoverRoot = forwardRefWithAs(function Popover< [dispatch, button] ) - let api = useMemo>(() => ({ close }), [close]) + let api = useMemo>( + () => ({ close, isPortalled }), + [close, isPortalled] + ) let slot = useMemo( () => ({ open: popoverState === PopoverStates.Open, close }), @@ -305,8 +334,11 @@ let Button = forwardRefWithAs(function Button ) { let [state, dispatch] = usePopoverContext('Popover.Button') + let { isPortalled } = usePopoverAPIContext('Popover.Button') let internalButtonRef = useRef(null) + let sentinelId = `headlessui-focus-sentinel-${useId()}` + let groupContext = usePopoverGroupContext() let closeOthers = groupContext?.closeOthers @@ -321,19 +353,6 @@ let Button = forwardRefWithAs(function Button(null) - let previousActiveElementRef = useRef(null) - useEventListener( - ownerDocument?.defaultView, - 'focus', - () => { - previousActiveElementRef.current = activeElementRef.current - activeElementRef.current = ownerDocument?.activeElement as HTMLElement - }, - true - ) - let handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (isWithinPanel) { @@ -371,39 +390,6 @@ let Button = forwardRefWithAs(function Button previousIdx) return - - event.preventDefault() - event.stopPropagation() - - focusIn(state.panel, Focus.Last) - } else { - event.preventDefault() - event.stopPropagation() - - focusIn(state.panel, Focus.First) - } - - break } } }, @@ -428,33 +414,8 @@ let Button = forwardRefWithAs(function Button previousIdx) return - - event.preventDefault() - event.stopPropagation() - focusIn(state.panel, Focus.Last) - break - } }, - [state.popoverState, state.panel, state.button, isWithinPanel] + [isWithinPanel] ) let handleClick = useCallback( @@ -483,10 +444,8 @@ let Button = forwardRefWithAs(function Button( - () => ({ open: state.popoverState === PopoverStates.Open }), - [state] - ) + let visible = state.popoverState === PopoverStates.Open + let slot = useMemo(() => ({ open: visible }), [visible]) let type = useResolveButtonType(props, internalButtonRef) let theirProps = props @@ -508,13 +467,46 @@ let Button = forwardRefWithAs(function Button { + let el = state.panel as HTMLElement + if (!el) return + + function run() { + match(direction.current, { + [TabDirection.Forwards]: () => focusIn(el, Focus.First), + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + } + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(run) + } else { + run() + } }) + + return ( + <> + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_BUTTON_TAG, + name: 'Popover.Button', + })} + {visible && !isWithinPanel && isPortalled && ( + + )} + + ) }) // --- @@ -601,7 +593,10 @@ let Panel = forwardRefWithAs(function Panel(null) let panelRef = useSyncRefs(internalPanelRef, ref, (panel) => { @@ -661,46 +656,6 @@ let Panel = forwardRefWithAs(function Panel { - if (state.popoverState !== PopoverStates.Open) return - if (!internalPanelRef.current) return - if (event.key !== Keys.Tab) return - if (!ownerDocument?.activeElement) return - if (!internalPanelRef.current) return - if (!internalPanelRef.current.contains(ownerDocument.activeElement)) return - - // We will take-over the default tab behaviour so that we have a bit - // control over what is focused next. It will behave exactly the same, - // but it will also "fix" some issues based on whether you are using a - // Portal or not. - event.preventDefault() - - let result = focusIn(internalPanelRef.current, event.shiftKey ? Focus.Previous : Focus.Next) - - if (result === FocusResult.Underflow) { - return state.button?.focus() - } else if (result === FocusResult.Overflow) { - if (!state.button) return - - let elements = getFocusableElements(ownerDocument.body) - let buttonIdx = elements.indexOf(state.button) - - let nextElements = elements - .splice(buttonIdx + 1) // Elements after button - .filter((element) => !internalPanelRef.current?.contains(element)) // Ignore items in panel - - // Try to focus the next element, however it could fail if we are in a - // Portal that happens to be the very last one in the DOM. In that - // case we would Error (because nothing after the button is - // focusable). Therefore we will try and focus the very first item in - // the document.body. - if (focusIn(nextElements, Focus.First) === FocusResult.Error) { - focusIn(ownerDocument.body, Focus.First) - } - } - }) - // Handle focus out when we are in special "focus" mode useEventListener( ownerDocument?.defaultView, @@ -710,12 +665,17 @@ let Panel = forwardRefWithAs(function Panel { + let el = internalPanelRef.current as HTMLElement + if (!el) return + + function run() { + match(direction.current, { + [TabDirection.Forwards]: () => { + focusIn(el, Focus.First) + }, + [TabDirection.Backwards]: () => { + // Coming from the Popover.Panel (which is portalled to somewhere else). Let's redirect + // the focus to the Popover.Button again. + state.button?.focus({ preventScroll: true }) + }, + }) + } + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(run) + } else { + run() + } + }) + + let handleAfterFocus = useEvent(() => { + let el = internalPanelRef.current as HTMLElement + if (!el) return + + function run() { + match(direction.current, { + [TabDirection.Forwards]: () => { + if (!state.button) return + + let elements = getFocusableElements() + + let idx = elements.indexOf(state.button) + let before = elements.slice(0, idx + 1) + let after = elements.slice(idx + 1) + + let combined = [...after, ...before] + + // Ignore sentinel buttons and items inside the panel + for (let element of combined.slice()) { + if ( + element?.id?.startsWith?.('headlessui-focus-sentinel-') || + state.panel?.contains(element) + ) { + let idx = combined.indexOf(element) + if (idx !== -1) combined.splice(idx, 1) + } + } + + focusIn(combined, Focus.First, false) + }, + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + } + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(run) + } else { + run() + } + }) + return ( + {visible && isPortalled && ( + + )} {render({ ourProps, theirProps, @@ -742,6 +780,16 @@ let Panel = forwardRefWithAs(function Panel + )} ) }) diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts index 1e31069639..257d4cc147 100644 --- a/packages/@headlessui-react/src/utils/focus-management.ts +++ b/packages/@headlessui-react/src/utils/focus-management.ts @@ -129,7 +129,7 @@ export function sortByDomNode( }) } -export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { +export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, sorted = true) { let ownerDocument = Array.isArray(container) ? container.length > 0 ? container[0].ownerDocument @@ -137,7 +137,9 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { : container.ownerDocument let elements = Array.isArray(container) - ? sortByDomNode(container) + ? sorted + ? sortByDomNode(container) + : container : getFocusableElements(container) let active = ownerDocument.activeElement as HTMLElement diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 606122305f..31ab5de925 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -1,6 +1,8 @@ import { + Fragment, computed, defineComponent, + h, inject, provide, ref, @@ -19,7 +21,6 @@ import { getFocusableElements, Focus, focusIn, - FocusResult, isFocusableElement, FocusableMode, } from '../../utils/focus-management' @@ -29,6 +30,9 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' import { getOwnerDocument } from '../../utils/owner' import { useEventListener } from '../../hooks/use-event-listener' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' +import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab-direction' +import { microTask } from '../../utils/micro-task' enum PopoverStates { Open, @@ -43,6 +47,11 @@ interface StateDefinition { panel: Ref panelId: string + isPortalled: Ref + + beforePanelSentinel: Ref + afterPanelSentinel: Ref + // State mutators togglePopover(): void closePopover(): void @@ -101,8 +110,22 @@ export let Popover = defineComponent({ let popoverState = ref(PopoverStates.Closed) let button = ref(null) + let beforePanelSentinel = ref(null) + let afterPanelSentinel = ref(null) let panel = ref(null) let ownerDocument = computed(() => getOwnerDocument(internalPopoverRef)) + let isPortalled = computed(() => { + if (!dom(button)) return false + if (!dom(panel)) return false + + for (let root of document.querySelectorAll('body > *')) { + if (Number(root?.contains(dom(button))) ^ Number(root?.contains(dom(panel)))) { + return true + } + } + + return false + }) let api = { popoverState, @@ -110,6 +133,9 @@ export let Popover = defineComponent({ panelId, panel, button, + isPortalled, + beforePanelSentinel, + afterPanelSentinel, togglePopover() { popoverState.value = match(popoverState.value, { [PopoverStates.Open]: PopoverStates.Closed, @@ -171,11 +197,13 @@ export let Popover = defineComponent({ useEventListener( ownerDocument.value?.defaultView, 'focus', - () => { + (event) => { if (popoverState.value !== PopoverStates.Open) return if (isFocusWithinPopoverGroup()) return if (!button) return if (!panel) return + if (dom(api.beforePanelSentinel)?.contains(event.target as HTMLElement)) return + if (dom(api.afterPanelSentinel)?.contains(event.target as HTMLElement)) return api.closePopover() }, @@ -218,6 +246,7 @@ export let PopoverButton = defineComponent({ as: { type: [Object, String], default: 'button' }, disabled: { type: [Boolean], default: false }, }, + inheritAttrs: false, setup(props, { attrs, slots, expose }) { let api = usePopoverContext('PopoverButton') let ownerDocument = computed(() => getOwnerDocument(api.button)) @@ -230,21 +259,8 @@ export let PopoverButton = defineComponent({ let panelContext = usePopoverPanelContext() let isWithinPanel = panelContext === null ? false : panelContext === api.panelId - // TODO: Revisit when handling Tab/Shift+Tab when using Portal's - let activeElementRef = ref(null) - let previousActiveElementRef = ref() - - useEventListener( - ownerDocument.value?.defaultView, - 'focus', - () => { - previousActiveElementRef.value = activeElementRef.value - activeElementRef.value = ownerDocument.value?.activeElement as HTMLElement - }, - true - ) - let elementRef = ref(null) + let sentinelId = `headlessui-focus-sentinel-${useId()}` if (!isWithinPanel) { watchEffect(() => { @@ -292,39 +308,6 @@ export let PopoverButton = defineComponent({ event.stopPropagation() api.closePopover() break - - case Keys.Tab: - if (api.popoverState.value !== PopoverStates.Open) return - if (!api.panel) return - if (!api.button) return - - // TODO: Revisit when handling Tab/Shift+Tab when using Portal's - if (event.shiftKey) { - // Check if the last focused element exists, and check that it is not inside button or panel itself - if (!previousActiveElementRef.value) return - if (dom(api.button)?.contains(previousActiveElementRef.value)) return - if (dom(api.panel)?.contains(previousActiveElementRef.value)) return - - // Check if the last focused element is *after* the button in the DOM - let focusableElements = getFocusableElements(ownerDocument.value?.body) - let previousIdx = focusableElements.indexOf( - previousActiveElementRef.value as HTMLElement - ) - let buttonIdx = focusableElements.indexOf(dom(api.button)!) - if (buttonIdx > previousIdx) return - - event.preventDefault() - event.stopPropagation() - - focusIn(dom(api.panel)!, Focus.Last) - } else { - event.preventDefault() - event.stopPropagation() - - focusIn(dom(api.panel)!, Focus.First) - } - - break } } } @@ -337,29 +320,6 @@ export let PopoverButton = defineComponent({ // triggers a *click*. event.preventDefault() } - if (api.popoverState.value !== PopoverStates.Open) return - if (!api.panel) return - if (!api.button) return - - // TODO: Revisit when handling Tab/Shift+Tab when using Portal's - switch (event.key) { - case Keys.Tab: - // Check if the last focused element exists, and check that it is not inside button or panel itself - if (!previousActiveElementRef.value) return - if (dom(api.button)?.contains(previousActiveElementRef.value)) return - if (dom(api.panel)?.contains(previousActiveElementRef.value)) return - - // Check if the last focused element is *after* the button in the DOM - let focusableElements = getFocusableElements(ownerDocument.value?.body) - let previousIdx = focusableElements.indexOf(previousActiveElementRef.value as HTMLElement) - let buttonIdx = focusableElements.indexOf(dom(api.button)!) - if (buttonIdx > previousIdx) return - - event.preventDefault() - event.stopPropagation() - focusIn(dom(api.panel)!, Focus.Last) - break - } } function handleClick(event: MouseEvent) { @@ -377,7 +337,8 @@ export let PopoverButton = defineComponent({ } return () => { - let slot = { open: api.popoverState.value === PopoverStates.Open } + let visible = api.popoverState.value === PopoverStates.Open + let slot = { open: visible } let ourProps = isWithinPanel ? { ref: elementRef, @@ -399,13 +360,45 @@ export let PopoverButton = defineComponent({ onClick: handleClick, } - return render({ - props: { ...props, ...ourProps }, - slot, - attrs: attrs, - slots: slots, - name: 'PopoverButton', - }) + let direction = useTabDirection() + function handleFocus() { + let el = dom(api.panel) as HTMLElement + if (!el) return + + function run() { + match(direction.value, { + [TabDirection.Forwards]: () => focusIn(el, Focus.First), + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + } + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(run) + } else { + run() + } + } + + return h(Fragment, [ + render({ + props: { ...attrs, ...props, ...ourProps }, + slot, + attrs: attrs, + slots: slots, + name: 'PopoverButton', + }), + visible && + !isWithinPanel && + api.isPortalled.value && + h(Hidden, { + id: sentinelId, + features: HiddenFeatures.Focusable, + as: 'button', + type: 'button', + onFocus: handleFocus, + }), + ]) } }, }) @@ -467,11 +460,15 @@ export let PopoverPanel = defineComponent({ unmount: { type: Boolean, default: true }, focus: { type: Boolean, default: false }, }, + inheritAttrs: false, setup(props, { attrs, slots, expose }) { let { focus } = props let api = usePopoverContext('PopoverPanel') let ownerDocument = computed(() => getOwnerDocument(api.panel)) + let beforePanelSentinelId = `headlessui-focus-sentinel-before-${useId()}` + let afterPanelSentinelId = `headlessui-focus-sentinel-after-${useId()}` + expose({ el: api.panel, $el: api.panel }) provide(PopoverPanelContext, api.panelId) @@ -488,46 +485,6 @@ export let PopoverPanel = defineComponent({ focusIn(dom(api.panel)!, Focus.First) }) - // Handle Tab / Shift+Tab focus positioning - useEventListener(ownerDocument.value?.defaultView, 'keydown', (event: KeyboardEvent) => { - if (api.popoverState.value !== PopoverStates.Open) return - if (!dom(api.panel)) return - - if (event.key !== Keys.Tab) return - if (!ownerDocument.value?.activeElement) return - if (!dom(api.panel)?.contains(ownerDocument.value.activeElement)) return - - // We will take-over the default tab behaviour so that we have a bit - // control over what is focused next. It will behave exactly the same, - // but it will also "fix" some issues based on whether you are using a - // Portal or not. - event.preventDefault() - - let result = focusIn(dom(api.panel)!, event.shiftKey ? Focus.Previous : Focus.Next) - - if (result === FocusResult.Underflow) { - return dom(api.button)?.focus() - } else if (result === FocusResult.Overflow) { - if (!dom(api.button)) return - - let elements = getFocusableElements(ownerDocument.value.body) - let buttonIdx = elements.indexOf(dom(api.button)!) - - let nextElements = elements - .splice(buttonIdx + 1) // Elements after button - .filter((element) => !dom(api.panel)?.contains(element)) // Ignore items in panel - - // Try to focus the next element, however it could fail if we are in a - // Portal that happens to be the very last one in the DOM. In that - // case we would Error (because nothing after the button is - // focusable). Therefore we will try and focus the very first item in - // the document.body. - if (focusIn(nextElements, Focus.First) === FocusResult.Error) { - focusIn(ownerDocument.value.body, Focus.First) - } - } - }) - // Handle focus out when we are in special "focus" mode useEventListener( ownerDocument.value?.defaultView, @@ -536,11 +493,18 @@ export let PopoverPanel = defineComponent({ if (!focus) return if (api.popoverState.value !== PopoverStates.Open) return if (!dom(api.panel)) return + + let activeElement = ownerDocument?.value?.activeElement as HTMLElement + if ( - ownerDocument.value?.activeElement && - dom(api.panel)?.contains(ownerDocument.value.activeElement as HTMLElement) - ) + activeElement && + (dom(api.panel)?.contains(activeElement) || + dom(api.beforePanelSentinel)?.contains?.(activeElement) || + dom(api.afterPanelSentinel)?.contains?.(activeElement)) + ) { return + } + api.closePopover() }, true @@ -570,6 +534,76 @@ export let PopoverPanel = defineComponent({ } } + let direction = useTabDirection() + function handleBeforeFocus() { + let el = dom(api.panel) as HTMLElement + if (!el) return + + function run() { + match(direction.value, { + [TabDirection.Forwards]: () => { + focusIn(el, Focus.First) + }, + [TabDirection.Backwards]: () => { + // Coming from the Popover.Panel (which is portalled to somewhere else). Let's redirect + // the focus to the Popover.Button again. + dom(api.button)?.focus({ preventScroll: true }) + }, + }) + } + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(run) + } else { + run() + } + } + + function handleAfterFocus() { + let el = dom(api.panel) as HTMLElement + if (!el) return + + function run() { + match(direction.value, { + [TabDirection.Forwards]: () => { + let button = dom(api.button) + let panel = dom(api.panel) + if (!button) return + + let elements = getFocusableElements() + + let idx = elements.indexOf(button) + let before = elements.slice(0, idx + 1) + let after = elements.slice(idx + 1) + + let combined = [...after, ...before] + + // Ignore sentinel buttons and items inside the panel + for (let element of combined.slice()) { + if ( + element?.id?.startsWith?.('headlessui-focus-sentinel-') || + panel?.contains(element) + ) { + let idx = combined.indexOf(element) + if (idx !== -1) combined.splice(idx, 1) + } + } + + focusIn(combined, Focus.First, false) + }, + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + } + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(run) + } else { + run() + } + } + return () => { let slot = { open: api.popoverState.value === PopoverStates.Open, @@ -582,15 +616,37 @@ export let PopoverPanel = defineComponent({ onKeydown: handleKeyDown, } - return render({ - props: { ...props, ...ourProps }, - slot, - attrs, - slots, - features: Features.RenderStrategy | Features.Static, - visible: visible.value, - name: 'PopoverPanel', - }) + return h(Fragment, [ + visible.value && + api.isPortalled.value && + h(Hidden, { + id: beforePanelSentinelId, + ref: api.beforePanelSentinel, + features: HiddenFeatures.Focusable, + as: 'button', + type: 'button', + onFocus: handleBeforeFocus, + }), + render({ + props: { ...attrs, ...props, ...ourProps }, + slot, + attrs, + slots, + features: Features.RenderStrategy | Features.Static, + visible: visible.value, + name: 'PopoverPanel', + }), + visible.value && + api.isPortalled.value && + h(Hidden, { + id: afterPanelSentinelId, + ref: api.afterPanelSentinel, + features: HiddenFeatures.Focusable, + as: 'button', + type: 'button', + onFocus: handleAfterFocus, + }), + ]) } }, }) diff --git a/packages/@headlessui-vue/src/utils/focus-management.ts b/packages/@headlessui-vue/src/utils/focus-management.ts index a64e5f88ae..118cc3b7b6 100644 --- a/packages/@headlessui-vue/src/utils/focus-management.ts +++ b/packages/@headlessui-vue/src/utils/focus-management.ts @@ -122,7 +122,7 @@ export function sortByDomNode( }) } -export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { +export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, sorted = true) { let ownerDocument = (Array.isArray(container) ? container.length > 0 @@ -131,7 +131,9 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { : container?.ownerDocument) ?? document let elements = Array.isArray(container) - ? sortByDomNode(container) + ? sorted + ? sortByDomNode(container) + : container : getFocusableElements(container) let active = ownerDocument.activeElement as HTMLElement diff --git a/packages/playground-react/pages/popover/popover.tsx b/packages/playground-react/pages/popover/popover.tsx index fa2d143dd3..788d3f4468 100644 --- a/packages/playground-react/pages/popover/popover.tsx +++ b/packages/playground-react/pages/popover/popover.tsx @@ -7,25 +7,13 @@ let Button = forwardRef( return ( ) } ) -function Link(props: React.ComponentProps<'a'>) { - return ( - - {props.children} - - ) -} - export default function Home() { let options = { placement: 'bottom-start' as const, @@ -36,7 +24,7 @@ export default function Home() { let [reference1, popper1] = usePopper(options) let [reference2, popper2] = usePopper(options) - let links = ['First', 'Second', 'Third', 'Fourth'] + let items = ['First', 'Second', 'Third', 'Fourth'] return (
@@ -60,10 +48,10 @@ export default function Home() { Normal - {links.map((link, i) => ( - - Normal - {link} - + {items.map((item, i) => ( + ))} @@ -74,8 +62,8 @@ export default function Home() { focus className="absolute flex w-64 flex-col border-2 border-blue-900 bg-gray-100" > - {links.map((link, i) => ( - Focus - {link} + {items.map((item) => ( + ))} @@ -87,8 +75,8 @@ export default function Home() { ref={popper1} className="flex w-64 flex-col border-2 border-blue-900 bg-gray-100" > - {links.map((link) => ( - Portal - {link} + {items.map((item) => ( + ))} @@ -102,8 +90,8 @@ export default function Home() { focus className="flex w-64 flex-col border-2 border-blue-900 bg-gray-100" > - {links.map((link) => ( - Focus in Portal - {link} + {items.map((item) => ( + ))} diff --git a/packages/playground-vue/src/components/popover/popover.vue b/packages/playground-vue/src/components/popover/popover.vue index 082fd0a7ca..78e0537764 100644 --- a/packages/playground-vue/src/components/popover/popover.vue +++ b/packages/playground-vue/src/components/popover/popover.vue @@ -20,14 +20,14 @@ >Normal - Normal - {{ link }} - + @@ -40,13 +40,13 @@ focus class="absolute flex w-64 flex-col border-2 border-blue-900 bg-gray-100" > - Focus - {{ link }} - + @@ -61,13 +61,13 @@ ref="container1" class="flex w-64 flex-col border-2 border-blue-900 bg-gray-100" > - Portal - {{ link }} - + @@ -84,13 +84,13 @@ focus class="flex w-64 flex-col border-2 border-blue-900 bg-gray-100" > - Focus in Portal - {{ link }} - +