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
This commit is contained in:
Robin Malfait
2022-05-11 15:03:54 +02:00
committed by GitHub
parent c494fa36e9
commit bf0d1120d3
32 changed files with 848 additions and 584 deletions
+2
View File
@@ -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
@@ -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]) => (
<VisuallyHidden
<Hidden
features={HiddenFeatures.Hidden}
{...compact({
key: name,
as: 'input',
@@ -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<HTMLDivElement | null>(null)
let dialogRef = useSyncRefs(internalDialogRef, ref)
// Reference to a node in the "main" tree, not in the portalled Dialog tree.
let mainTreeNode = useRef<HTMLDivElement | null>(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<
<Portal.Group target={internalDialogRef}>
<ForcePortalRoot force={false}>
<DescriptionProvider slot={slot} name="Dialog.Description">
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_DIALOG_TAG,
features: DialogRenderFeatures,
visible: dialogState === DialogStates.Open,
name: 'Dialog',
})}
<FocusTrap
initialFocus={initialFocus}
containers={containers}
features={
enabled
? match(position, {
parent: FocusTrap.features.RestoreFocus,
leaf: FocusTrap.features.All & ~FocusTrap.features.FocusLock,
})
: FocusTrap.features.None
}
>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_DIALOG_TAG,
features: DialogRenderFeatures,
visible: dialogState === DialogStates.Open,
name: 'Dialog',
})}
</FocusTrap>
</DescriptionProvider>
</ForcePortalRoot>
</Portal.Group>
</DialogContext.Provider>
</Portal>
</ForcePortalRoot>
<Hidden features={HiddenFeatures.Hidden} ref={mainTreeNode} />
</StackProvider>
)
})
@@ -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<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> },
ref: Ref<HTMLElement>
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<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
props: Props<TTag> & {
initialFocus?: MutableRefObject<HTMLElement | null>
features?: Features
containers?: MutableRefObject<Set<MutableRefObject<HTMLElement | null>>>
},
ref: Ref<HTMLDivElement>
) {
let container = useRef<HTMLDivElement | null>(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) && (
<Hidden
as="button"
type="button"
onFocus={handleFocus}
features={HiddenFeatures.Focusable}
/>
)}
{render({
ourProps,
theirProps,
defaultTag: DEFAULT_FOCUS_TRAP_TAG,
name: 'FocusTrap',
})}
{Boolean(features & Features.TabLock) && (
<Hidden
as="button"
type="button"
onFocus={handleFocus}
features={HiddenFeatures.Focusable}
/>
)}
</>
)
}),
{ features: Features }
)
function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) {
let restoreElement = useRef<HTMLElement | null>(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<HTMLElement | null>
initialFocus?: MutableRefObject<HTMLElement | null>
},
enabled: boolean
) {
let container = useRef<HTMLElement | null>(null)
let focusTrapRef = useSyncRefs(container, ref)
let { initialFocus, ...theirProps } = props
let previousActiveElement = useRef<HTMLElement | null>(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 <FocusTrap />')
}
}
previousActiveElement.current = ownerDocument?.activeElement as HTMLElement
}, [enabled])
return previousActiveElement
}
function useFocusLock(
{
ownerDocument,
container,
containers,
previousActiveElement,
}: {
ownerDocument: Document | null
container: MutableRefObject<HTMLElement | null>
containers?: MutableRefObject<Set<MutableRefObject<HTMLElement | null>>>
previousActiveElement: MutableRefObject<HTMLElement | null>
},
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<MutableRefObject<HTMLElement | null>>, 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
}
@@ -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]) => (
<VisuallyHidden
<Hidden
features={HiddenFeatures.Hidden}
{...compact({
key: name,
as: 'input',
@@ -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>): 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])
@@ -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]) => (
<VisuallyHidden
<Hidden
features={HiddenFeatures.Hidden}
{...compact({
key: name,
as: 'input',
@@ -23,7 +23,7 @@ import { Label, useLabels } from '../label/label'
import { Description, useDescriptions } from '../description/description'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { VisuallyHidden } from '../../internal/visually-hidden'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { attemptSubmit } from '../../utils/form'
interface StateDefinition {
@@ -166,7 +166,8 @@ let SwitchRoot = forwardRefWithAs(function Switch<
return (
<>
{name != null && checked && (
<VisuallyHidden
<Hidden
features={HiddenFeatures.Hidden}
{...compact({
as: 'input',
type: 'checkbox',
@@ -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<typeof useId>
@@ -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<NestingContextValues | null>(null)
@@ -117,7 +118,7 @@ function useNesting(done?: () => void) {
let transitionableChildren = useRef<NestingContextValues['children']['current']>([])
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)
}
}),
})
@@ -0,0 +1,9 @@
import React from 'react'
export let useEvent =
// TODO: Add React.useEvent ?? once the useEvent hook is available
function useEvent<T, R>(cb: (...args: T[]) => R) {
let cache = React.useRef(cb)
cache.current = cb
return React.useCallback((...args: T[]) => cache.current(...args), [cache])
}
@@ -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<HTMLElement | null>,
features: Features = Features.All,
{
initialFocus,
containers,
}: {
initialFocus?: MutableRefObject<HTMLElement | null>
containers?: MutableRefObject<Set<MutableRefObject<HTMLElement | null>>>
} = {}
) {
let restoreElement = useRef<HTMLElement | null>(null)
let previousActiveElement = useRef<HTMLElement | null>(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 <FocusTrap />')
}
}
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<MutableRefObject<HTMLElement | null>>, element: HTMLElement) {
for (let container of containers) {
if (container.current?.contains(element)) return true
}
return false
}
@@ -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> | 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)
}
@@ -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
}
@@ -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]: () => {},
@@ -0,0 +1,18 @@
import { useEffect, useRef } from 'react'
import { useEvent } from './use-event'
export function useWatch<T>(cb: (values: T[]) => void | (() => void), dependencies: T[]) {
let track = useRef<typeof dependencies>([])
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])
}
@@ -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 (
<VisuallyHidden
<Hidden
as="button"
type="button"
features={Features.Focusable}
onFocus={(event: ReactFocusEvent) => {
event.preventDefault()
let frame: ReturnType<typeof requestAnimationFrame>
@@ -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<TTag> & { features?: Features }, ref: Ref<HTMLElement>) {
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',
})
})
@@ -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<TTag>, ref: Ref<HTMLElement>) {
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',
})
})
@@ -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',
@@ -46,7 +46,7 @@ afterAll(() => jest.restoreAllMocks())
let TabSentinel = defineComponent({
name: 'TabSentinel',
template: html` <div :tabindex="0"></div> `,
template: html`<div :tabindex="0"></div>`,
})
jest.mock('../../hooks/use-id')
@@ -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<Set<Ref<HTMLElement | null>>>(new Set())
let internalDialogRef = ref<HTMLDivElement | null>(null)
// Reference to a node in the "main" tree, not in the portalled Dialog tree.
let mainTreeNode = ref<HTMLDivElement | null>(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 }),
])
}
},
})
@@ -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<HTMLElement | null>, 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<HTMLElement | null>, default: null },
features: { type: Number as PropType<Features>, default: Features.All },
containers: {
type: Object as PropType<Ref<Set<Ref<HTMLElement | null>>>>,
default: ref(new Set()),
},
},
inheritAttrs: false,
setup(props, { attrs, slots, expose }) {
let container = ref<HTMLElement | null>(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<Document | null> },
enabled: Ref<boolean>
) {
let restoreElement = ref<HTMLElement | null>(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<Document | null>
container: Ref<HTMLElement | null>
initialFocus?: Ref<HTMLElement | null>
},
setup(props, { attrs, slots, expose }) {
let container = ref<HTMLElement | null>(null)
enabled: Ref<boolean>
) {
let previousActiveElement = ref<HTMLElement | null>(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 <FocusTrap />')
}
}
previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement
},
{ immediate: true, flush: 'post' }
)
})
return previousActiveElement
}
function useFocusLock(
{
ownerDocument,
container,
containers,
previousActiveElement,
}: {
ownerDocument: Ref<Document | null>
container: Ref<HTMLElement | null>
containers: Ref<Set<Ref<HTMLElement | null>>>
previousActiveElement: Ref<HTMLElement | null>
},
})
enabled: Ref<boolean>
) {
// 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<Ref<HTMLElement | null>>, element: HTMLElement) {
for (let container of containers) {
if (container.value?.contains(element)) return true
}
return false
}
@@ -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',
@@ -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',
@@ -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,
@@ -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<HTMLElement | null>,
features: Ref<Features> = ref(Features.All),
options: Ref<{
initialFocus?: Ref<HTMLElement | null>
containers?: Ref<Set<Ref<HTMLElement | null>>>
}> = ref({})
) {
let restoreElement = ref<HTMLElement | null>(null)
let previousActiveElement = ref<HTMLElement | null>(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 <FocusTrap />')
}
}
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<Ref<HTMLElement | null>>, element: HTMLElement) {
for (let container of containers) {
if (container.value?.contains(element)) return true
}
return false
}
@@ -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> | HTMLElement | null
type ContainerCollection = Container[] | Set<Container>
@@ -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
}
@@ -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<typeof requestAnimationFrame>
@@ -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<Features>, 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',
})
}
},
})
@@ -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',
})
}
},
})
@@ -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
})
)
}
}