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:
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user