diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 178fe892b8..6ce4c25dbc 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Ensure the main tree and parent `Dialog` components are marked as `inert` ([#2290](https://github.com/tailwindlabs/headlessui/pull/2290)) ## [1.7.11] - 2023-02-15 diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 35b73b6e90..a33b490dde 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -2,6 +2,7 @@ import React, { createContext, createRef, + useCallback, useContext, useEffect, useMemo, @@ -25,7 +26,6 @@ import { Keys } from '../keyboard' import { isDisabledReactIssue7711 } from '../../utils/bugs' import { useId } from '../../hooks/use-id' 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' import { Description, useDescriptions } from '../description/description' @@ -38,6 +38,7 @@ import { useEventListener } from '../../hooks/use-event-listener' import { Hidden, Features as HiddenFeatures } from '../../internal/hidden' import { useEvent } from '../../hooks/use-event' import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow' +import { useInert } from '../../hooks/use-inert' enum DialogStates { Open, @@ -216,11 +217,33 @@ let DialogRoot = forwardRefWithAs(function Dialog< // Ensure other elements can't be interacted with let inertOthersEnabled = (() => { - if (!hasNestedDialogs) return false + // Nested dialogs should not modify the `inert` property, only the root one should. + if (hasParentDialog) return false if (isClosing) return false return enabled })() - useInertOthers(internalDialogRef, inertOthersEnabled) + let resolveRootOfMainTreeNode = useCallback(() => { + return (Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).find((root) => { + // Skip the portal root, we don't want to make that one inert + if (root.id === 'headlessui-portal-root') return false + + // Find the root of the main tree node + return root.contains(mainTreeNode.current) && root instanceof HTMLElement + }) ?? null) as HTMLElement | null + }, [mainTreeNode]) + useInert(resolveRootOfMainTreeNode, inertOthersEnabled) + + // This would mark the parent dialogs as inert + let inertParentDialogs = (() => { + if (hasNestedDialogs) return true + return enabled + })() + let resolveRootOfParentDialog = useCallback(() => { + return (Array.from(ownerDocument?.querySelectorAll('[data-headlessui-portal]') ?? []).find( + (root) => root.contains(mainTreeNode.current) && root instanceof HTMLElement + ) ?? null) as HTMLElement | null + }, [mainTreeNode]) + useInert(resolveRootOfParentDialog, inertParentDialogs) let resolveContainers = useEvent(() => { // Third party roots diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx b/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx deleted file mode 100644 index f03c3c957a..0000000000 --- a/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import React, { useRef, useState } from 'react' -import { render } from '@testing-library/react' -import { useInertOthers } from './use-inert-others' -import { getByText } from '../test-utils/accessibility-assertions' -import { click } from '../test-utils/interactions' - -beforeEach(() => { - jest.restoreAllMocks() - jest.spyOn(global.console, 'error').mockImplementation(jest.fn()) -}) - -it('should be possible to inert other elements', async () => { - function Example() { - let ref = useRef(null) - let [enabled, setEnabled] = useState(true) - useInertOthers(ref, enabled) - - return ( -
- -
- ) - } - - function Before() { - return
before
- } - - function After() { - return
after
- } - - render( - <> - - - - , - { container: document.body } - ) - - // Verify the others are hidden - expect(document.getElementById('main')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Restore - await click(getByText('toggle')) - - // Verify we are un-hidden - expect(document.getElementById('main')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) - -it('should restore inert elements, when all useInertOthers calls are disabled', async () => { - function Example({ toggle, id }: { toggle: string; id: string }) { - let ref = useRef(null) - let [enabled, setEnabled] = useState(false) - useInertOthers(ref, enabled) - - return ( -
- -
- ) - } - - function Before() { - return
before
- } - - function After() { - return
after
- } - - render( - <> - - - - - , - { container: document.body } - ) - - // Verify nothing is hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') - - // Enable inert on others (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Enable inert on others (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove first level of inert (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove second level of inert (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are not hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) - -it('should restore inert elements, when all useInertOthers calls are disabled (including parents)', async () => { - function Example({ toggle, id }: { toggle: string; id: string }) { - let ref = useRef(null) - let [enabled, setEnabled] = useState(false) - useInertOthers(ref, enabled) - - return ( -
-
- -
-
- ) - } - - function Before() { - return
before
- } - - function After() { - return
after
- } - - render( - <> - - - - - , - { container: document.body } - ) - - // Verify nothing is hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') - - // Enable inert on others (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden', 'true') - expect(document.getElementById('parent-main2')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Enable inert on others (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove first level of inert (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove second level of inert (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are not hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) - -it('should handle inert others correctly when 2 useInertOthers are used in a shared parent', async () => { - function Example({ toggle, id }: { toggle: string; id: string }) { - let ref = useRef(null) - let [enabled, setEnabled] = useState(false) - useInertOthers(ref, enabled) - - return ( -
- -
- ) - } - - function Before() { - return
before
- } - - function After() { - return
after
- } - - render( - <> - -
- - -
- - , - { container: document.body } - ) - - // Verify nothing is hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') - - // Enable inert on others (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden', 'true') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Enable inert on others (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove first level of inert (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove second level of inert (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are not hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.ts b/packages/@headlessui-react/src/hooks/use-inert-others.ts deleted file mode 100644 index c598ec0dea..0000000000 --- a/packages/@headlessui-react/src/hooks/use-inert-others.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { MutableRefObject } from 'react' -import { getOwnerDocument } from '../utils/owner' -import { useIsoMorphicEffect } from './use-iso-morphic-effect' - -let interactables = new Set() -let originals = new Map() - -function inert(element: HTMLElement) { - element.setAttribute('aria-hidden', 'true') - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - element.inert = true -} - -function restore(element: HTMLElement) { - let original = originals.get(element) - if (!original) return - - if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') - else element.setAttribute('aria-hidden', original['aria-hidden']) - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - element.inert = original.inert -} - -export function useInertOthers( - container: MutableRefObject, - enabled: boolean = true -) { - useIsoMorphicEffect(() => { - if (!enabled) return - if (!container.current) return - - let element = container.current - let ownerDocument = getOwnerDocument(element) - if (!ownerDocument) return - - // Mark myself as an interactable element - interactables.add(element) - - // Restore elements that now contain an interactable child - for (let original of originals.keys()) { - if (original.contains(element)) { - restore(original) - originals.delete(original) - } - } - - // Collect direct children of the body - ownerDocument.querySelectorAll('body > *').forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } - - // Keep track of the elements - if (interactables.size === 1) { - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, - }) - - // Mutate the element - inert(child) - } - }) - - return () => { - // Inert is disabled on the current element - interactables.delete(element) - - // We still have interactable elements, therefore this one and its parent - // will become inert as well. - if (interactables.size > 0) { - // Collect direct children of the body - ownerDocument!.querySelectorAll('body > *').forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip already inert parents - if (originals.has(child)) return - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } - - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, - }) - - // Mutate the element - inert(child) - }) - } else { - for (let element of originals.keys()) { - // Restore - restore(element) - - // Cleanup - originals.delete(element) - } - } - } - }, [enabled]) -} diff --git a/packages/@headlessui-react/src/hooks/use-inert.test.tsx b/packages/@headlessui-react/src/hooks/use-inert.test.tsx new file mode 100644 index 0000000000..ba1a8698e7 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-inert.test.tsx @@ -0,0 +1,141 @@ +import React, { ReactNode, useRef, useState } from 'react' +import { render } from '@testing-library/react' +import { useInert } from './use-inert' +import { getByText, assertInert, assertNotInert } from '../test-utils/accessibility-assertions' +import { click } from '../test-utils/interactions' + +beforeEach(() => { + jest.restoreAllMocks() + jest.spyOn(global.console, 'error').mockImplementation(jest.fn()) +}) + +it('should be possible to inert an element', async () => { + function Example() { + let ref = useRef(null) + let [enabled, setEnabled] = useState(true) + useInert(ref, enabled) + + return ( +
+ +
+ ) + } + + function Before() { + return
before
+ } + + function After() { + return
after
+ } + + render( + <> + + + + , + { container: document.body } + ) + + // Verify that `main` is inert + assertInert(document.getElementById('main')) + + // Verify that the others are not inert + assertNotInert(getByText('before')) + assertNotInert(getByText('after')) + + // Restore + await click(getByText('toggle')) + + // Verify that nothing is inert + assertNotInert(document.getElementById('main')) + assertNotInert(getByText('before')) + assertNotInert(getByText('after')) +}) + +it('should not mark an element as inert when the hook is disabled', async () => { + function Example() { + let ref = useRef(null) + let [enabled, setEnabled] = useState(false) + useInert(ref, enabled) + + return ( +
+ +
+ ) + } + + function Before() { + return
before
+ } + + function After() { + return
after
+ } + + render( + <> + + + + , + { container: document.body } + ) + + assertNotInert(document.getElementById('main')) + assertNotInert(getByText('before')) + assertNotInert(getByText('after')) +}) + +it('should mark the element as not inert anymore, once all references are gone', async () => { + function Example({ children }: { children: ReactNode }) { + let ref = useRef(null) + + let [enabled, setEnabled] = useState(false) + useInert(() => ref.current?.parentElement ?? null, enabled) + + return ( +
+ +
+ ) + } + + render( +
+ A + B +
, + { container: document.body } + ) + + // Parent should not be inert yet + assertNotInert(document.getElementById('parent')) + + // Toggle A + await click(getByText('A')) + + // Parent should be inert + assertInert(document.getElementById('parent')) + + // Toggle B + await click(getByText('B')) + + // Parent should still be inert + assertInert(document.getElementById('parent')) + + // Toggle A + await click(getByText('A')) + + // Parent should still be inert (because B is still enabled) + assertInert(document.getElementById('parent')) + + // Toggle B + await click(getByText('B')) + + // Parent should not be inert because both A and B are disabled + assertNotInert(document.getElementById('parent')) +}) diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx new file mode 100644 index 0000000000..e3e10c210b --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-inert.tsx @@ -0,0 +1,59 @@ +import { MutableRefObject } from 'react' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' + +let originals = new Map() +let counts = new Map() + +export function useInert( + node: MutableRefObject | (() => TElement | null), + enabled = true +) { + useIsoMorphicEffect(() => { + if (!enabled) return + + let element = typeof node === 'function' ? node() : node.current + if (!element) return + + function cleanup() { + if (!element) return // Should never happen + + // Decrease counts + let count = counts.get(element) ?? 1 // Should always exist + if (count === 1) counts.delete(element) // We are the last one, so we can delete the count + else counts.set(element, count - 1) // We are not the last one + + // Not the last one, so we don't restore the original values (yet) + if (count !== 1) return + + let original = originals.get(element) + if (!original) return // Should never happen + + // Restore original values + if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') + else element.setAttribute('aria-hidden', original['aria-hidden']) + element.inert = original.inert + + // Remove tracking of original values + originals.delete(element) + } + + // Increase count + let count = counts.get(element) ?? 0 + counts.set(element, count + 1) + + // Already marked as inert, no need to do it again + if (count !== 0) return cleanup + + // Keep track of previous values, so that we can restore them when we are done + originals.set(element, { + 'aria-hidden': element.getAttribute('aria-hidden'), + inert: element.inert, + }) + + // Mark as inert + element.setAttribute('aria-hidden', 'true') + element.inert = true + + return cleanup + }, [node, enabled]) +} diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index 50a4f9c467..a1797fd60d 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -1817,6 +1817,31 @@ export function assertNotFocusable(element: HTMLElement | null) { } } +export function assertInert(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(element).toHaveAttribute('aria-hidden', 'true') + expect(element).toHaveProperty('inert', true) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertInert) + throw err + } +} + +export function assertNotInert(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + // NOTE: We can't test that the element doesn't have `aria-hidden`, because this can still be + // the case even if they are not inert. + expect(element.inert).toBeUndefined() + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNotInert) + throw err + } +} + // --- export function getByText(text: string): HTMLElement | null { diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index f8554efeb7..5deee342b3 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Ensure the main tree and parent `Dialog` components are marked as `inert` ([#2290](https://github.com/tailwindlabs/headlessui/pull/2290)) ## [1.7.10] - 2023-02-15 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index c42317bf25..f3865966e2 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -21,7 +21,7 @@ import { render, Features } from '../../utils/render' import { Keys } from '../../keyboard' import { useId } from '../../hooks/use-id' import { FocusTrap } from '../../components/focus-trap/focus-trap' -import { useInertOthers } from '../../hooks/use-inert-others' +import { useInert } from '../../hooks/use-inert' import { Portal, PortalGroup } from '../portal/portal' import { StackMessage, useStackProvider } from '../../internal/stack-context' import { match } from '../../utils/match' @@ -145,11 +145,34 @@ export let Dialog = defineComponent({ // Ensure other elements can't be interacted with let inertOthersEnabled = computed(() => { - if (!hasNestedDialogs.value) return false + // Nested dialogs should not modify the `inert` property, only the root one should. + if (hasParentDialog) return false if (isClosing.value) return false return enabled.value }) - useInertOthers(internalDialogRef, inertOthersEnabled) + let resolveRootOfMainTreeNode = computed(() => { + return (Array.from(ownerDocument.value?.querySelectorAll('body > *') ?? []).find((root) => { + // Skip the portal root, we don't want to make that one inert + if (root.id === 'headlessui-portal-root') return false + + // Find the root of the main tree node + return root.contains(dom(mainTreeNode)) && root instanceof HTMLElement + }) ?? null) as HTMLElement | null + }) + useInert(resolveRootOfMainTreeNode, inertOthersEnabled) + + // This would mark the parent dialogs as inert + let inertParentDialogs = computed(() => { + if (hasNestedDialogs.value) return true + return enabled.value + }) + let resolveRootOfParentDialog = computed(() => { + return (Array.from( + ownerDocument.value?.querySelectorAll('[data-headlessui-portal]') ?? [] + ).find((root) => root.contains(dom(mainTreeNode)) && root instanceof HTMLElement) ?? + null) as HTMLElement | null + }) + useInert(resolveRootOfParentDialog, inertParentDialogs) useStackProvider({ type: 'Dialog', diff --git a/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts b/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts deleted file mode 100644 index 3b82253a55..0000000000 --- a/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { defineComponent, ref, nextTick, ComponentOptionsWithoutProps } from 'vue' - -import { render } from '../test-utils/vue-testing-library' -import { useInertOthers } from './use-inert-others' -import { getByText } from '../test-utils/accessibility-assertions' -import { click } from '../test-utils/interactions' -import { html } from '../test-utils/html' - -beforeAll(() => { - jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) - jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) - jest.spyOn(global.console, 'error').mockImplementation(jest.fn()) -}) - -afterAll(() => jest.restoreAllMocks()) - -function renderTemplate(input: string | ComponentOptionsWithoutProps) { - let defaultComponents = {} - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents }), { - attachTo: document.body, - }) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ), - { attachTo: document.body } - ) -} - -let Before = defineComponent({ - name: 'Before', - template: html`
before
`, -}) - -let After = defineComponent({ - name: 'After', - template: html`
after
`, -}) - -it('should be possible to inert other elements', async () => { - renderTemplate({ - template: html` - - - - `, - components: { - Before, - After, - Example: defineComponent({ - name: 'Example', - template: html` -
- -
- `, - setup() { - let container = ref(null) - let enabled = ref(true) - - useInertOthers(container, enabled) - - return { container, enabled } - }, - }), - }, - }) - - await new Promise(nextTick) - - // Verify the others are hidden - expect(document.getElementById('main')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Restore - await click(getByText('toggle')) - - // Verify we are un-hidden - expect(document.getElementById('main')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) - -it('should restore inert elements, when all useInertOthers calls are disabled', async () => { - renderTemplate({ - template: html` - - - - - `, - - components: { - Before, - After, - Example: defineComponent({ - name: 'Example', - props: { - toggle: { type: String }, - id: { type: String }, - }, - template: html` -
- -
- `, - setup() { - let container = ref(null) - let enabled = ref(false) - - useInertOthers(container, enabled) - - return { container, enabled } - }, - }), - }, - }) - - // Verify nothing is hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') - - // Enable inert on others (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Enable inert on others (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove first level of inert (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove second level of inert (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are not hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) - -it('should restore inert elements, when all useInertOthers calls are disabled (including parents)', async () => { - renderTemplate({ - template: html` - - - - - `, - components: { - Before, - After, - Example: defineComponent({ - name: 'Example', - props: { - toggle: { type: String }, - id: { type: String }, - }, - template: html` -
-
- -
-
- `, - setup() { - let container = ref(null) - let enabled = ref(false) - - useInertOthers(container, enabled) - - return { container, enabled } - }, - }), - }, - }) - - // Verify nothing is hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') - - // Enable inert on others (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden', 'true') - expect(document.getElementById('parent-main2')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Enable inert on others (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove first level of inert (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove second level of inert (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are not hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent-main2')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) - -it('should handle inert others correctly when 2 useInertOthers are used in a shared parent', async () => { - renderTemplate({ - template: html` - -
- - -
- - `, - components: { - Before, - After, - Example: defineComponent({ - name: 'Example', - props: { - toggle: { type: String }, - id: { type: String }, - }, - template: html` -
-
- -
-
- `, - setup() { - let container = ref(null) - let enabled = ref(false) - - useInertOthers(container, enabled) - - return { container, enabled } - }, - }), - }, - }) - - // Verify nothing is hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') - - // Enable inert on others (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden', 'true') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Enable inert on others (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove first level of inert (via toggle 1) - await click(getByText('toggle 1')) - - // Verify the others are hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).toHaveAttribute('aria-hidden', 'true') - expect(getByText('after')).toHaveAttribute('aria-hidden', 'true') - - // Remove second level of inert (via toggle 2) - await click(getByText('toggle 2')) - - // Verify the others are not hidden - expect(document.getElementById('main1')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('main2')).not.toHaveAttribute('aria-hidden') - expect(document.getElementById('parent')).not.toHaveAttribute('aria-hidden') - expect(getByText('before')).not.toHaveAttribute('aria-hidden') - expect(getByText('after')).not.toHaveAttribute('aria-hidden') -}) diff --git a/packages/@headlessui-vue/src/hooks/use-inert-others.ts b/packages/@headlessui-vue/src/hooks/use-inert-others.ts deleted file mode 100644 index c7c2989ff1..0000000000 --- a/packages/@headlessui-vue/src/hooks/use-inert-others.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - ref, - watchEffect, - - // Types - Ref, -} from 'vue' -import { getOwnerDocument } from '../utils/owner' - -// TODO: Figure out a nice way to attachTo document.body in the tests without automagically inserting a div with data-v-app -let CHILDREN_SELECTOR = process.env.NODE_ENV === 'test' ? '[data-v-app=""] > *' : 'body > *' - -let interactables = new Set() -let originals = new Map() - -function inert(element: HTMLElement) { - element.setAttribute('aria-hidden', 'true') - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - element.inert = true -} - -function restore(element: HTMLElement) { - let original = originals.get(element) - if (!original) return - - if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') - else element.setAttribute('aria-hidden', original['aria-hidden']) - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - element.inert = original.inert -} - -export function useInertOthers( - container: Ref, - enabled: Ref = ref(true) -) { - watchEffect((onInvalidate) => { - if (!enabled.value) return - if (!container.value) return - - let element = container.value - let ownerDocument = getOwnerDocument(element) - if (!ownerDocument) return - - // Mark myself as an interactable element - interactables.add(element) - - // Restore elements that now contain an interactable child - for (let original of originals.keys()) { - if (original.contains(element)) { - restore(original) - originals.delete(original) - } - } - - // Collect direct children of the body - ownerDocument.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } - - // Keep track of the elements - if (interactables.size === 1) { - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, - }) - - // Mutate the element - inert(child) - } - }) - - onInvalidate(() => { - // Inert is disabled on the current element - interactables.delete(element) - - // We still have interactable elements, therefore this one and its parent - // will become inert as well. - if (interactables.size > 0) { - // Collect direct children of the body - ownerDocument!.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip already inert parents - if (originals.has(child)) return - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } - - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, - }) - - // Mutate the element - inert(child) - }) - } else { - for (let element of originals.keys()) { - // Restore - restore(element) - - // Cleanup - originals.delete(element) - } - } - }) - }) -} diff --git a/packages/@headlessui-vue/src/hooks/use-inert.test.ts b/packages/@headlessui-vue/src/hooks/use-inert.test.ts new file mode 100644 index 0000000000..13c7aebaef --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-inert.test.ts @@ -0,0 +1,189 @@ +import { defineComponent, ref, nextTick, computed, ComponentOptionsWithoutProps } from 'vue' + +import { render } from '../test-utils/vue-testing-library' +import { useInert } from './use-inert' +import { getByText, assertInert, assertNotInert } from '../test-utils/accessibility-assertions' +import { click } from '../test-utils/interactions' +import { html } from '../test-utils/html' +import { dom } from '../utils/dom' + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) + jest.spyOn(global.console, 'error').mockImplementation(jest.fn()) +}) + +afterAll(() => jest.restoreAllMocks()) + +function renderTemplate(input: string | ComponentOptionsWithoutProps) { + let defaultComponents = {} + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents }), { + attachTo: document.body, + }) + } + + return render( + defineComponent( + Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as Parameters[0] + ), + { attachTo: document.body } + ) +} + +let Before = defineComponent({ + name: 'Before', + template: html`
before
`, +}) + +let After = defineComponent({ + name: 'After', + template: html`
after
`, +}) + +it('should be possible to inert an element', async () => { + renderTemplate({ + template: html` + + + + `, + components: { + Before, + After, + Example: defineComponent({ + name: 'Example', + template: html` +
+ +
+ `, + setup() { + let container = ref(null) + let enabled = ref(true) + + useInert(container, enabled) + + return { container, enabled } + }, + }), + }, + }) + + await new Promise(nextTick) + + // Verify that `main` is inert + assertInert(document.getElementById('main')) + + // Verify that the others are not inert + assertNotInert(getByText('before')) + assertNotInert(getByText('after')) + + // Restore + await click(getByText('toggle')) + + // Verify that nothing is inert + assertNotInert(document.getElementById('main')) + assertNotInert(getByText('before')) + assertNotInert(getByText('after')) +}) + +it('should be possible to inert an element', async () => { + renderTemplate({ + template: html` + + + + `, + components: { + Before, + After, + Example: defineComponent({ + name: 'Example', + template: html` +
+ +
+ `, + setup() { + let container = ref(null) + let enabled = ref(false) + + useInert(container, enabled) + + return { container, enabled } + }, + }), + }, + }) + + await new Promise(nextTick) + + assertNotInert(document.getElementById('main')) + assertNotInert(getByText('before')) + assertNotInert(getByText('after')) +}) + +it('should mark the element as not inert anymore, once all references are gone', async () => { + renderTemplate({ + template: html` +
+ A + B +
+ `, + components: { + Example: defineComponent({ + name: 'Example', + template: html` +
+ +
+ `, + setup() { + let container = ref(null) + let enabled = ref(false) + + let resolveParentContainer = computed(() => dom(container)?.parentElement ?? null) + useInert(resolveParentContainer, enabled) + + return { container, enabled } + }, + }), + }, + }) + + await new Promise(nextTick) + + // Parent should not be inert yet + assertNotInert(document.getElementById('parent')) + + // Toggle A + await click(getByText('A')) + + // Parent should be inert + assertInert(document.getElementById('parent')) + + // Toggle B + await click(getByText('B')) + + // Parent should still be inert + assertInert(document.getElementById('parent')) + + // Toggle A + await click(getByText('A')) + + // Parent should still be inert (because B is still enabled) + assertInert(document.getElementById('parent')) + + // Toggle B + await click(getByText('B')) + + // Parent should not be inert because both A and B are disabled + assertNotInert(document.getElementById('parent')) +}) diff --git a/packages/@headlessui-vue/src/hooks/use-inert.ts b/packages/@headlessui-vue/src/hooks/use-inert.ts new file mode 100644 index 0000000000..7ad23bcf16 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-inert.ts @@ -0,0 +1,63 @@ +import { + ref, + watchEffect, + + // Types + Ref, +} from 'vue' +import { dom } from '../utils/dom' + +let originals = new Map() +let counts = new Map() + +export function useInert( + node: Ref, + enabled: Ref = ref(true) +) { + watchEffect((onInvalidate) => { + if (!enabled.value) return + + let element = dom(node) + if (!element) return + + onInvalidate(function cleanup() { + if (!element) return // Should never happen + + // Decrease counts + let count = counts.get(element) ?? 1 // Should always exist + if (count === 1) counts.delete(element) // We are the last one, so we can delete the count + else counts.set(element, count - 1) // We are not the last one + + // Not the last one, so we don't restore the original values (yet) + if (count !== 1) return + + let original = originals.get(element) + if (!original) return // Should never happen + + // Restore original values + if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') + else element.setAttribute('aria-hidden', original['aria-hidden']) + element.inert = original.inert + + // Remove tracking of original values + originals.delete(element) + }) + + // Increase count + let count = counts.get(element) ?? 0 + counts.set(element, count + 1) + + // Already marked as inert, no need to do it again + if (count !== 0) return + + // Keep track of previous values, so that we can restore them when we are done + originals.set(element, { + 'aria-hidden': element.getAttribute('aria-hidden'), + inert: element.inert, + }) + + // Mark as inert + element.setAttribute('aria-hidden', 'true') + element.inert = true + }) +} diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index 50a4f9c467..a1797fd60d 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -1817,6 +1817,31 @@ export function assertNotFocusable(element: HTMLElement | null) { } } +export function assertInert(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + expect(element).toHaveAttribute('aria-hidden', 'true') + expect(element).toHaveProperty('inert', true) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertInert) + throw err + } +} + +export function assertNotInert(element: HTMLElement | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + // NOTE: We can't test that the element doesn't have `aria-hidden`, because this can still be + // the case even if they are not inert. + expect(element.inert).toBeUndefined() + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNotInert) + throw err + } +} + // --- export function getByText(text: string): HTMLElement | null {