Fix incorrect nested Dialogs behaviour (#489)
* add tests to verify the nested Dialog behaviour * set mounted to true once rendered once * cache useWindowEvent listener We only care about the very last version of the listener function. This allows us to only change the event listener if the event name (string) and options (boolean | object) change. * add/delete messages when mounting/unmounting We don't require a dedicated hook anymore, so this is a bit of cleanup! * add comments to the FocusResult enum * splitup functionality and make it a bit more clear using feature flags * add getDialogOverlays helper * simplify the Portal component We don't need to add the current element to the Stack. We only want to take care of that in the Dialog component itself. * drop dom-containers Currently it is only used in a single spot, so I inlined it into that file. * simplify the FocusTrap component, use new API * improve Dialog component * update CHANGELOG
This commit is contained in:
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Improve SSR for `Dialog` ([#477](https://github.com/tailwindlabs/headlessui/pull/477))
|
||||
- Delay focus trap initialization ([#477](https://github.com/tailwindlabs/headlessui/pull/477))
|
||||
- Improve incorrect behaviour for nesting `Dialog` components ([#560](https://github.com/tailwindlabs/headlessui/pull/560))
|
||||
|
||||
## [Unreleased - Vue]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
getDialogOverlays,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { PropsOf } from '../../types'
|
||||
@@ -637,79 +638,205 @@ describe('Mouse interactions', () => {
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
|
||||
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
|
||||
let [showChild, setShowChild] = useState(false)
|
||||
function Nested({ onClose, level = 1 }: { onClose: (value: boolean) => void; level?: number }) {
|
||||
let [showChild, setShowChild] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<div>
|
||||
<p>Level: {level}</p>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1}</button>
|
||||
</div>
|
||||
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<Dialog.Overlay />
|
||||
|
||||
<div>
|
||||
<p>Level: {level}</p>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} a</button>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} b</button>
|
||||
<button onClick={() => setShowChild(true)}>Open {level + 1} c</button>
|
||||
</div>
|
||||
{showChild && <Nested onClose={setShowChild} level={level + 1} />}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Example() {
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open 1</button>
|
||||
{open && <Nested onClose={setOpen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
it.each`
|
||||
strategy | action
|
||||
${'with `Escape`'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
`(
|
||||
'should be possible to open nested Dialog components and close them $strategy',
|
||||
async ({ action }) => {
|
||||
render(<Example />)
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 a` has focus
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Open Dialog 2 via the second button
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Open Dialog 2 via button b
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3 via button c
|
||||
await click(getByText('Open 3 c'))
|
||||
|
||||
// Verify that the `Open 4 a` has focus
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that the `Open 3 c` button got focused again
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 0 open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Verify that the `Open 1` button got focused again
|
||||
assertActiveElement(getByText('Open 1'))
|
||||
}
|
||||
|
||||
function Example() {
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Open 1</button>
|
||||
{open && <Nested onClose={setOpen} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Example />)
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3
|
||||
await click(getByText('Open 3'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import React, {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MutableRefObject,
|
||||
Ref,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { Props } from '../../types'
|
||||
@@ -24,16 +25,15 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
|
||||
import { Keys } from '../keyboard'
|
||||
import { isDisabledReactIssue7711 } from '../../utils/bugs'
|
||||
import { useId } from '../../hooks/use-id'
|
||||
import { useFocusTrap } from '../../hooks/use-focus-trap'
|
||||
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
|
||||
import { useInertOthers } from '../../hooks/use-inert-others'
|
||||
import { Portal } from '../../components/portal/portal'
|
||||
import { StackProvider, StackMessage } from '../../internal/stack-context'
|
||||
import { ForcePortalRoot } from '../../internal/portal-force-root'
|
||||
import { contains } from '../../internal/dom-containers'
|
||||
import { Description, useDescriptions } from '../description/description'
|
||||
import { useWindowEvent } from '../../hooks/use-window-event'
|
||||
import { useOpenClosed, State } from '../../internal/open-closed'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
import { StackProvider, StackMessage } from '../../internal/stack-context'
|
||||
|
||||
enum DialogStates {
|
||||
Open,
|
||||
@@ -117,6 +117,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
ref: Ref<HTMLDivElement>
|
||||
) {
|
||||
let { open, onClose, initialFocus, ...rest } = props
|
||||
let [nestedDialogCount, setNestedDialogCount] = useState(0)
|
||||
|
||||
let usesOpenClosedState = useOpenClosed()
|
||||
if (open === undefined && usesOpenClosedState !== null) {
|
||||
@@ -127,7 +128,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
})
|
||||
}
|
||||
|
||||
let containers = useRef<Set<HTMLElement>>(new Set())
|
||||
let containers = useRef<Set<MutableRefObject<HTMLElement | null>>>(new Set())
|
||||
let internalDialogRef = useRef<HTMLDivElement | null>(null)
|
||||
let dialogRef = useSyncRefs(internalDialogRef, ref)
|
||||
|
||||
@@ -184,13 +185,34 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
let ready = useServerHandoffComplete()
|
||||
let enabled = ready && dialogState === DialogStates.Open
|
||||
let hasNestedDialogs = nestedDialogCount > 1 // 1 is the current dialog
|
||||
let hasParentDialog = useContext(DialogContext) !== null
|
||||
|
||||
// If there are multiple dialogs, then you can be the root, the leaf or one
|
||||
// in between. We only care abou whether you are the top most one or not.
|
||||
let position = !hasNestedDialogs ? 'leaf' : 'parent'
|
||||
|
||||
useFocusTrap(
|
||||
internalDialogRef,
|
||||
enabled
|
||||
? match(position, {
|
||||
parent: FocusTrapFeatures.RestoreFocus,
|
||||
leaf: FocusTrapFeatures.All,
|
||||
})
|
||||
: FocusTrapFeatures.None,
|
||||
{ initialFocus, containers }
|
||||
)
|
||||
useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false)
|
||||
|
||||
// Handle outside click
|
||||
useWindowEvent('mousedown', event => {
|
||||
let target = event.target as HTMLElement
|
||||
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (containers.current.size !== 1) return
|
||||
if (contains(containers.current, target)) return
|
||||
if (hasNestedDialogs) return
|
||||
if (internalDialogRef.current?.contains(target)) return
|
||||
|
||||
close()
|
||||
})
|
||||
@@ -198,6 +220,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (hasParentDialog) return
|
||||
|
||||
let overflow = document.documentElement.style.overflow
|
||||
let paddingRight = document.documentElement.style.paddingRight
|
||||
@@ -211,7 +234,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
document.documentElement.style.overflow = overflow
|
||||
document.documentElement.style.paddingRight = paddingRight
|
||||
}
|
||||
}, [dialogState])
|
||||
}, [dialogState, hasParentDialog])
|
||||
|
||||
// Trigger close when the FocusTrap gets hidden
|
||||
useEffect(() => {
|
||||
@@ -236,11 +259,6 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
return () => observer.disconnect()
|
||||
}, [dialogState, internalDialogRef, close])
|
||||
|
||||
let ready = useServerHandoffComplete()
|
||||
let enabled = ready && dialogState === DialogStates.Open
|
||||
|
||||
useFocusTrap(containers, enabled, { initialFocus })
|
||||
useInertOthers(internalDialogRef, enabled)
|
||||
let [describedby, DescriptionProvider] = useDescriptions()
|
||||
|
||||
let id = `headlessui-dialog-${useId()}`
|
||||
@@ -269,7 +287,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
onKeyDown(event: ReactKeyboardEvent) {
|
||||
if (event.key !== Keys.Escape) return
|
||||
if (dialogState !== DialogStates.Open) return
|
||||
if (containers.current.size > 1) return // 1 is myself, otherwise other elements in the Stack
|
||||
if (hasNestedDialogs) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
@@ -279,16 +297,22 @@ let DialogRoot = forwardRefWithAs(function Dialog<
|
||||
|
||||
return (
|
||||
<StackProvider
|
||||
onUpdate={(message, element) => {
|
||||
return match(message, {
|
||||
[StackMessage.AddElement]() {
|
||||
type="Dialog"
|
||||
element={internalDialogRef}
|
||||
onUpdate={useCallback((message, type, element) => {
|
||||
if (type !== 'Dialog') return
|
||||
|
||||
match(message, {
|
||||
[StackMessage.Add]() {
|
||||
containers.current.add(element)
|
||||
setNestedDialogCount(count => count + 1)
|
||||
},
|
||||
[StackMessage.RemoveElement]() {
|
||||
containers.current.delete(element)
|
||||
[StackMessage.Remove]() {
|
||||
containers.current.add(element)
|
||||
setNestedDialogCount(count => count - 1)
|
||||
},
|
||||
})
|
||||
}}
|
||||
}, [])}
|
||||
>
|
||||
<ForcePortalRoot force={true}>
|
||||
<Portal>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
import { Props } from '../../types'
|
||||
import { render } from '../../utils/render'
|
||||
import { useFocusTrap } from '../../hooks/use-focus-trap'
|
||||
import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
|
||||
let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
|
||||
@@ -16,17 +16,14 @@ let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
|
||||
export function FocusTrap<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
|
||||
props: Props<TTag> & { initialFocus?: MutableRefObject<HTMLElement | null> }
|
||||
) {
|
||||
let containers = useRef<Set<HTMLElement>>(new Set())
|
||||
let container = useRef<HTMLElement | null>(null)
|
||||
let { initialFocus, ...passthroughProps } = props
|
||||
|
||||
let ready = useServerHandoffComplete()
|
||||
useFocusTrap(containers, ready, { initialFocus })
|
||||
useFocusTrap(container, ready ? FocusTrapFeatures.All : FocusTrapFeatures.None, { initialFocus })
|
||||
|
||||
let propsWeControl = {
|
||||
ref(element: HTMLElement | null) {
|
||||
if (!element) return
|
||||
containers.current.add(element)
|
||||
},
|
||||
ref: container,
|
||||
}
|
||||
|
||||
return render({
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createPortal } from 'react-dom'
|
||||
import { Props } from '../../types'
|
||||
import { render } from '../../utils/render'
|
||||
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
|
||||
import { useElementStack, StackProvider } from '../../internal/stack-context'
|
||||
import { usePortalRoot } from '../../internal/portal-force-root'
|
||||
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
|
||||
|
||||
@@ -60,8 +59,6 @@ export function Portal<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
|
||||
|
||||
let ready = useServerHandoffComplete()
|
||||
|
||||
useElementStack(element)
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
if (!target) return
|
||||
if (!element) return
|
||||
@@ -82,16 +79,12 @@ export function Portal<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
|
||||
|
||||
if (!ready) return null
|
||||
|
||||
return (
|
||||
<StackProvider>
|
||||
{!target || !element
|
||||
? null
|
||||
: createPortal(
|
||||
render({ props: passthroughProps, defaultTag: DEFAULT_PORTAL_TAG, name: 'Portal' }),
|
||||
element
|
||||
)}
|
||||
</StackProvider>
|
||||
)
|
||||
return !target || !element
|
||||
? null
|
||||
: createPortal(
|
||||
render({ props: passthroughProps, defaultTag: DEFAULT_PORTAL_TAG, name: 'Portal' }),
|
||||
element
|
||||
)
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
@@ -7,93 +7,124 @@ import {
|
||||
|
||||
import { Keys } from '../components/keyboard'
|
||||
import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management'
|
||||
import { contains } from '../internal/dom-containers'
|
||||
import { useWindowEvent } from './use-window-event'
|
||||
import { useIsMounted } from './use-is-mounted'
|
||||
|
||||
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(
|
||||
containers: MutableRefObject<Set<HTMLElement>>,
|
||||
enabled: boolean = true,
|
||||
options: { initialFocus?: MutableRefObject<HTMLElement | null> } = {}
|
||||
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>(
|
||||
typeof window !== 'undefined' ? (document.activeElement as HTMLElement) : null
|
||||
)
|
||||
let previousActiveElement = useRef<HTMLElement | null>(null)
|
||||
let mounted = useRef(false)
|
||||
let mounted = useIsMounted()
|
||||
|
||||
let featuresRestoreFocus = Boolean(features & Features.RestoreFocus)
|
||||
let featuresInitialFocus = Boolean(features & Features.InitialFocus)
|
||||
|
||||
// Capture the currently focused element, before we enable the focus trap.
|
||||
useEffect(() => {
|
||||
if (!featuresRestoreFocus) return
|
||||
|
||||
restoreElement.current = document.activeElement as HTMLElement
|
||||
}, [featuresRestoreFocus])
|
||||
|
||||
// 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 (!enabled) return
|
||||
if (containers.current.size !== 1) return
|
||||
|
||||
mounted.current = true
|
||||
if (!featuresInitialFocus) return
|
||||
if (!container.current) return
|
||||
|
||||
let activeElement = document.activeElement as HTMLElement
|
||||
|
||||
if (options.initialFocus?.current) {
|
||||
if (options.initialFocus?.current === activeElement) {
|
||||
if (initialFocus?.current) {
|
||||
if (initialFocus?.current === activeElement) {
|
||||
previousActiveElement.current = activeElement
|
||||
return // Initial focus ref is already the active element
|
||||
}
|
||||
} else if (contains(containers.current, activeElement)) {
|
||||
} else if (container.current.contains(activeElement)) {
|
||||
previousActiveElement.current = activeElement
|
||||
return // Already focused within Dialog
|
||||
}
|
||||
|
||||
restoreElement.current = activeElement
|
||||
|
||||
// Try to focus the initialFocus ref
|
||||
if (options.initialFocus?.current) {
|
||||
focusElement(options.initialFocus.current)
|
||||
if (initialFocus?.current) {
|
||||
focusElement(initialFocus.current)
|
||||
} else {
|
||||
let couldFocus = false
|
||||
for (let container of containers.current) {
|
||||
let result = focusIn(container, Focus.First)
|
||||
if (result === FocusResult.Success) {
|
||||
couldFocus = true
|
||||
break
|
||||
}
|
||||
if (focusIn(container.current, Focus.First) === FocusResult.Error) {
|
||||
throw new Error('There are no focusable elements inside the <FocusTrap />')
|
||||
}
|
||||
|
||||
if (!couldFocus) throw new Error('There are no focusable elements inside the <FocusTrap />')
|
||||
}
|
||||
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
}, [container, initialFocus, featuresInitialFocus])
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
focusElement(restoreElement.current)
|
||||
restoreElement.current = null
|
||||
previousActiveElement.current = null
|
||||
}
|
||||
}, [enabled, containers, mounted, options.initialFocus])
|
||||
|
||||
// Handle Tab & Shift+Tab keyboard events
|
||||
// Handle `Tab` & `Shift+Tab` keyboard events
|
||||
useWindowEvent('keydown', event => {
|
||||
if (!enabled) return
|
||||
if (!(features & Features.TabLock)) return
|
||||
|
||||
if (!container.current) return
|
||||
if (event.key !== Keys.Tab) return
|
||||
if (!document.activeElement) return
|
||||
if (containers.current.size !== 1) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
for (let element of containers.current) {
|
||||
let result = focusIn(
|
||||
element,
|
||||
if (
|
||||
focusIn(
|
||||
container.current,
|
||||
(event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround
|
||||
)
|
||||
|
||||
if (result === FocusResult.Success) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
break
|
||||
}
|
||||
) === FocusResult.Success
|
||||
) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent programmatically escaping
|
||||
// Prevent programmatically escaping the container
|
||||
useWindowEvent(
|
||||
'focus',
|
||||
event => {
|
||||
if (!enabled) return
|
||||
if (containers.current.size !== 1) return
|
||||
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
|
||||
@@ -102,7 +133,7 @@ export function useFocusTrap(
|
||||
let toElement = event.target as HTMLElement | null
|
||||
|
||||
if (toElement && toElement instanceof HTMLElement) {
|
||||
if (!contains(containers.current, toElement)) {
|
||||
if (!contains(allContainers, toElement)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
focusElement(previous)
|
||||
@@ -117,3 +148,11 @@ export function useFocusTrap(
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
function contains(containers: Set<MutableRefObject<HTMLElement | null>>, element: HTMLElement) {
|
||||
for (let container of containers) {
|
||||
if (container.current?.contains(element)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
export function useIsMounted() {
|
||||
let mounted = useRef(true)
|
||||
let mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function useWindowEvent<TType extends keyof WindowEventMap>(
|
||||
type: TType,
|
||||
listener: (this: Window, ev: WindowEventMap[TType]) => any,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) {
|
||||
let listenerRef = useRef(listener)
|
||||
listenerRef.current = listener
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(type, listener, options)
|
||||
return () => window.removeEventListener(type, listener, options)
|
||||
}, [type, listener, options])
|
||||
function handler(event: WindowEventMap[TType]) {
|
||||
listenerRef.current.call(window, event)
|
||||
}
|
||||
|
||||
window.addEventListener(type, handler, options)
|
||||
return () => window.removeEventListener(type, handler, options)
|
||||
}, [type, options])
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function contains(containers: Set<HTMLElement>, element: HTMLElement) {
|
||||
for (let container of containers) {
|
||||
if (container.contains(element)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,37 +1,42 @@
|
||||
import React, { ReactNode, createContext, useContext, useCallback } from 'react'
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
|
||||
// Types
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { useIsoMorphicEffect } from '../hooks/use-iso-morphic-effect'
|
||||
|
||||
type OnUpdate = (message: StackMessage, element: HTMLElement) => void
|
||||
type OnUpdate = (
|
||||
message: StackMessage,
|
||||
type: string,
|
||||
element: MutableRefObject<HTMLElement | null>
|
||||
) => void
|
||||
|
||||
let StackContext = createContext<OnUpdate>(() => {})
|
||||
StackContext.displayName = 'StackContext'
|
||||
|
||||
export enum StackMessage {
|
||||
AddElement,
|
||||
RemoveElement,
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
export function useStackContext() {
|
||||
return useContext(StackContext)
|
||||
}
|
||||
|
||||
export function useElementStack(element: HTMLElement | null) {
|
||||
let notify = useStackContext()
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
if (!element) return
|
||||
|
||||
notify(StackMessage.AddElement, element)
|
||||
return () => notify(StackMessage.RemoveElement, element)
|
||||
}, [element])
|
||||
}
|
||||
|
||||
export function StackProvider({
|
||||
children,
|
||||
onUpdate,
|
||||
type,
|
||||
element,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onUpdate?: OnUpdate
|
||||
type: string
|
||||
element: MutableRefObject<HTMLElement | null>
|
||||
}) {
|
||||
let parentUpdate = useStackContext()
|
||||
|
||||
@@ -46,5 +51,10 @@ export function StackProvider({
|
||||
[parentUpdate, onUpdate]
|
||||
)
|
||||
|
||||
useIsoMorphicEffect(() => {
|
||||
notify(StackMessage.Add, type, element)
|
||||
return () => notify(StackMessage.Remove, type, element)
|
||||
}, [notify, type, element])
|
||||
|
||||
return <StackContext.Provider value={notify}>{children}</StackContext.Provider>
|
||||
}
|
||||
|
||||
@@ -880,6 +880,10 @@ export function getDialogOverlay(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
||||
}
|
||||
|
||||
export function getDialogOverlays(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]'))
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
export enum DialogState {
|
||||
|
||||
@@ -44,9 +44,16 @@ export enum Focus {
|
||||
}
|
||||
|
||||
export enum FocusResult {
|
||||
/** Something went wrong while trying to focus. */
|
||||
Error,
|
||||
|
||||
/** When `Focus.WrapAround` is enabled, going from position `N` to `N+1` where `N` is the last index in the array, then we overflow. */
|
||||
Overflow,
|
||||
|
||||
/** Focus was successful. */
|
||||
Success,
|
||||
|
||||
/** When `Focus.WrapAround` is enabled, going from position `N` to `N-1` where `N` is the first index in the array, then we underflow. */
|
||||
Underflow,
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
getDialogOverlays,
|
||||
} from '../../test-utils/accessibility-assertions'
|
||||
import { click, press, Keys } from '../../test-utils/interactions'
|
||||
import { html } from '../../test-utils/html'
|
||||
@@ -781,102 +782,243 @@ describe('Mouse interactions', () => {
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
it('should be possible to open nested Dialog components and close them with `Escape`', async () => {
|
||||
let Nested = defineComponent({
|
||||
components: { Dialog },
|
||||
emits: ['close'],
|
||||
props: ['level'],
|
||||
render() {
|
||||
let level = this.$props.level ?? 1
|
||||
return h(Dialog, { open: true, onClose: this.onClose }, () => [
|
||||
h('div', [
|
||||
h('p', `Level: ${level}`),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
let Nested = defineComponent({
|
||||
components: { Dialog, DialogOverlay },
|
||||
emits: ['close'],
|
||||
props: ['level'],
|
||||
render() {
|
||||
let level = this.$props.level ?? 1
|
||||
return h(Dialog, { open: true, onClose: this.onClose }, () => [
|
||||
h(DialogOverlay),
|
||||
h('div', [
|
||||
h('p', `Level: ${level}`),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
`Open ${level + 1}`
|
||||
),
|
||||
]),
|
||||
this.showChild &&
|
||||
h(Nested, {
|
||||
onClose: () => {
|
||||
this.showChild = false
|
||||
},
|
||||
`Open ${level + 1} a`
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
level: level + 1,
|
||||
}),
|
||||
])
|
||||
},
|
||||
setup(_props, { emit }) {
|
||||
let showChild = ref(false)
|
||||
},
|
||||
`Open ${level + 1} b`
|
||||
),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
onClick: () => {
|
||||
this.showChild = true
|
||||
},
|
||||
},
|
||||
`Open ${level + 1} c`
|
||||
),
|
||||
]),
|
||||
this.showChild &&
|
||||
h(Nested, {
|
||||
onClose: () => {
|
||||
this.showChild = false
|
||||
},
|
||||
level: level + 1,
|
||||
}),
|
||||
])
|
||||
},
|
||||
setup(_props, { emit }) {
|
||||
let showChild = ref(false)
|
||||
|
||||
return {
|
||||
showChild,
|
||||
onClose() {
|
||||
emit('close', false)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
renderTemplate({
|
||||
components: { Nested },
|
||||
template: `
|
||||
<button @click="isOpen = true">Open 1</button>
|
||||
<Nested v-if="isOpen" @close="isOpen = false" />
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return { isOpen }
|
||||
},
|
||||
})
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Open Dialog 2
|
||||
await click(getByText('Open 2'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3
|
||||
await click(getByText('Open 3'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Press escape to close the top most Dialog
|
||||
await press(Keys.Escape)
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
return {
|
||||
showChild,
|
||||
onClose() {
|
||||
emit('close', false)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
it.each`
|
||||
strategy | action
|
||||
${'with `Escape`'} | ${() => press(Keys.Escape)}
|
||||
${'with `Outside Click`'} | ${() => click(document.body)}
|
||||
${'with `Click on Dialog.Overlay`'} | ${() => click(getDialogOverlays().pop()!)}
|
||||
`(
|
||||
'should be possible to open nested Dialog components and close them $strategy',
|
||||
async ({ action }) => {
|
||||
renderTemplate({
|
||||
components: { Nested },
|
||||
template: `
|
||||
<button @click="isOpen = true">Open 1</button>
|
||||
<Nested v-if="isOpen" @close="isOpen = false" />
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return { isOpen }
|
||||
},
|
||||
})
|
||||
|
||||
// Verify we have no open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Open Dialog 1
|
||||
await click(getByText('Open 1'))
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 a` has focus
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Open Dialog 2 via the second button
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Open Dialog 2 via button b
|
||||
await click(getByText('Open 2 b'))
|
||||
|
||||
// Verify that the `Open 3 a` has focus
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Open Dialog 3 via button c
|
||||
await click(getByText('Open 3 c'))
|
||||
|
||||
// Verify that the `Open 4 a` has focus
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 4 a'))
|
||||
|
||||
// Verify that we have 3 open dialogs
|
||||
expect(getDialogs()).toHaveLength(3)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that the `Open 3 c` button got focused again
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 3 c'))
|
||||
|
||||
// Verify that we have 2 open dialogs
|
||||
expect(getDialogs()).toHaveLength(2)
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 1 open dialog
|
||||
expect(getDialogs()).toHaveLength(1)
|
||||
|
||||
// Verify that the `Open 2 b` button got focused again
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 c'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 a'))
|
||||
|
||||
// Verify that we can tab around
|
||||
await press(Keys.Tab)
|
||||
assertActiveElement(getByText('Open 2 b'))
|
||||
|
||||
// Close the top most Dialog
|
||||
await action()
|
||||
|
||||
// Verify that we have 0 open dialogs
|
||||
expect(getDialogs()).toHaveLength(0)
|
||||
|
||||
// Verify that the `Open 1` button got focused again
|
||||
assertActiveElement(getByText('Open 1'))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -880,6 +880,10 @@ export function getDialogOverlay(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
||||
}
|
||||
|
||||
export function getDialogOverlays(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]'))
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
export enum DialogState {
|
||||
|
||||
Reference in New Issue
Block a user