diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 1da0b7a..b77c788 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173)) - Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153)) - Fix crash when reading `headlessuiFocusGuard` of `relatedTarget` in the `FocusTrap` component ([#2203](https://github.com/tailwindlabs/headlessui/pull/2203)) +- Fix `FocusTrap` in `Dialog` when there is only 1 focusable element ([#2172](https://github.com/tailwindlabs/headlessui/pull/2172)) ## [1.7.7] - 2022-12-16 diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index 1f3efc9..f632115 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -21,7 +21,7 @@ import { getDialogs, getDialogOverlays, } from '../../test-utils/accessibility-assertions' -import { click, mouseDrag, press, Keys } from '../../test-utils/interactions' +import { click, mouseDrag, press, Keys, shift } from '../../test-utils/interactions' import { PropsOf } from '../../types' import { Transition } from '../transitions/transition' import { createPortal } from 'react-dom' @@ -797,6 +797,106 @@ describe('Keyboard interactions', () => { assertActiveElement(document.getElementById('a')) }) ) + + it( + 'should not escape the FocusTrap when there is only 1 focusable element (going forwards)', + suppressConsoleLogs(async () => { + function Example() { + let [isOpen, setIsOpen] = useState(false) + return ( + <> + + + + + + + + + + ) + } + render() + + assertDialog({ state: DialogState.InvisibleUnmounted }) + + // Open dialog + await click(document.getElementById('trigger')) + + // Verify it is open + assertDialog({ + state: DialogState.Visible, + attributes: { id: 'headlessui-dialog-1' }, + }) + + // Verify that the input field is focused + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(Keys.Tab) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(Keys.Tab) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(Keys.Tab) + assertActiveElement(document.getElementById('a')) + }) + ) + + it( + 'should not escape the FocusTrap when there is only 1 focusable element (going backwards)', + suppressConsoleLogs(async () => { + function Example() { + let [isOpen, setIsOpen] = useState(false) + return ( + <> + + + + + + + + + + ) + } + render() + + assertDialog({ state: DialogState.InvisibleUnmounted }) + + // Open dialog + await click(document.getElementById('trigger')) + + // Verify it is open + assertDialog({ + state: DialogState.Visible, + attributes: { id: 'headlessui-dialog-1' }, + }) + + // Verify that the input field is focused + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('a')) + }) + ) }) }) diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx index 7f43d5c..054ed6d 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx @@ -108,6 +108,50 @@ it('should warn when there is no focusable element inside the FocusTrap', async spy.mockReset() }) +it( + 'should not be possible to programmatically escape the focus trap (if there is only 1 focusable element)', + suppressConsoleLogs(async () => { + function Example() { + return ( + <> + + + + + + + ) + } + + render() + + await nextFrame() + + let [a, b] = Array.from(document.querySelectorAll('input')) + + // Ensure that input-b is the active element + assertActiveElement(b) + + // Tab to the next item + await press(Keys.Tab) + + // Ensure that input-b is still the active element + assertActiveElement(b) + + // Try to move focus + a?.focus() + + // Ensure that input-b is still the active element + assertActiveElement(b) + + // Click on an element within the FocusTrap + await click(b) + + // Ensure that input-b is the active element + assertActiveElement(b) + }) +) + it( 'should not be possible to programmatically escape the focus trap', suppressConsoleLogs(async () => { @@ -214,6 +258,56 @@ it('should restore the previously focused element, before entering the FocusTrap assertActiveElement(document.getElementById('item-2')) }) +it('should stay in the FocusTrap when using `tab`, if there is only 1 focusable element', async () => { + render( + <> + + + + + + + ) + + await nextFrame() + + // Item A should be focused because the FocusTrap will focus the first item + assertActiveElement(document.getElementById('item-a')) + + // Next + await press(Keys.Tab) + assertActiveElement(document.getElementById('item-a')) + + // Next + await press(Keys.Tab) + assertActiveElement(document.getElementById('item-a')) +}) + +it('should stay in the FocusTrap when using `shift+tab`, if there is only 1 focusable element', async () => { + render( + <> + + + + + + + ) + + await nextFrame() + + // Item A should be focused because the FocusTrap will focus the first item + assertActiveElement(document.getElementById('item-a')) + + // Previous (loop around!) + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('item-a')) + + // Previous + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('item-a')) +}) + it('should be possible tab to the next focusable element within the focus trap', async () => { render( <> diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 606d267..df13b43 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -85,10 +85,12 @@ export let FocusTrap = Object.assign( let wrapper = process.env.NODE_ENV === 'test' ? microTask : (cb: Function) => cb() wrapper(() => { match(direction.current, { - [TabDirection.Forwards]: () => - focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }), - [TabDirection.Backwards]: () => - focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }), + [TabDirection.Forwards]: () => { + focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }) + }, + [TabDirection.Backwards]: () => { + focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }) + }, }) }) }) diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts index ab8971a..3301664 100644 --- a/packages/@headlessui-react/src/utils/focus-management.ts +++ b/packages/@headlessui-react/src/utils/focus-management.ts @@ -171,7 +171,7 @@ export function focusIn( : container : getFocusableElements(container) - if (skipElements.length > 0) { + if (skipElements.length > 0 && elements.length > 1) { elements = elements.filter((x) => !skipElements.includes(x)) } diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 35069d5..cf7fb2a 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173)) - Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153)) - Fix crash when reading `headlessuiFocusGuard` of `relatedTarget` in the `FocusTrap` component ([#2203](https://github.com/tailwindlabs/headlessui/pull/2203)) +- Fix `FocusTrap` in `Dialog` when there is only 1 focusable element ([#2172](https://github.com/tailwindlabs/headlessui/pull/2172)) ## [1.7.7] - 2022-12-16 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 9d4bac9..3a9f600 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -30,7 +30,7 @@ import { getDialogs, getDialogOverlays, } from '../../test-utils/accessibility-assertions' -import { click, mouseDrag, press, Keys } from '../../test-utils/interactions' +import { click, mouseDrag, press, Keys, shift } from '../../test-utils/interactions' import { html } from '../../test-utils/html' // @ts-expect-error @@ -1074,6 +1074,126 @@ describe('Keyboard interactions', () => { assertActiveElement(document.getElementById('a')) }) ) + + it( + 'should not escape the FocusTrap when there is only 1 focusable element (going forwards)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` +
+ + + + + + +
+ `, + setup() { + let isOpen = ref(false) + let initialFocusRef = ref(null) + return { + isOpen, + initialFocusRef, + setIsOpen(value: boolean) { + isOpen.value = value + }, + toggleOpen() { + isOpen.value = !isOpen.value + }, + } + }, + }) + + assertDialog({ state: DialogState.InvisibleUnmounted }) + + // Open dialog + await click(document.getElementById('trigger')) + + // Verify it is open + assertDialog({ + state: DialogState.Visible, + attributes: { id: 'headlessui-dialog-1' }, + }) + + // Verify that the input field is focused + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(Keys.Tab) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(Keys.Tab) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(Keys.Tab) + assertActiveElement(document.getElementById('a')) + }) + ) + + it( + 'should not escape the FocusTrap when there is only 1 focusable element (going backwards)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` +
+ + + + + + +
+ `, + setup() { + let isOpen = ref(false) + let initialFocusRef = ref(null) + return { + isOpen, + initialFocusRef, + setIsOpen(value: boolean) { + isOpen.value = value + }, + toggleOpen() { + isOpen.value = !isOpen.value + }, + } + }, + }) + + assertDialog({ state: DialogState.InvisibleUnmounted }) + + // Open dialog + await click(document.getElementById('trigger')) + + // Verify it is open + assertDialog({ + state: DialogState.Visible, + attributes: { id: 'headlessui-dialog-1' }, + }) + + // Verify that the input field is focused + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('a')) + + // Verify that we stay within the Dialog + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('a')) + }) + ) }) }) diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts index c7996c7..f3c4c58 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts @@ -227,6 +227,60 @@ it('should restore the previously focused element, before entering the FocusTrap assertActiveElement(document.getElementById('item-2')) }) +it('should stay in the FocusTrap when using `tab`, if there is only 1 focusable element', async () => { + renderTemplate({ + template: html` +
+ + + + + +
+ `, + }) + + await nextFrame() + + // Item A should be focused because the FocusTrap will focus the first item + assertActiveElement(document.getElementById('item-a')) + + // Next + await press(Keys.Tab) + assertActiveElement(document.getElementById('item-a')) + + // Next + await press(Keys.Tab) + assertActiveElement(document.getElementById('item-a')) +}) + +it('should stay in the FocusTrap when using `shift+tab`, if there is only 1 focusable element', async () => { + renderTemplate({ + template: html` +
+ + + + + +
+ `, + }) + + await nextFrame() + + // Item A should be focused because the FocusTrap will focus the first item + assertActiveElement(document.getElementById('item-a')) + + // Previous (loop around!) + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('item-a')) + + // Previous + await press(shift(Keys.Tab)) + assertActiveElement(document.getElementById('item-a')) +}) + it('should be possible to tab to the next focusable element within the focus trap', async () => { renderTemplate( html` diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts index 4cd3790..119ec91 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts @@ -89,10 +89,12 @@ export let FocusTrap = Object.assign( let wrapper = process.env.NODE_ENV === 'test' ? microTask : (cb: Function) => cb() wrapper(() => { match(direction.value, { - [TabDirection.Forwards]: () => - focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }), - [TabDirection.Backwards]: () => - focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }), + [TabDirection.Forwards]: () => { + focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }) + }, + [TabDirection.Backwards]: () => { + focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }) + }, }) }) } diff --git a/packages/@headlessui-vue/src/utils/focus-management.ts b/packages/@headlessui-vue/src/utils/focus-management.ts index 9ec2cd5..846f666 100644 --- a/packages/@headlessui-vue/src/utils/focus-management.ts +++ b/packages/@headlessui-vue/src/utils/focus-management.ts @@ -165,7 +165,7 @@ export function focusIn( : container : getFocusableElements(container) - if (skipElements.length > 0) { + if (skipElements.length > 0 && elements.length > 1) { elements = elements.filter((x) => !skipElements.includes(x)) }