diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba4252c43..b354073ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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)) ## [Unreleased - @headlessui/react] @@ -18,6 +19,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)) ## [@headlessui/react@1.6.1] - 2022-05-03 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 38db7e71f4..84e3f796a4 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -36,7 +36,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useLatestValue } from '../../hooks/use-latest-value' import { useTreeWalker } from '../../hooks/use-tree-walker' import { sortByDomNode } from '../../utils/focus-management' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' enum ComboboxStates { @@ -565,7 +565,8 @@ let ComboboxRoot = forwardRefWithAs(function Combobox< {name != null && value != null && objectToFormEntries({ [name]: value }).map(([name, value]) => ( - (null) let dialogRef = useSyncRefs(internalDialogRef, ref) + // Reference to a node in the "main" tree, not in the portalled Dialog tree. + let mainTreeNode = useRef(null) + let ownerDocument = useOwnerDocument(internalDialogRef) // Validations @@ -196,26 +200,17 @@ let DialogRoot = forwardRefWithAs(function Dialog< // in between. We only care abou whether you are the top most one or not. let position = !hasNestedDialogs ? 'leaf' : 'parent' - let previousElement = useFocusTrap( - internalDialogRef, - enabled - ? match(position, { - parent: FocusTrapFeatures.RestoreFocus, - leaf: FocusTrapFeatures.All & ~FocusTrapFeatures.FocusLock, - }) - : FocusTrapFeatures.None, - { initialFocus, containers } - ) + // Ensure other elements can't be interacted with useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false) - // Handle outside click + // Close Dialog on outside click useOutsideClick( () => { // Third party roots let rootContainers = Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).filter( (container) => { if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements - if (container.contains(previousElement.current)) return false // Skip if it is the main app + if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app if (state.panelRef.current && container.contains(state.panelRef.current)) return false return true // Keep } @@ -345,21 +340,35 @@ let DialogRoot = forwardRefWithAs(function Dialog< - {render({ - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_DIALOG_TAG, - features: DialogRenderFeatures, - visible: dialogState === DialogStates.Open, - name: 'Dialog', - })} + + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_DIALOG_TAG, + features: DialogRenderFeatures, + visible: dialogState === DialogStates.Open, + name: 'Dialog', + })} + + ) }) diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 92dd61a6e2..1005f18ce3 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -1,5 +1,6 @@ -import { +import React, { useRef, + useEffect, // Types ElementType, @@ -9,33 +10,264 @@ import { import { Props } from '../../types' import { forwardRefWithAs, render } from '../../utils/render' -import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { useSyncRefs } from '../../hooks/use-sync-refs' +import { Features as HiddenFeatures, Hidden } from '../../internal/hidden' +import { focusElement, focusIn, Focus, FocusResult } from '../../utils/focus-management' +import { match } from '../../utils/match' +import { useEvent } from '../../hooks/use-event' +import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab-direction' +import { useIsMounted } from '../../hooks/use-is-mounted' +import { useOwnerDocument } from '../../hooks/use-owner' +import { useEventListener } from '../../hooks/use-event-listener' +import { microTask } from '../../utils/micro-task' +import { useWatch } from '../../hooks/use-watch' let DEFAULT_FOCUS_TRAP_TAG = 'div' as const -export let FocusTrap = forwardRefWithAs(function FocusTrap< - TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG ->( - props: Props & { initialFocus?: MutableRefObject }, - ref: Ref +enum Features { + /** No features enabled for the focus trap. */ + None = 1 << 0, + + /** Ensure that we move focus initially into the container. */ + InitialFocus = 1 << 1, + + /** Ensure that pressing `Tab` and `Shift+Tab` is trapped within the container. */ + TabLock = 1 << 2, + + /** Ensure that programmatically moving focus outside of the container is disallowed. */ + FocusLock = 1 << 3, + + /** Ensure that we restore the focus when unmounting the focus trap. */ + RestoreFocus = 1 << 4, + + /** Enable all features. */ + All = InitialFocus | TabLock | FocusLock | RestoreFocus, +} + +export let FocusTrap = Object.assign( + forwardRefWithAs(function FocusTrap( + props: Props & { + initialFocus?: MutableRefObject + features?: Features + containers?: MutableRefObject>> + }, + ref: Ref + ) { + let container = useRef(null) + let focusTrapRef = useSyncRefs(container, ref) + let { initialFocus, containers, features = Features.All, ...theirProps } = props + + if (!useServerHandoffComplete()) { + features = Features.None + } + + let ownerDocument = useOwnerDocument(container) + + useRestoreFocus({ ownerDocument }, Boolean(features & Features.RestoreFocus)) + let previousActiveElement = useInitialFocus( + { ownerDocument, container, initialFocus }, + Boolean(features & Features.InitialFocus) + ) + useFocusLock( + { ownerDocument, container, containers, previousActiveElement }, + Boolean(features & Features.FocusLock) + ) + + let direction = useTabDirection() + let handleFocus = useEvent(() => { + let el = container.current as HTMLElement + if (!el) return + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(() => { + match(direction.current, { + [TabDirection.Forwards]: () => focusIn(el, Focus.First), + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + }) + } else { + match(direction.current, { + [TabDirection.Forwards]: () => focusIn(el, Focus.First), + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + } + }) + + let ourProps = { ref: focusTrapRef } + + return ( + <> + {Boolean(features & Features.TabLock) && ( + + )} + {render({ + ourProps, + theirProps, + defaultTag: DEFAULT_FOCUS_TRAP_TAG, + name: 'FocusTrap', + })} + {Boolean(features & Features.TabLock) && ( + + )} + + ) + }), + { features: Features } +) + +function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) { + let restoreElement = useRef(null) + + // Capture the currently focused element, before we try to move the focus inside the FocusTrap. + useEventListener( + ownerDocument?.defaultView, + 'focusout', + (event) => { + if (!enabled) return + if (restoreElement.current) return + + restoreElement.current = event.target as HTMLElement + }, + true + ) + + // Restore the focus to the previous element when `enabled` becomes false again + useWatch(() => { + if (enabled) return + + focusElement(restoreElement.current) + restoreElement.current = null + }, [enabled]) + + // Restore the focus to the previous element when the component is unmounted + let trulyUnmounted = useRef(false) + useEffect(() => { + trulyUnmounted.current = false + + return () => { + trulyUnmounted.current = true + microTask(() => { + if (!trulyUnmounted.current) return + + focusElement(restoreElement.current) + restoreElement.current = null + }) + } + }, []) +} + +function useInitialFocus( + { + ownerDocument, + container, + initialFocus, + }: { + ownerDocument: Document | null + container: MutableRefObject + initialFocus?: MutableRefObject + }, + enabled: boolean ) { - let container = useRef(null) - let focusTrapRef = useSyncRefs(container, ref) - let { initialFocus, ...theirProps } = props + let previousActiveElement = useRef(null) + + // Handle initial focus + useWatch(() => { + if (!enabled) return + let containerElement = container.current + if (!containerElement) return + + let activeElement = ownerDocument?.activeElement as HTMLElement + + if (initialFocus?.current) { + if (initialFocus?.current === activeElement) { + previousActiveElement.current = activeElement + return // Initial focus ref is already the active element + } + } else if (containerElement.contains(activeElement)) { + previousActiveElement.current = activeElement + return // Already focused within Dialog + } + + // Try to focus the initialFocus ref + if (initialFocus?.current) { + focusElement(initialFocus.current) + } else { + if (focusIn(containerElement, Focus.First) === FocusResult.Error) { + console.warn('There are no focusable elements inside the ') + } + } + + previousActiveElement.current = ownerDocument?.activeElement as HTMLElement + }, [enabled]) + + return previousActiveElement +} + +function useFocusLock( + { + ownerDocument, + container, + containers, + previousActiveElement, + }: { + ownerDocument: Document | null + container: MutableRefObject + containers?: MutableRefObject>> + previousActiveElement: MutableRefObject + }, + enabled: boolean +) { + let mounted = useIsMounted() + + // Prevent programmatically escaping the container + useEventListener( + ownerDocument?.defaultView, + 'focus', + (event) => { + if (!enabled) return + if (!mounted.current) return + + let allContainers = new Set(containers?.current) + allContainers.add(container) + + let previous = previousActiveElement.current + if (!previous) return + + let toElement = event.target as HTMLElement | null - let ready = useServerHandoffComplete() - useFocusTrap(container, ready ? FocusTrapFeatures.All : FocusTrapFeatures.None, { initialFocus }) + if (toElement && toElement instanceof HTMLElement) { + if (!contains(allContainers, toElement)) { + event.preventDefault() + event.stopPropagation() + focusElement(previous) + } else { + previousActiveElement.current = toElement + focusElement(toElement) + } + } else { + focusElement(previousActiveElement.current) + } + }, + true + ) +} - let ourProps = { - ref: focusTrapRef, +function contains(containers: Set>, element: HTMLElement) { + for (let container of containers) { + if (container.current?.contains(element)) return true } - return render({ - ourProps, - theirProps, - defaultTag: DEFAULT_FOCUS_TRAP_TAG, - name: 'FocusTrap', - }) -}) + return false +} diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 5e8876308a..68c98f7063 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -34,7 +34,7 @@ import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/fo import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' @@ -404,7 +404,8 @@ let ListboxRoot = forwardRefWithAs(function Listbox< {name != null && value != null && objectToFormEntries({ [name]: value }).map(([name, value]) => ( - ): HTMLElement | null { let forceInRoot = usePortalRoot() @@ -85,21 +86,31 @@ let PortalRoot = forwardRefWithAs(function Portal< let ready = useServerHandoffComplete() + let trulyUnmounted = useRef(false) useIsoMorphicEffect(() => { - if (!target) return - if (!element) return + trulyUnmounted.current = false - target.appendChild(element) + if (!target || !element) return + + // Element already exists in target, always calling target.appendChild(element) will cause a + // brief unmount/remount. + if (!target.contains(element)) { + target.appendChild(element) + } return () => { - if (!target) return - if (!element) return + trulyUnmounted.current = true + + microTask(() => { + if (!trulyUnmounted.current) return + if (!target || !element) return - target.removeChild(element) + target.removeChild(element) - if (target.childNodes.length <= 0) { - target.parentElement?.removeChild(target) - } + if (target.childNodes.length <= 0) { + target.parentElement?.removeChild(target) + } + }) } }, [target, element]) 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 2628a5ac17..22622b8df7 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -26,7 +26,7 @@ import { Label, useLabels } from '../../components/label/label' import { Description, useDescriptions } from '../../components/description/description' import { useTreeWalker } from '../../hooks/use-tree-walker' import { useSyncRefs } from '../../hooks/use-sync-refs' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { attemptSubmit, objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' @@ -271,7 +271,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< {name != null && value != null && objectToFormEntries({ [name]: value }).map(([name, value]) => ( - {name != null && checked && ( - @@ -98,8 +99,8 @@ function useParentNesting() { interface NestingContextValues { children: MutableRefObject<{ id: ID; state: TreeStates }[]> - register: MutableRefObject<(id: ID) => () => void> - unregister: MutableRefObject<(id: ID, strategy?: RenderStrategy) => void> + register: (id: ID) => () => void + unregister: (id: ID, strategy?: RenderStrategy) => void } let NestingContext = createContext(null) @@ -117,7 +118,7 @@ function useNesting(done?: () => void) { let transitionableChildren = useRef([]) let mounted = useIsMounted() - let unregister = useLatestValue((childId: ID, strategy = RenderStrategy.Hidden) => { + let unregister = useEvent((childId: ID, strategy = RenderStrategy.Hidden) => { let idx = transitionableChildren.current.findIndex(({ id }) => id === childId) if (idx === -1) return @@ -137,7 +138,7 @@ function useNesting(done?: () => void) { }) }) - let register = useLatestValue((childId: ID) => { + let register = useEvent((childId: ID) => { let child = transitionableChildren.current.find(({ id }) => id === childId) if (!child) { transitionableChildren.current.push({ id: childId, state: TreeStates.Visible }) @@ -145,7 +146,7 @@ function useNesting(done?: () => void) { child.state = TreeStates.Visible } - return () => unregister.current(childId, RenderStrategy.Unmount) + return () => unregister(childId, RenderStrategy.Unmount) }) return useMemo( @@ -224,13 +225,13 @@ let TransitionChild = forwardRefWithAs(function TransitionChild< // transitioning ourselves. Otherwise we would unmount before the transitions are finished. if (!transitionInFlight.current) { setState(TreeStates.Hidden) - unregister.current(id) + unregister(id) } }) useEffect(() => { if (!id) return - return register.current(id) + return register(id) }, [register, id]) useEffect(() => { @@ -245,8 +246,8 @@ let TransitionChild = forwardRefWithAs(function TransitionChild< } match(state, { - [TreeStates.Hidden]: () => unregister.current(id), - [TreeStates.Visible]: () => register.current(id), + [TreeStates.Hidden]: () => unregister(id), + [TreeStates.Visible]: () => register(id), }) }, [state, id, register, unregister, show, strategy]) @@ -290,7 +291,7 @@ let TransitionChild = forwardRefWithAs(function TransitionChild< // When we don't have children anymore we can safely unregister from the parent and hide // ourselves. setState(TreeStates.Hidden) - unregister.current(id) + unregister(id) } }), }) diff --git a/packages/@headlessui-react/src/hooks/use-event.ts b/packages/@headlessui-react/src/hooks/use-event.ts new file mode 100644 index 0000000000..893893e352 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-event.ts @@ -0,0 +1,9 @@ +import React from 'react' + +export let useEvent = + // TODO: Add React.useEvent ?? once the useEvent hook is available + function useEvent(cb: (...args: T[]) => R) { + let cache = React.useRef(cb) + cache.current = cb + return React.useCallback((...args: T[]) => cache.current(...args), [cache]) + } diff --git a/packages/@headlessui-react/src/hooks/use-focus-trap.ts b/packages/@headlessui-react/src/hooks/use-focus-trap.ts deleted file mode 100644 index 3a0313590c..0000000000 --- a/packages/@headlessui-react/src/hooks/use-focus-trap.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { - useRef, - // Types - MutableRefObject, - useEffect, -} from 'react' - -import { Keys } from '../components/keyboard' -import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management' -import { useEventListener } from './use-event-listener' -import { useIsMounted } from './use-is-mounted' -import { useOwnerDocument } from './use-owner' - -export enum Features { - /** No features enabled for the `useFocusTrap` hook. */ - None = 1 << 0, - - /** Ensure that we move focus initially into the container. */ - InitialFocus = 1 << 1, - - /** Ensure that pressing `Tab` and `Shift+Tab` is trapped within the container. */ - TabLock = 1 << 2, - - /** Ensure that programmatically moving focus outside of the container is disallowed. */ - FocusLock = 1 << 3, - - /** Ensure that we restore the focus when unmounting the component that uses this `useFocusTrap` hook. */ - RestoreFocus = 1 << 4, - - /** Enable all features. */ - All = InitialFocus | TabLock | FocusLock | RestoreFocus, -} - -export function useFocusTrap( - container: MutableRefObject, - features: Features = Features.All, - { - initialFocus, - containers, - }: { - initialFocus?: MutableRefObject - containers?: MutableRefObject>> - } = {} -) { - let restoreElement = useRef(null) - let previousActiveElement = useRef(null) - let mounted = useIsMounted() - - let featuresRestoreFocus = Boolean(features & Features.RestoreFocus) - let featuresInitialFocus = Boolean(features & Features.InitialFocus) - - let ownerDocument = useOwnerDocument(container) - - // Capture the currently focused element, before we enable the focus trap. - useEffect(() => { - if (!featuresRestoreFocus) return - - if (!restoreElement.current) { - restoreElement.current = ownerDocument?.activeElement as HTMLElement - } - }, [featuresRestoreFocus, ownerDocument]) - - // Restore the focus when we unmount the component. - useEffect(() => { - if (!featuresRestoreFocus) return - - return () => { - focusElement(restoreElement.current) - restoreElement.current = null - } - }, [featuresRestoreFocus]) - - // Handle initial focus - useEffect(() => { - if (!featuresInitialFocus) return - let containerElement = container.current - if (!containerElement) return - - let activeElement = ownerDocument?.activeElement as HTMLElement - - if (initialFocus?.current) { - if (initialFocus?.current === activeElement) { - previousActiveElement.current = activeElement - return // Initial focus ref is already the active element - } - } else if (containerElement.contains(activeElement)) { - previousActiveElement.current = activeElement - return // Already focused within Dialog - } - - // Try to focus the initialFocus ref - if (initialFocus?.current) { - focusElement(initialFocus.current) - } else { - if (focusIn(containerElement, Focus.First) === FocusResult.Error) { - console.warn('There are no focusable elements inside the ') - } - } - - previousActiveElement.current = ownerDocument?.activeElement as HTMLElement - }, [container, initialFocus, featuresInitialFocus, ownerDocument]) - - // Handle `Tab` & `Shift+Tab` keyboard events - useEventListener(ownerDocument?.defaultView, 'keydown', (event) => { - if (!(features & Features.TabLock)) return - - if (!container.current) return - if (event.key !== Keys.Tab) return - - event.preventDefault() - - if ( - focusIn( - container.current, - (event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround - ) === FocusResult.Success - ) { - previousActiveElement.current = ownerDocument?.activeElement as HTMLElement - } - }) - - // Prevent programmatically escaping the container - useEventListener( - ownerDocument?.defaultView, - 'focus', - (event) => { - if (!(features & Features.FocusLock)) return - - let allContainers = new Set(containers?.current) - allContainers.add(container) - - if (!allContainers.size) return - - let previous = previousActiveElement.current - if (!previous) return - if (!mounted.current) return - - let toElement = event.target as HTMLElement | null - - if (toElement && toElement instanceof HTMLElement) { - if (!contains(allContainers, toElement)) { - event.preventDefault() - event.stopPropagation() - focusElement(previous) - } else { - previousActiveElement.current = toElement - focusElement(toElement) - } - } else { - focusElement(previousActiveElement.current) - } - }, - true - ) - - return restoreElement -} - -function contains(containers: Set>, element: HTMLElement) { - for (let container of containers) { - if (container.current?.contains(element)) return true - } - - return false -} diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index d0b08983e5..8cef785e08 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -1,6 +1,6 @@ import { MutableRefObject, useRef } from 'react' import { microTask } from '../utils/micro-task' -import { useLatestValue } from './use-latest-value' +import { useEvent } from './use-event' import { useWindowEvent } from './use-window-event' type Container = MutableRefObject | HTMLElement | null @@ -18,7 +18,7 @@ export function useOutsideClick( features: Features = Features.None ) { let called = useRef(false) - let handler = useLatestValue((event: MouseEvent | PointerEvent) => { + let handler = useEvent((event: MouseEvent | PointerEvent) => { if (called.current) return called.current = true microTask(() => { @@ -77,6 +77,6 @@ export function useOutsideClick( return cb(event, target) }) - useWindowEvent('pointerdown', (...args) => handler.current(...args)) - useWindowEvent('mousedown', (...args) => handler.current(...args)) + useWindowEvent('pointerdown', handler) + useWindowEvent('mousedown', handler) } diff --git a/packages/@headlessui-react/src/hooks/use-tab-direction.ts b/packages/@headlessui-react/src/hooks/use-tab-direction.ts new file mode 100644 index 0000000000..55ab59d59c --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-tab-direction.ts @@ -0,0 +1,23 @@ +import { useRef } from 'react' +import { useWindowEvent } from './use-window-event' + +export enum Direction { + Forwards, + Backwards, +} + +export function useTabDirection() { + let direction = useRef(Direction.Forwards) + + useWindowEvent( + 'keydown', + (event) => { + if (event.key === 'Tab') { + direction.current = event.shiftKey ? Direction.Backwards : Direction.Forwards + } + }, + true + ) + + return direction +} diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index e066bbd9a1..7c1b029dd1 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -5,6 +5,7 @@ import { disposables } from '../utils/disposables' import { match } from '../utils/match' import { useDisposables } from './use-disposables' +import { useEvent } from './use-event' import { useIsMounted } from './use-is-mounted' import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useLatestValue } from './use-latest-value' @@ -46,7 +47,7 @@ export function useTransition({ let latestDirection = useLatestValue(direction) - let beforeEvent = useLatestValue(() => { + let beforeEvent = useEvent(() => { return match(latestDirection.current, { enter: () => events.current.beforeEnter(), leave: () => events.current.beforeLeave(), @@ -54,7 +55,7 @@ export function useTransition({ }) }) - let afterEvent = useLatestValue(() => { + let afterEvent = useEvent(() => { return match(latestDirection.current, { enter: () => events.current.afterEnter(), leave: () => events.current.afterLeave(), @@ -73,7 +74,7 @@ export function useTransition({ dd.dispose() - beforeEvent.current() + beforeEvent() onStart.current(latestDirection.current) @@ -83,7 +84,7 @@ export function useTransition({ match(reason, { [Reason.Ended]() { - afterEvent.current() + afterEvent() onStop.current(latestDirection.current) }, [Reason.Cancelled]: () => {}, diff --git a/packages/@headlessui-react/src/hooks/use-watch.ts b/packages/@headlessui-react/src/hooks/use-watch.ts new file mode 100644 index 0000000000..18f5917411 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-watch.ts @@ -0,0 +1,18 @@ +import { useEffect, useRef } from 'react' +import { useEvent } from './use-event' + +export function useWatch(cb: (values: T[]) => void | (() => void), dependencies: T[]) { + let track = useRef([]) + let action = useEvent(cb) + + useEffect(() => { + for (let [idx, value] of dependencies.entries()) { + if (track.current[idx] !== value) { + // At least 1 item changed + let returnValue = action(dependencies) + track.current = dependencies + return returnValue + } + } + }, [action, ...dependencies]) +} diff --git a/packages/@headlessui-react/src/internal/focus-sentinel.tsx b/packages/@headlessui-react/src/internal/focus-sentinel.tsx index 06659cbb2a..de3268210d 100644 --- a/packages/@headlessui-react/src/internal/focus-sentinel.tsx +++ b/packages/@headlessui-react/src/internal/focus-sentinel.tsx @@ -1,6 +1,6 @@ import React, { useState, FocusEvent as ReactFocusEvent } from 'react' -import { VisuallyHidden } from './visually-hidden' +import { Hidden, Features } from './hidden' interface FocusSentinelProps { onFocus(): boolean @@ -12,9 +12,10 @@ export function FocusSentinel({ onFocus }: FocusSentinelProps) { if (!enabled) return null return ( - { event.preventDefault() let frame: ReturnType diff --git a/packages/@headlessui-react/src/internal/hidden.tsx b/packages/@headlessui-react/src/internal/hidden.tsx new file mode 100644 index 0000000000..4e9ffac056 --- /dev/null +++ b/packages/@headlessui-react/src/internal/hidden.tsx @@ -0,0 +1,47 @@ +import { ElementType, Ref } from 'react' +import { Props } from '../types' +import { forwardRefWithAs, render } from '../utils/render' + +let DEFAULT_VISUALLY_HIDDEN_TAG = 'div' as const + +export enum Features { + // The default, no features. + None = 1 << 0, + + // Whether the element should be focusable or not. + Focusable = 1 << 1, + + // Whether it should be completely hidden, even to assistive technologies. + Hidden = 1 << 2, +} + +export let Hidden = forwardRefWithAs(function VisuallyHidden< + TTag extends ElementType = typeof DEFAULT_VISUALLY_HIDDEN_TAG +>(props: Props & { features?: Features }, ref: Ref) { + let { features = Features.None, ...theirProps } = props + let ourProps = { + ref, + 'aria-hidden': (features & Features.Focusable) === Features.Focusable ? true : undefined, + style: { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: '0', + ...((features & Features.Hidden) === Features.Hidden && + !((features & Features.Focusable) === Features.Focusable) && { display: 'none' }), + }, + } + + return render({ + ourProps, + theirProps, + slot: {}, + defaultTag: DEFAULT_VISUALLY_HIDDEN_TAG, + name: 'Hidden', + }) +}) diff --git a/packages/@headlessui-react/src/internal/visually-hidden.tsx b/packages/@headlessui-react/src/internal/visually-hidden.tsx deleted file mode 100644 index e3a0b2bbb6..0000000000 --- a/packages/@headlessui-react/src/internal/visually-hidden.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ElementType, Ref } from 'react' -import { Props } from '../types' -import { forwardRefWithAs, render } from '../utils/render' - -let DEFAULT_VISUALLY_HIDDEN_TAG = 'div' as const - -export let VisuallyHidden = forwardRefWithAs(function VisuallyHidden< - TTag extends ElementType = typeof DEFAULT_VISUALLY_HIDDEN_TAG ->(props: Props, ref: Ref) { - let theirProps = props - let ourProps = { - ref, - style: { - position: 'absolute', - width: 1, - height: 1, - padding: 0, - margin: -1, - overflow: 'hidden', - clip: 'rect(0, 0, 0, 0)', - whiteSpace: 'nowrap', - borderWidth: '0', - display: 'none', - }, - } - - return render({ - ourProps, - theirProps, - slot: {}, - defaultTag: DEFAULT_VISUALLY_HIDDEN_TAG, - name: 'VisuallyHidden', - }) -}) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 587933f4f7..c60112427c 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -32,7 +32,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useTreeWalker } from '../../hooks/use-tree-walker' import { sortByDomNode } from '../../utils/focus-management' import { useOutsideClick } from '../../hooks/use-outside-click' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' enum ComboboxStates { @@ -432,8 +432,9 @@ export let Combobox = defineComponent({ ...(name != null && modelValue != null ? objectToFormEntries({ [name]: modelValue }).map(([name, value]) => h( - VisuallyHidden, + Hidden, compact({ + features: HiddenFeatures.Hidden, key: name, as: 'input', type: 'hidden', diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 7e8814c2a5..0ba781c473 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -46,7 +46,7 @@ afterAll(() => jest.restoreAllMocks()) let TabSentinel = defineComponent({ name: 'TabSentinel', - template: html`
`, + template: html`
`, }) jest.mock('../../hooks/use-id') diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index b02897b5da..e191758308 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -20,7 +20,7 @@ import { import { render, Features } from '../../utils/render' import { Keys } from '../../keyboard' import { useId } from '../../hooks/use-id' -import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap' +import { FocusTrap } from '../../components/focus-trap/focus-trap' import { useInertOthers } from '../../hooks/use-inert-others' import { Portal, PortalGroup } from '../portal/portal' import { StackMessage, useStackProvider } from '../../internal/stack-context' @@ -32,6 +32,7 @@ import { useOpenClosed, State } from '../../internal/open-closed' import { useOutsideClick, Features as OutsideClickFeatures } 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' enum DialogStates { Open, @@ -93,6 +94,10 @@ export let Dialog = defineComponent({ let containers = ref>>(new Set()) let internalDialogRef = ref(null) + + // Reference to a node in the "main" tree, not in the portalled Dialog tree. + let mainTreeNode = ref(null) + let ownerDocument = computed(() => getOwnerDocument(internalDialogRef)) expose({ el: internalDialogRef, $el: internalDialogRef }) @@ -122,21 +127,6 @@ export let Dialog = defineComponent({ // in between. We only care abou whether you are the top most one or not. let position = computed(() => (!hasNestedDialogs.value ? 'leaf' : 'parent')) - let previousElement = useFocusTrap( - internalDialogRef, - computed(() => { - return enabled.value - ? match(position.value, { - parent: FocusTrapFeatures.RestoreFocus, - leaf: FocusTrapFeatures.All & ~FocusTrapFeatures.FocusLock, - }) - : FocusTrapFeatures.None - }), - computed(() => ({ - initialFocus: ref(props.initialFocus), - containers, - })) - ) useInertOthers( internalDialogRef, computed(() => (hasNestedDialogs.value ? enabled.value : false)) @@ -192,7 +182,7 @@ export let Dialog = defineComponent({ ownerDocument.value?.querySelectorAll('body > *') ?? [] ).filter((container) => { if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements - if (container.contains(previousElement.value)) return false // Skip if it is the main app + if (container.contains(dom(mainTreeNode))) return false // Skip if it is the main app if (api.panelRef.value && container.contains(api.panelRef.value)) return false return true // Keep }) @@ -291,23 +281,38 @@ export let Dialog = defineComponent({ let slot = { open: dialogState.value === DialogStates.Open } - return h(ForcePortalRoot, { force: true }, () => + return h(ForcePortalRoot, { force: true }, () => [ h(Portal, () => h(PortalGroup, { target: internalDialogRef.value }, () => h(ForcePortalRoot, { force: false }, () => - render({ - props: { ...incomingProps, ...ourProps }, - slot, - attrs, - slots, - visible: dialogState.value === DialogStates.Open, - features: Features.RenderStrategy | Features.Static, - name: 'Dialog', - }) + h( + FocusTrap, + { + initialFocus, + containers, + features: enabled.value + ? match(position.value, { + parent: FocusTrap.features.RestoreFocus, + leaf: FocusTrap.features.All & ~FocusTrap.features.FocusLock, + }) + : FocusTrap.features.None, + }, + () => + render({ + props: { ...incomingProps, ...ourProps }, + slot, + attrs, + slots, + visible: dialogState.value === DialogStates.Open, + features: Features.RenderStrategy | Features.Static, + name: 'Dialog', + }) + ) ) ) - ) - ) + ), + h(Hidden, { features: HiddenFeatures.Hidden, ref: mainTreeNode }), + ]) } }, }) diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts index f42ed2ae54..b488b4e98e 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts @@ -1,40 +1,292 @@ import { computed, defineComponent, + h, + onMounted, ref, + watch, // Types PropType, + Fragment, + Ref, } from 'vue' import { render } from '../../utils/render' -import { useFocusTrap } from '../../hooks/use-focus-trap' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' +import { dom } from '../../utils/dom' +import { focusIn, Focus, focusElement, FocusResult } from '../../utils/focus-management' +import { match } from '../../utils/match' +import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab-direction' +import { getOwnerDocument } from '../../utils/owner' +import { useEventListener } from '../../hooks/use-event-listener' +import { microTask } from '../../utils/micro-task' -export let FocusTrap = defineComponent({ - name: 'FocusTrap', - props: { - as: { type: [Object, String], default: 'div' }, - initialFocus: { type: Object as PropType, default: null }, +enum Features { + /** No features enabled for the focus trap. */ + None = 1 << 0, + + /** Ensure that we move focus initially into the container. */ + InitialFocus = 1 << 1, + + /** Ensure that pressing `Tab` and `Shift+Tab` is trapped within the container. */ + TabLock = 1 << 2, + + /** Ensure that programmatically moving focus outside of the container is disallowed. */ + FocusLock = 1 << 3, + + /** Ensure that we restore the focus when unmounting the focus trap. */ + RestoreFocus = 1 << 4, + + /** Enable all features. */ + All = InitialFocus | TabLock | FocusLock | RestoreFocus, +} + +export let FocusTrap = Object.assign( + defineComponent({ + name: 'FocusTrap', + props: { + as: { type: [Object, String], default: 'div' }, + initialFocus: { type: Object as PropType, default: null }, + features: { type: Number as PropType, default: Features.All }, + containers: { + type: Object as PropType>>>, + default: ref(new Set()), + }, + }, + inheritAttrs: false, + setup(props, { attrs, slots, expose }) { + let container = ref(null) + + expose({ el: container, $el: container }) + + let ownerDocument = computed(() => getOwnerDocument(container)) + + useRestoreFocus( + { ownerDocument }, + computed(() => Boolean(props.features & Features.RestoreFocus)) + ) + let previousActiveElement = useInitialFocus( + { ownerDocument, container, initialFocus: computed(() => props.initialFocus) }, + computed(() => Boolean(props.features & Features.InitialFocus)) + ) + useFocusLock( + { + ownerDocument, + container, + containers: props.containers, + previousActiveElement, + }, + computed(() => Boolean(props.features & Features.FocusLock)) + ) + + let direction = useTabDirection() + function handleFocus() { + let el = dom(container) as HTMLElement + if (!el) return + + // TODO: Cleanup once we are using real browser tests + if (process.env.NODE_ENV === 'test') { + microTask(() => { + match(direction.value, { + [TabDirection.Forwards]: () => focusIn(el, Focus.First), + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + }) + } else { + match(direction.value, { + [TabDirection.Forwards]: () => focusIn(el, Focus.First), + [TabDirection.Backwards]: () => focusIn(el, Focus.Last), + }) + } + } + + return () => { + let slot = {} + let ourProps = { 'data-hi': 'container', ref: container } + let { features, initialFocus, containers: _containers, ...incomingProps } = props + + return h(Fragment, [ + Boolean(features & Features.TabLock) && + h(Hidden, { + as: 'button', + type: 'button', + onFocus: handleFocus, + features: HiddenFeatures.Focusable, + }), + render({ + props: { ...attrs, ...incomingProps, ...ourProps }, + slot, + attrs, + slots, + name: 'FocusTrap', + }), + Boolean(features & Features.TabLock) && + h(Hidden, { + as: 'button', + type: 'button', + onFocus: handleFocus, + features: HiddenFeatures.Focusable, + }), + ]) + } + }, + }), + { features: Features } +) + +function useRestoreFocus( + { ownerDocument }: { ownerDocument: Ref }, + enabled: Ref +) { + let restoreElement = ref(null) + + // Deliberately not using a ref, we don't want to trigger re-renders. + let mounted = { value: false } + + onMounted(() => { + // Capture the currently focused element, before we try to move the focus inside the FocusTrap. + watch( + enabled, + (newValue, prevValue) => { + if (newValue === prevValue) return + if (!enabled.value) return + + mounted.value = true + + if (!restoreElement.value) { + restoreElement.value = ownerDocument.value?.activeElement as HTMLElement + } + }, + { immediate: true } + ) + + // Restore the focus when we unmount the component. + watch( + enabled, + (newValue, prevValue, onInvalidate) => { + if (newValue === prevValue) return + if (!enabled.value) return + + onInvalidate(() => { + if (mounted.value === false) return + mounted.value = false + + focusElement(restoreElement.value) + restoreElement.value = null + }) + }, + { immediate: true } + ) + }) +} + +function useInitialFocus( + { + ownerDocument, + container, + initialFocus, + }: { + ownerDocument: Ref + container: Ref + initialFocus?: Ref }, - setup(props, { attrs, slots, expose }) { - let container = ref(null) - - expose({ el: container, $el: container }) - - let focusTrapOptions = computed(() => ({ initialFocus: ref(props.initialFocus) })) - useFocusTrap(container, FocusTrap.All, focusTrapOptions) - - return () => { - let slot = {} - let ourProps = { ref: container } - let { initialFocus, ...incomingProps } = props - - return render({ - props: { ...incomingProps, ...ourProps }, - slot, - attrs, - slots, - name: 'FocusTrap', - }) - } + enabled: Ref +) { + let previousActiveElement = ref(null) + + onMounted(() => { + watch( + // Handle initial focus + [container, initialFocus, enabled], + (newValues, prevValues) => { + if (newValues.every((value, idx) => prevValues?.[idx] === value)) return + if (!enabled.value) return + + let containerElement = dom(container) + if (!containerElement) return + + let initialFocusElement = dom(initialFocus) + + let activeElement = ownerDocument.value?.activeElement as HTMLElement + + if (initialFocusElement) { + if (initialFocusElement === activeElement) { + previousActiveElement.value = activeElement + return // Initial focus ref is already the active element + } + } else if (containerElement.contains(activeElement)) { + previousActiveElement.value = activeElement + return // Already focused within Dialog + } + + // Try to focus the initialFocus ref + if (initialFocusElement) { + focusElement(initialFocusElement) + } else { + if (focusIn(containerElement, Focus.First) === FocusResult.Error) { + console.warn('There are no focusable elements inside the ') + } + } + + previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement + }, + { immediate: true, flush: 'post' } + ) + }) + + return previousActiveElement +} + +function useFocusLock( + { + ownerDocument, + container, + containers, + previousActiveElement, + }: { + ownerDocument: Ref + container: Ref + containers: Ref>> + previousActiveElement: Ref }, -}) + enabled: Ref +) { + // Prevent programmatically escaping + useEventListener( + ownerDocument.value?.defaultView, + 'focus', + (event) => { + if (!enabled.value) return + + let allContainers = new Set(containers?.value) + allContainers.add(container) + + let previous = previousActiveElement.value + if (!previous) return + + let toElement = event.target as HTMLElement | null + + if (toElement && toElement instanceof HTMLElement) { + if (!contains(allContainers, toElement)) { + event.preventDefault() + event.stopPropagation() + focusElement(previous) + } else { + previousActiveElement.value = toElement + focusElement(toElement) + } + } else { + focusElement(previousActiveElement.value) + } + }, + true + ) +} + +function contains(containers: Set>, element: HTMLElement) { + for (let container of containers) { + if (container.value?.contains(element)) return true + } + + return false +} diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index e720810213..8c9b0a7f58 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -30,7 +30,7 @@ import { match } from '../../utils/match' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management' import { useOutsideClick } from '../../hooks/use-outside-click' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { objectToFormEntries } from '../../utils/form' enum ListboxStates { @@ -315,8 +315,9 @@ export let Listbox = defineComponent({ ...(name != null && modelValue != null ? objectToFormEntries({ [name]: modelValue }).map(([name, value]) => h( - VisuallyHidden, + Hidden, compact({ + features: HiddenFeatures.Hidden, key: name, as: 'input', type: 'hidden', 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 2ca9bff452..a1dad48b9b 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -23,7 +23,7 @@ import { compact, omit, render } from '../../utils/render' import { Label, useLabels } from '../label/label' import { Description, useDescriptions } from '../description/description' import { useTreeWalker } from '../../hooks/use-tree-walker' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { attemptSubmit, objectToFormEntries } from '../../utils/form' import { getOwnerDocument } from '../../utils/owner' @@ -210,8 +210,9 @@ export let RadioGroup = defineComponent({ ...(name != null && modelValue != null ? objectToFormEntries({ [name]: modelValue }).map(([name, value]) => h( - VisuallyHidden, + Hidden, compact({ + features: HiddenFeatures.Hidden, key: name, as: 'input', type: 'hidden', diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts index a6c69d1e5e..a78a702f16 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.ts +++ b/packages/@headlessui-vue/src/components/switch/switch.ts @@ -18,7 +18,7 @@ import { Keys } from '../../keyboard' import { Label, useLabels } from '../label/label' import { Description, useDescriptions } from '../description/description' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' -import { VisuallyHidden } from '../../internal/visually-hidden' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { attemptSubmit } from '../../utils/form' type StateDefinition = { @@ -127,8 +127,9 @@ export let Switch = defineComponent({ return h(Fragment, [ name != null && modelValue != null ? h( - VisuallyHidden, + Hidden, compact({ + features: HiddenFeatures.Hidden, as: 'input', type: 'checkbox', hidden: true, diff --git a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts deleted file mode 100644 index 776f4c4fa2..0000000000 --- a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { - computed, - onMounted, - ref, - watch, - - // Types - Ref, -} from 'vue' - -import { Keys } from '../keyboard' -import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management' -import { getOwnerDocument } from '../utils/owner' -import { useEventListener } from './use-event-listener' -import { dom } from '../utils/dom' - -export enum Features { - /** No features enabled for the `useFocusTrap` hook. */ - None = 1 << 0, - - /** Ensure that we move focus initially into the container. */ - InitialFocus = 1 << 1, - - /** Ensure that pressing `Tab` and `Shift+Tab` is trapped within the container. */ - TabLock = 1 << 2, - - /** Ensure that programmatically moving focus outside of the container is disallowed. */ - FocusLock = 1 << 3, - - /** Ensure that we restore the focus when unmounting the component that uses this `useFocusTrap` hook. */ - RestoreFocus = 1 << 4, - - /** Enable all features. */ - All = InitialFocus | TabLock | FocusLock | RestoreFocus, -} - -export function useFocusTrap( - container: Ref, - features: Ref = ref(Features.All), - options: Ref<{ - initialFocus?: Ref - containers?: Ref>> - }> = ref({}) -) { - let restoreElement = ref(null) - let previousActiveElement = ref(null) - // Deliberately not using a ref, we don't want to trigger re-renders. - let mounted = { value: false } - - let featuresRestoreFocus = computed(() => Boolean(features.value & Features.RestoreFocus)) - let featuresInitialFocus = computed(() => Boolean(features.value & Features.InitialFocus)) - - let ownerDocument = computed(() => getOwnerDocument(container)) - - onMounted(() => { - // Capture the currently focused element, before we enable the focus trap. - watch( - featuresRestoreFocus, - (newValue, prevValue) => { - if (newValue === prevValue) return - if (!featuresRestoreFocus.value) return - - mounted.value = true - - if (!restoreElement.value) { - restoreElement.value = ownerDocument.value?.activeElement as HTMLElement - } - }, - { immediate: true } - ) - - // Restore the focus when we unmount the component. - watch( - featuresRestoreFocus, - (newValue, prevValue, onInvalidate) => { - if (newValue === prevValue) return - if (!featuresRestoreFocus.value) return - - onInvalidate(() => { - if (mounted.value === false) return - mounted.value = false - - focusElement(restoreElement.value) - restoreElement.value = null - }) - }, - { immediate: true } - ) - - // Handle initial focus - watch( - [container, options, options.value.initialFocus, featuresInitialFocus], - (newValues, prevValues) => { - if (newValues.every((value, idx) => prevValues?.[idx] === value)) return - if (!featuresInitialFocus.value) return - - let containerElement = container.value - if (!containerElement) return - - let initialFocusElement = dom(options.value.initialFocus) - - let activeElement = ownerDocument.value?.activeElement as HTMLElement - - if (initialFocusElement) { - if (initialFocusElement === activeElement) { - previousActiveElement.value = activeElement - return // Initial focus ref is already the active element - } - } else if (containerElement.contains(activeElement)) { - previousActiveElement.value = activeElement - return // Already focused within Dialog - } - - // Try to focus the initialFocus ref - if (initialFocusElement) { - focusElement(initialFocusElement) - } else { - if (focusIn(containerElement, Focus.First) === FocusResult.Error) { - console.warn('There are no focusable elements inside the ') - } - } - - previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement - }, - { immediate: true } - ) - }) - - // Handle Tab & Shift+Tab keyboard events - useEventListener(ownerDocument.value?.defaultView, 'keydown', (event) => { - if (!(features.value & Features.TabLock)) return - - if (!container.value) return - if (event.key !== Keys.Tab) return - - event.preventDefault() - - if ( - focusIn( - container.value, - (event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround - ) === FocusResult.Success - ) { - previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement - } - }) - - // Prevent programmatically escaping - useEventListener( - ownerDocument.value?.defaultView, - 'focus', - (event) => { - if (!(features.value & Features.FocusLock)) return - - let allContainers = new Set(options.value.containers?.value) - allContainers.add(container) - - if (!allContainers.size) return - - let previous = previousActiveElement.value - if (!previous) return - if (!mounted.value) return - - let toElement = event.target as HTMLElement | null - - if (toElement && toElement instanceof HTMLElement) { - if (!contains(allContainers, toElement)) { - event.preventDefault() - event.stopPropagation() - focusElement(previous) - } else { - previousActiveElement.value = toElement - focusElement(toElement) - } - } else { - focusElement(previousActiveElement.value) - } - }, - true - ) - - return restoreElement -} - -function contains(containers: Set>, element: HTMLElement) { - for (let container of containers) { - if (container.value?.contains(element)) return true - } - - return false -} diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts index 6af891ec04..fc2891b7a7 100644 --- a/packages/@headlessui-vue/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-vue/src/hooks/use-outside-click.ts @@ -1,21 +1,7 @@ import { useWindowEvent } from './use-window-event' import { Ref } from 'vue' import { dom } from '../utils/dom' - -// Polyfill -function microTask(cb: () => void) { - if (typeof queueMicrotask === 'function') { - queueMicrotask(cb) - } else { - Promise.resolve() - .then(cb) - .catch((e) => - setTimeout(() => { - throw e - }) - ) - } -} +import { microTask } from '../utils/micro-task' type Container = Ref | HTMLElement | null type ContainerCollection = Container[] | Set diff --git a/packages/@headlessui-vue/src/hooks/use-tab-direction.ts b/packages/@headlessui-vue/src/hooks/use-tab-direction.ts new file mode 100644 index 0000000000..f4b9ebe4f6 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-tab-direction.ts @@ -0,0 +1,19 @@ +import { ref } from 'vue' +import { useWindowEvent } from './use-window-event' + +export enum Direction { + Forwards, + Backwards, +} + +export function useTabDirection() { + let direction = ref(Direction.Forwards) + + useWindowEvent('keydown', (event) => { + if (event.key === 'Tab') { + direction.value = event.shiftKey ? Direction.Backwards : Direction.Forwards + } + }) + + return direction +} diff --git a/packages/@headlessui-vue/src/internal/focus-sentinel.ts b/packages/@headlessui-vue/src/internal/focus-sentinel.ts index c574861958..40370b491e 100644 --- a/packages/@headlessui-vue/src/internal/focus-sentinel.ts +++ b/packages/@headlessui-vue/src/internal/focus-sentinel.ts @@ -1,6 +1,6 @@ import { h, ref, defineComponent } from 'vue' -import { VisuallyHidden } from './visually-hidden' +import { Hidden, Features } from './hidden' export let FocusSentinel = defineComponent({ props: { @@ -15,9 +15,10 @@ export let FocusSentinel = defineComponent({ return () => { if (!enabled.value) return null - return h(VisuallyHidden, { + return h(Hidden, { as: 'button', type: 'button', + features: Features.Focusable, onFocus(event: FocusEvent) { event.preventDefault() let frame: ReturnType diff --git a/packages/@headlessui-vue/src/internal/hidden.ts b/packages/@headlessui-vue/src/internal/hidden.ts new file mode 100644 index 0000000000..7401a5df2c --- /dev/null +++ b/packages/@headlessui-vue/src/internal/hidden.ts @@ -0,0 +1,50 @@ +import { defineComponent, PropType } from 'vue' +import { render } from '../utils/render' + +export enum Features { + // The default, no features. + None = 1 << 0, + + // Whether the element should be focusable or not. + Focusable = 1 << 1, + + // Whether it should be completely hidden, even to assistive technologies. + Hidden = 1 << 2, +} + +export let Hidden = defineComponent({ + name: 'Hidden', + props: { + as: { type: [Object, String], default: 'div' }, + features: { type: Number as PropType, default: Features.None }, + }, + setup(props, { slots, attrs }) { + return () => { + let { features, ...theirProps } = props + let ourProps = { + 'aria-hidden': (features & Features.Focusable) === Features.Focusable ? true : undefined, + style: { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: '0', + ...((features & Features.Hidden) === Features.Hidden && + !((features & Features.Focusable) === Features.Focusable) && { display: 'none' }), + }, + } + + return render({ + props: { ...theirProps, ...ourProps }, + slot: {}, + attrs, + slots, + name: 'Hidden', + }) + } + }, +}) diff --git a/packages/@headlessui-vue/src/internal/visually-hidden.ts b/packages/@headlessui-vue/src/internal/visually-hidden.ts deleted file mode 100644 index d4268fe8a9..0000000000 --- a/packages/@headlessui-vue/src/internal/visually-hidden.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineComponent } from 'vue' -import { render } from '../utils/render' - -export let VisuallyHidden = defineComponent({ - name: 'VisuallyHidden', - props: { - as: { type: [Object, String], default: 'div' }, - }, - setup(props, { slots, attrs }) { - return () => { - let ourProps = { - style: { - position: 'absolute', - width: 1, - height: 1, - padding: 0, - margin: -1, - overflow: 'hidden', - clip: 'rect(0, 0, 0, 0)', - whiteSpace: 'nowrap', - borderWidth: '0', - display: 'none', - }, - } - - return render({ - props: { ...props, ...ourProps }, - slot: {}, - attrs, - slots, - name: 'VisuallyHidden', - }) - } - }, -}) diff --git a/packages/@headlessui-vue/src/utils/micro-task.ts b/packages/@headlessui-vue/src/utils/micro-task.ts new file mode 100644 index 0000000000..3098563a55 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/micro-task.ts @@ -0,0 +1,14 @@ +// Polyfill +export function microTask(cb: () => void) { + if (typeof queueMicrotask === 'function') { + queueMicrotask(cb) + } else { + Promise.resolve() + .then(cb) + .catch((e) => + setTimeout(() => { + throw e + }) + ) + } +}