Skip to content

Commit bf0d112

Browse files
authored
Improve FocusTrap behaviour (#1432)
* 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 <kbd>Tab</kbd> 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 `<Portal />` 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
1 parent c494fa3 commit bf0d112

File tree

32 files changed

+852
-588
lines changed

32 files changed

+852
-588
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Ensure `DialogPanel` exposes its ref ([#1404](https://github.com/tailwindlabs/headlessui/pull/1404))
1313
- Ignore `Escape` when event got prevented in `Dialog` component ([#1424](https://github.com/tailwindlabs/headlessui/pull/1424))
14+
- Improve `FocusTrap` behaviour ([#1432](https://github.com/tailwindlabs/headlessui/pull/1432))
1415

1516
## [Unreleased - @headlessui/react]
1617

1718
### Fixed
1819

1920
- Fix closing of `Popover.Panel` in React 18 ([#1409](https://github.com/tailwindlabs/headlessui/pull/1409))
2021
- Ignore `Escape` when event got prevented in `Dialog` component ([#1424](https://github.com/tailwindlabs/headlessui/pull/1424))
22+
- Improve `FocusTrap` behaviour ([#1432](https://github.com/tailwindlabs/headlessui/pull/1432))
2123

2224
## [@headlessui/react@1.6.1] - 2022-05-03
2325

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3636
import { useLatestValue } from '../../hooks/use-latest-value'
3737
import { useTreeWalker } from '../../hooks/use-tree-walker'
3838
import { sortByDomNode } from '../../utils/focus-management'
39-
import { VisuallyHidden } from '../../internal/visually-hidden'
39+
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
4040
import { objectToFormEntries } from '../../utils/form'
4141

4242
enum ComboboxStates {
@@ -565,7 +565,8 @@ let ComboboxRoot = forwardRefWithAs(function Combobox<
565565
{name != null &&
566566
value != null &&
567567
objectToFormEntries({ [name]: value }).map(([name, value]) => (
568-
<VisuallyHidden
568+
<Hidden
569+
features={HiddenFeatures.Hidden}
569570
{...compact({
570571
key: name,
571572
as: 'input',

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
2525
import { Keys } from '../keyboard'
2626
import { isDisabledReactIssue7711 } from '../../utils/bugs'
2727
import { useId } from '../../hooks/use-id'
28-
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
28+
import { FocusTrap } from '../../components/focus-trap/focus-trap'
2929
import { useInertOthers } from '../../hooks/use-inert-others'
3030
import { Portal } from '../../components/portal/portal'
3131
import { ForcePortalRoot } from '../../internal/portal-force-root'
@@ -37,6 +37,7 @@ import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/u
3737
import { getOwnerDocument } from '../../utils/owner'
3838
import { useOwnerDocument } from '../../hooks/use-owner'
3939
import { useEventListener } from '../../hooks/use-event-listener'
40+
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
4041

4142
enum DialogStates {
4243
Open,
@@ -137,6 +138,9 @@ let DialogRoot = forwardRefWithAs(function Dialog<
137138
let internalDialogRef = useRef<HTMLDivElement | null>(null)
138139
let dialogRef = useSyncRefs(internalDialogRef, ref)
139140

141+
// Reference to a node in the "main" tree, not in the portalled Dialog tree.
142+
let mainTreeNode = useRef<HTMLDivElement | null>(null)
143+
140144
let ownerDocument = useOwnerDocument(internalDialogRef)
141145

142146
// Validations
@@ -196,26 +200,17 @@ let DialogRoot = forwardRefWithAs(function Dialog<
196200
// in between. We only care abou whether you are the top most one or not.
197201
let position = !hasNestedDialogs ? 'leaf' : 'parent'
198202

199-
let previousElement = useFocusTrap(
200-
internalDialogRef,
201-
enabled
202-
? match(position, {
203-
parent: FocusTrapFeatures.RestoreFocus,
204-
leaf: FocusTrapFeatures.All & ~FocusTrapFeatures.FocusLock,
205-
})
206-
: FocusTrapFeatures.None,
207-
{ initialFocus, containers }
208-
)
203+
// Ensure other elements can't be interacted with
209204
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)
210205

211-
// Handle outside click
206+
// Close Dialog on outside click
212207
useOutsideClick(
213208
() => {
214209
// Third party roots
215210
let rootContainers = Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).filter(
216211
(container) => {
217212
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
218-
if (container.contains(previousElement.current)) return false // Skip if it is the main app
213+
if (container.contains(mainTreeNode.current)) return false // Skip if it is the main app
219214
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
220215
return true // Keep
221216
}
@@ -345,21 +340,35 @@ let DialogRoot = forwardRefWithAs(function Dialog<
345340
<Portal.Group target={internalDialogRef}>
346341
<ForcePortalRoot force={false}>
347342
<DescriptionProvider slot={slot} name="Dialog.Description">
348-
{render({
349-
ourProps,
350-
theirProps,
351-
slot,
352-
defaultTag: DEFAULT_DIALOG_TAG,
353-
features: DialogRenderFeatures,
354-
visible: dialogState === DialogStates.Open,
355-
name: 'Dialog',
356-
})}
343+
<FocusTrap
344+
initialFocus={initialFocus}
345+
containers={containers}
346+
features={
347+
enabled
348+
? match(position, {
349+
parent: FocusTrap.features.RestoreFocus,
350+
leaf: FocusTrap.features.All & ~FocusTrap.features.FocusLock,
351+
})
352+
: FocusTrap.features.None
353+
}
354+
>
355+
{render({
356+
ourProps,
357+
theirProps,
358+
slot,
359+
defaultTag: DEFAULT_DIALOG_TAG,
360+
features: DialogRenderFeatures,
361+
visible: dialogState === DialogStates.Open,
362+
name: 'Dialog',
363+
})}
364+
</FocusTrap>
357365
</DescriptionProvider>
358366
</ForcePortalRoot>
359367
</Portal.Group>
360368
</DialogContext.Provider>
361369
</Portal>
362370
</ForcePortalRoot>
371+
<Hidden features={HiddenFeatures.Hidden} ref={mainTreeNode} />
363372
</StackProvider>
364373
)
365374
})

0 commit comments

Comments
 (0)