From 863f63c63b3d34040dbe7b62f32c4cf40c4d8564 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 16:44:17 +0200 Subject: [PATCH 01/10] 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"`) --- .../src/components/combobox/combobox.tsx | 5 +- .../src/components/listbox/listbox.tsx | 5 +- .../components/radio-group/radio-group.tsx | 5 +- .../src/components/switch/switch.tsx | 5 +- .../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/listbox/listbox.ts | 5 +- .../src/components/radio-group/radio-group.ts | 5 +- .../src/components/switch/switch.ts | 5 +- .../src/internal/focus-sentinel.ts | 5 +- .../@headlessui-vue/src/internal/hidden.ts | 50 +++++++++++++++++++ .../src/internal/visually-hidden.ts | 35 ------------- 14 files changed, 127 insertions(+), 89 deletions(-) create mode 100644 packages/@headlessui-react/src/internal/hidden.tsx delete mode 100644 packages/@headlessui-react/src/internal/visually-hidden.tsx create mode 100644 packages/@headlessui-vue/src/internal/hidden.ts delete mode 100644 packages/@headlessui-vue/src/internal/visually-hidden.ts diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 38db7e71f..84e3f796a 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]) => ( - ( - ( - {name != null && checked && ( - { 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 000000000..4e9ffac05 --- /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 e3a0b2bbb..000000000 --- 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 587933f4f..c60112427 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/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index e72081021..8c9b0a7f5 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 2ca9bff45..a1dad48b9 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 a6c69d1e5..a78a702f1 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/internal/focus-sentinel.ts b/packages/@headlessui-vue/src/internal/focus-sentinel.ts index c57486195..40370b491 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 000000000..7401a5df2 --- /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 d4268fe8a..000000000 --- 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', - }) - } - }, -}) From 8cc70fd55bb053b015d8fa56c4fa4e5304465449 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 16:54:40 +0200 Subject: [PATCH 02/10] 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. --- packages/@headlessui-react/src/hooks/use-event.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/@headlessui-react/src/hooks/use-event.ts 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 000000000..893893e35 --- /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]) + } From caccbab0f64ac410a4ba1819b1c456bcea435cf3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 18:00:40 +0200 Subject: [PATCH 03/10] 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. --- .../src/hooks/use-tab-direction.ts | 23 +++++++++++++++++++ .../src/hooks/use-tab-direction.ts | 19 +++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/@headlessui-react/src/hooks/use-tab-direction.ts create mode 100644 packages/@headlessui-vue/src/hooks/use-tab-direction.ts 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 000000000..55ab59d59 --- /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-vue/src/hooks/use-tab-direction.ts b/packages/@headlessui-vue/src/hooks/use-tab-direction.ts new file mode 100644 index 000000000..f4b9ebe4f --- /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 +} From 68e3cb15a2fbba0b2366cfa9f1e278da24f1b55e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 18:10:03 +0200 Subject: [PATCH 04/10] add `useWatch` hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is similar to the `useEffect` hook, but only executes if values are _actually_ changing... 😒 --- .../@headlessui-react/src/hooks/use-watch.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/@headlessui-react/src/hooks/use-watch.ts 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 000000000..18f591741 --- /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]) +} From 93d6c8bcee3cdfa9ccdd4e34d37323ff6b3ff659 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 11 May 2022 14:08:17 +0200 Subject: [PATCH 05/10] add `microTask` util --- .../src/hooks/use-outside-click.ts | 16 +--------------- packages/@headlessui-vue/src/utils/micro-task.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 packages/@headlessui-vue/src/utils/micro-task.ts diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts index 6af891ec0..fc2891b7a 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/utils/micro-task.ts b/packages/@headlessui-vue/src/utils/micro-task.ts new file mode 100644 index 000000000..3098563a5 --- /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 + }) + ) + } +} From 4fbe5759eac8319c81c819578af5528706c63707 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 18:54:10 +0200 Subject: [PATCH 06/10] 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! --- .../src/components/focus-trap/focus-trap.tsx | 263 ++++++++++++++- .../src/hooks/use-focus-trap.ts | 165 ---------- .../src/components/focus-trap/focus-trap.ts | 308 ++++++++++++++++-- .../src/hooks/use-focus-trap.ts | 191 ----------- 4 files changed, 527 insertions(+), 400 deletions(-) delete mode 100644 packages/@headlessui-react/src/hooks/use-focus-trap.ts delete mode 100644 packages/@headlessui-vue/src/hooks/use-focus-trap.ts 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 92dd61a6e..c06750641 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,263 @@ 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 enum FocusTrapFeatures { + /** 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 component that uses this `useFocusTrap` hook. */ + RestoreFocus = 1 << 4, + + /** Enable all features. */ + All = InitialFocus | TabLock | FocusLock | RestoreFocus, +} + export let FocusTrap = forwardRefWithAs(function FocusTrap< TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG >( - props: Props & { initialFocus?: MutableRefObject }, - ref: Ref + props: Props & { + initialFocus?: MutableRefObject + features?: FocusTrapFeatures + containers?: MutableRefObject>> + }, + ref: Ref ) { - let container = useRef(null) + let container = useRef(null) let focusTrapRef = useSyncRefs(container, ref) - let { initialFocus, ...theirProps } = props - - let ready = useServerHandoffComplete() - useFocusTrap(container, ready ? FocusTrapFeatures.All : FocusTrapFeatures.None, { initialFocus }) + let { initialFocus, containers, features = FocusTrapFeatures.All, ...theirProps } = props - let ourProps = { - ref: focusTrapRef, + if (!useServerHandoffComplete()) { + features = FocusTrapFeatures.None } - return render({ - ourProps, - theirProps, - defaultTag: DEFAULT_FOCUS_TRAP_TAG, - name: 'FocusTrap', + let ownerDocument = useOwnerDocument(container) + + useRestoreFocus({ ownerDocument }, Boolean(features & FocusTrapFeatures.RestoreFocus)) + let previousActiveElement = useInitialFocus( + { ownerDocument, container, initialFocus }, + Boolean(features & FocusTrapFeatures.InitialFocus) + ) + useFocusLock( + { ownerDocument, container, containers, previousActiveElement }, + Boolean(features & FocusTrapFeatures.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 & FocusTrapFeatures.TabLock) && ( + + )} + {render({ + ourProps, + theirProps, + defaultTag: DEFAULT_FOCUS_TRAP_TAG, + name: 'FocusTrap', + })} + {Boolean(features & FocusTrapFeatures.TabLock) && ( + + )} + + ) }) + +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 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 + + 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 false +} 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 3a0313590..000000000 --- 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-vue/src/components/focus-trap/focus-trap.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts index f42ed2ae5..b488b4e98 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/hooks/use-focus-trap.ts b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts deleted file mode 100644 index 776f4c4fa..000000000 --- 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 -} From 549d8a1351a11dab8d8fad3e59784f8cd1b7d666 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 19:00:15 +0200 Subject: [PATCH 07/10] 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. --- .../src/components/dialog/dialog.tsx | 53 +++--- .../src/components/focus-trap/focus-trap.tsx | 155 +++++++++--------- .../src/components/dialog/dialog.test.ts | 2 +- .../src/components/dialog/dialog.ts | 63 +++---- 4 files changed, 144 insertions(+), 129 deletions(-) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index d7968be08..b30176c22 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -25,7 +25,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs' import { Keys } from '../keyboard' import { isDisabledReactIssue7711 } from '../../utils/bugs' 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 } from '../../components/portal/portal' import { ForcePortalRoot } from '../../internal/portal-force-root' @@ -37,6 +37,7 @@ import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/u import { getOwnerDocument } from '../../utils/owner' import { useOwnerDocument } from '../../hooks/use-owner' import { useEventListener } from '../../hooks/use-event-listener' +import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' enum DialogStates { Open, @@ -137,6 +138,9 @@ let DialogRoot = forwardRefWithAs(function Dialog< let internalDialogRef = useRef(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 c06750641..1005f18ce 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -25,7 +25,7 @@ import { useWatch } from '../../hooks/use-watch' let DEFAULT_FOCUS_TRAP_TAG = 'div' as const -export enum FocusTrapFeatures { +enum Features { /** No features enabled for the focus trap. */ None = 1 << 0, @@ -38,93 +38,94 @@ export enum FocusTrapFeatures { /** 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. */ + /** 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 = forwardRefWithAs(function FocusTrap< - TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG ->( - props: Props & { - initialFocus?: MutableRefObject - features?: FocusTrapFeatures - containers?: MutableRefObject>> - }, - ref: Ref -) { - let container = useRef(null) - let focusTrapRef = useSyncRefs(container, ref) - let { initialFocus, containers, features = FocusTrapFeatures.All, ...theirProps } = props - - if (!useServerHandoffComplete()) { - features = FocusTrapFeatures.None - } - - let ownerDocument = useOwnerDocument(container) - - useRestoreFocus({ ownerDocument }, Boolean(features & FocusTrapFeatures.RestoreFocus)) - let previousActiveElement = useInitialFocus( - { ownerDocument, container, initialFocus }, - Boolean(features & FocusTrapFeatures.InitialFocus) - ) - useFocusLock( - { ownerDocument, container, containers, previousActiveElement }, - Boolean(features & FocusTrapFeatures.FocusLock) - ) - - let direction = useTabDirection() - let handleFocus = useEvent(() => { - let el = container.current as HTMLElement - if (!el) return +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 + } - // TODO: Cleanup once we are using real browser tests - if (process.env.NODE_ENV === 'test') { - microTask(() => { + 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), }) - }) - } else { - match(direction.current, { - [TabDirection.Forwards]: () => focusIn(el, Focus.First), - [TabDirection.Backwards]: () => focusIn(el, Focus.Last), - }) - } - }) - - let ourProps = { ref: focusTrapRef } - - return ( - <> - {Boolean(features & FocusTrapFeatures.TabLock) && ( - - )} - {render({ - ourProps, - theirProps, - defaultTag: DEFAULT_FOCUS_TRAP_TAG, - name: 'FocusTrap', - })} - {Boolean(features & FocusTrapFeatures.TabLock) && ( - - )} - - ) -}) + } + }) + + 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) diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 7e8814c2a..0ba781c47 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 b02897b5d..e19175830 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 }), + ]) } }, }) From 8eae0f8839a84e9d425118eb7b57014109d07328 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 20:20:17 +0200 Subject: [PATCH 08/10] 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. --- .../src/components/portal/portal.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index 54959e395..b3ee03e63 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -20,6 +20,7 @@ import { usePortalRoot } from '../../internal/portal-force-root' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs' import { useOwnerDocument } from '../../hooks/use-owner' +import { microTask } from '../../utils/micro-task' function usePortalTarget(ref: MutableRefObject): 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]) From 33583ffe43400e4ab7dbcce4750b8057c1c045ba Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 10 May 2022 20:32:15 +0200 Subject: [PATCH 09/10] refactor: use `useEvent` instead of `useLatestValue` Not really necessary, just cleaner. --- .../src/components/transitions/transition.tsx | 21 ++++++++++--------- .../src/hooks/use-outside-click.ts | 8 +++---- .../src/hooks/use-transition.ts | 9 ++++---- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index 95c6bc198..c45e2a959 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -30,6 +30,7 @@ import { useLatestValue } from '../../hooks/use-latest-value' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTransition } from '../../hooks/use-transition' +import { useEvent } from '../../hooks/use-event' type ID = ReturnType @@ -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-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index d0b08983e..8cef785e0 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-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index e066bbd9a..7c1b029dd 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]: () => {}, From 974e1d503a208edfe98b99955f535887840bfb89 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 11 May 2022 14:58:33 +0200 Subject: [PATCH 10/10] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba4252c4..b354073ba 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