From bf0d1120d353101007d10dcaccaa8ccb90001a97 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 11 May 2022 15:03:54 +0200 Subject: [PATCH] Improve `FocusTrap` behaviour (#1432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor `VisuallyHidden` to `Hidden` component This new component will also make sure that it is visually hidden to sighted users. However, it contains a few more features that are going to be useful in other places as well. These features include: 1. Make visually hidden to sighted users (default) 2. Hide from assistive technology via `features={Features.Hidden}` (will add `display: none;`) 3. Hide from assistive technology but make the element focusable via `features={Features.Focusable}` (will add `aria-hidden="true"`) * add `useEvent` hook This will behave the same (roughly) as the new to be released `useEvent` hook in React 18.X This hook allows you to have a stable function that can "see" the latest data it is using. We already had this concept using: ```js let handleX = useLatestValue(() => { // ... }) ``` But this returned a stable ref so you had to call `handleX.current()`. This new hook is a bit nicer to work with but doesn't change much in the end. * add `useTabDirection` hook This keeps track of the direction people are tabbing in. This returns a ref so no re-renders happen because of this hook. * add `useWatch` hook This is similar to the `useEffect` hook, but only executes if values are _actually_ changing... 😒 * add `microTask` util * refactor `useFocusTrap` hook to `FocusTrap` component Using a component directly allows us to simplify the focus trap logic itself. Instead of intercepting the Tab keydown event and figuring out the correct element to focus, we will now add 2 "guard" buttons (hence why we require a component now). These buttons will receive focus and if they do, redirect the focus to the first/last element inside the focus trap. The sweet part is that all the tabs in between those buttons will now be handled natively by the browser. No need to find the first non disabled, non hidden with correct tabIndex element! * refactor the `Dialog` component to use the `FocusTrap` component Also added a hidden button so that we know the correct "main" tree of the application. Before this we were assuming the previous active element which will still be correct in most cases but we don't have access to that anymore since the logic is encapsulated inside the FocusTrap component. * ensure `` properly cleans up We make sure that the Portal is cleaning up its `element` properly. We also make sure to call the `target.appendChild(element)` conditionally because I ran into a super annoying bug where a focused element got blurred because I believe that this re-mounts the element instead of 'moving' it or just ignoring it, if it already is in the correct spot. * refactor: use `useEvent` instead of `useLatestValue` Not really necessary, just cleaner. * update changelog --- CHANGELOG.md | 2 + .../src/components/combobox/combobox.tsx | 5 +- .../src/components/dialog/dialog.tsx | 53 ++-- .../src/components/focus-trap/focus-trap.tsx | 274 ++++++++++++++-- .../src/components/listbox/listbox.tsx | 5 +- .../src/components/portal/portal.tsx | 29 +- .../components/radio-group/radio-group.tsx | 5 +- .../src/components/switch/switch.tsx | 5 +- .../src/components/transitions/transition.tsx | 21 +- .../@headlessui-react/src/hooks/use-event.ts | 9 + .../src/hooks/use-focus-trap.ts | 165 ---------- .../src/hooks/use-outside-click.ts | 8 +- .../src/hooks/use-tab-direction.ts | 23 ++ .../src/hooks/use-transition.ts | 9 +- .../@headlessui-react/src/hooks/use-watch.ts | 18 ++ .../src/internal/focus-sentinel.tsx | 5 +- .../@headlessui-react/src/internal/hidden.tsx | 47 +++ .../src/internal/visually-hidden.tsx | 34 -- .../src/components/combobox/combobox.ts | 5 +- .../src/components/dialog/dialog.test.ts | 2 +- .../src/components/dialog/dialog.ts | 63 ++-- .../src/components/focus-trap/focus-trap.ts | 300 ++++++++++++++++-- .../src/components/listbox/listbox.ts | 5 +- .../src/components/radio-group/radio-group.ts | 5 +- .../src/components/switch/switch.ts | 5 +- .../src/hooks/use-focus-trap.ts | 191 ----------- .../src/hooks/use-outside-click.ts | 16 +- .../src/hooks/use-tab-direction.ts | 19 ++ .../src/internal/focus-sentinel.ts | 5 +- .../@headlessui-vue/src/internal/hidden.ts | 50 +++ .../src/internal/visually-hidden.ts | 35 -- .../@headlessui-vue/src/utils/micro-task.ts | 14 + 32 files changed, 848 insertions(+), 584 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-event.ts delete mode 100644 packages/@headlessui-react/src/hooks/use-focus-trap.ts create mode 100644 packages/@headlessui-react/src/hooks/use-tab-direction.ts create mode 100644 packages/@headlessui-react/src/hooks/use-watch.ts create mode 100644 packages/@headlessui-react/src/internal/hidden.tsx delete mode 100644 packages/@headlessui-react/src/internal/visually-hidden.tsx delete mode 100644 packages/@headlessui-vue/src/hooks/use-focus-trap.ts create mode 100644 packages/@headlessui-vue/src/hooks/use-tab-direction.ts create mode 100644 packages/@headlessui-vue/src/internal/hidden.ts delete mode 100644 packages/@headlessui-vue/src/internal/visually-hidden.ts create mode 100644 packages/@headlessui-vue/src/utils/micro-task.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba4252..b354073 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 38db7e7..84e3f79 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 92dd61a..1005f18 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) - let ready = useServerHandoffComplete() - useFocusTrap(container, ready ? FocusTrapFeatures.All : FocusTrapFeatures.None, { initialFocus }) + // Handle initial focus + useWatch(() => { + if (!enabled) return + let containerElement = container.current + if (!containerElement) return - let ourProps = { - ref: focusTrapRef, + 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 + + 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 + ) +} + +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 5e88763..68c98f7 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 - target.removeChild(element) + microTask(() => { + if (!trulyUnmounted.current) return + if (!target || !element) return - if (target.childNodes.length <= 0) { - target.parentElement?.removeChild(target) - } + target.removeChild(element) + + 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 2628a5a..22622b8 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 0000000..893893e --- /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 3a03135..0000000 --- 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 d0b0898..8cef785 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 0000000..55ab59d --- /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 e066bbd..7c1b029 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 0000000..18f5917 --- /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 06659cb..de32682 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 0000000..4e9ffac --- /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 e3a0b2b..0000000 --- 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 587933f..c601124 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 7e8814c..0ba781c 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 b02897b..e191758 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 f42ed2a..b488b4e 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) + enabled: Ref +) { + let previousActiveElement = ref(null) - expose({ el: container, $el: container }) + onMounted(() => { + watch( + // Handle initial focus + [container, initialFocus, enabled], + (newValues, prevValues) => { + if (newValues.every((value, idx) => prevValues?.[idx] === value)) return + if (!enabled.value) return - let focusTrapOptions = computed(() => ({ initialFocus: ref(props.initialFocus) })) - useFocusTrap(container, FocusTrap.All, focusTrapOptions) + let containerElement = dom(container) + if (!containerElement) return - return () => { - let slot = {} - let ourProps = { ref: container } - let { initialFocus, ...incomingProps } = props + let initialFocusElement = dom(initialFocus) - return render({ - props: { ...incomingProps, ...ourProps }, - slot, - attrs, - slots, - name: 'FocusTrap', - }) - } + 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 e720810..8c9b0a7 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 2ca9bff..a1dad48 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 a6c69d1..a78a702 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 776f4c4..0000000 --- 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 6af891e..fc2891b 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 0000000..f4b9ebe --- /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 c574861..40370b4 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 0000000..7401a5d --- /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 d4268fe..0000000 --- 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 0000000..3098563 --- /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 + }) + ) + } +}