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 (
-
- setEnabled((v) => !v)}>toggle
-
- )
- }
-
- 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 (
-
- setEnabled((v) => !v)}>{toggle}
-
- )
- }
-
- 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 (
-
-
- setEnabled((v) => !v)}>{toggle}
-
-
- )
- }
-
- 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 (
-
- setEnabled((v) => !v)}>{toggle}
-
- )
- }
-
- 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 (
+
+ setEnabled((v) => !v)}>toggle
+
+ )
+ }
+
+ 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 (
+
+ setEnabled((v) => !v)}>toggle
+
+ )
+ }
+
+ 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 (
+
+ setEnabled((v) => !v)}>{children}
+
+ )
+ }
+
+ 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`
-
- toggle
-
- `,
- 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`
-
- {{toggle}}
-
- `,
- 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`
+
+ toggle
+
+ `,
+ 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`
+
+ toggle
+
+ `,
+ 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 {