Skip to content

Commit 0e1599b

Browse files
RobinMalfaiteriese
andauthored
Close Menu component when using tab key (#1673)
* menu should not trap focus for tab key * introduce `focusFrom` focus management utility This is internal API, and the actual API is not 100% ideal. I started refactoring this in a separate branch but it got out of hand and touches a bit more pieces of the codebase that aren't related to this PR at all. The idea of this function is just so that we can go Next/Previous but from the given element not from the document.activeElement. This is important for this feature. We also bolted this ontop of the existing code which now means that we have this API: ```js focusIn([], Focus.Previouw, true, DOMNode) ``` Luckily it's internal API only! * ensure closing via Tab works as expected Just closing the Menu isn't 100% enough. If we do this, it means that when the Menu is open, we press shift+tab, then we go to the Menu.Button because the Menu.Items were the active element. The other way is also incorrect because it can happen if you have an `<a>` element as one of the Menu.Item elements then that `<a>` will receive focus, then the `Menu` will close unmounting the focused `<a>` and now that element is gone resulting in `document.body` being the active element. To fix this, we will make sure that we consider the `Menu` as 1 coherent component. This means that using `<Tab>` will now go to the next element after the `<Menu.Button>` once the Menu is closed. Shift+Tab will go to the element before the `<Menu.Button>` even though you are currently focused on the `Menu.Items` so depending on the timing you go to the `Menu.Button` or not. Considering the Menu as a single component it makes more sense use the elements before / after the `Menu` * update changelog Co-authored-by: Enoch Riese <enoch.riese@gmail.com>
1 parent f1daa1e commit 0e1599b

File tree

8 files changed

+76
-30
lines changed

8 files changed

+76
-30
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Don’t close dialog when opened during mouse up event ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
1919
- Don’t close dialog when drag ends outside dialog ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
2020
- Fix outside clicks to close dialog when nested, unopened dialogs are present ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
21+
- Close `Menu` component when using `tab` key ([#1673](https://github.com/tailwindlabs/headlessui/pull/1673))
2122

2223
## [1.6.6] - 2022-07-07
2324

packages/@headlessui-react/src/components/menu/menu.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,7 +1359,7 @@ describe('Keyboard interactions', () => {
13591359

13601360
describe('`Tab` key', () => {
13611361
it(
1362-
'should focus trap when we use Tab',
1362+
'should close when we use Tab',
13631363
suppressConsoleLogs(async () => {
13641364
render(
13651365
<Menu>
@@ -1401,9 +1401,9 @@ describe('Keyboard interactions', () => {
14011401
// Try to tab
14021402
await press(Keys.Tab)
14031403

1404-
// Verify it is still open
1405-
assertMenuButton({ state: MenuState.Visible })
1406-
assertMenu({ state: MenuState.Visible })
1404+
// Verify it is closed
1405+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
1406+
assertMenu({ state: MenuState.InvisibleUnmounted })
14071407
})
14081408
)
14091409

@@ -1450,9 +1450,9 @@ describe('Keyboard interactions', () => {
14501450
// Try to Shift+Tab
14511451
await press(shift(Keys.Tab))
14521452

1453-
// Verify it is still open
1454-
assertMenuButton({ state: MenuState.Visible })
1455-
assertMenu({ state: MenuState.Visible })
1453+
// Verify it is closed
1454+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
1455+
assertMenu({ state: MenuState.InvisibleUnmounted })
14561456
})
14571457
)
14581458
})

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ import { useId } from '../../hooks/use-id'
2929
import { Keys } from '../keyboard'
3030
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
3131
import { isDisabledReactIssue7711 } from '../../utils/bugs'
32-
import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/focus-management'
32+
import {
33+
isFocusableElement,
34+
FocusableMode,
35+
sortByDomNode,
36+
Focus as FocusManagementFocus,
37+
focusFrom,
38+
} from '../../utils/focus-management'
3339
import { useOutsideClick } from '../../hooks/use-outside-click'
3440
import { useTreeWalker } from '../../hooks/use-tree-walker'
3541
import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed'
@@ -492,6 +498,13 @@ let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DE
492498
case Keys.Tab:
493499
event.preventDefault()
494500
event.stopPropagation()
501+
dispatch({ type: ActionTypes.CloseMenu })
502+
disposables().nextFrame(() => {
503+
focusFrom(
504+
state.buttonRef.current!,
505+
event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next
506+
)
507+
})
495508
break
496509

497510
default:

packages/@headlessui-react/src/utils/focus-management.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,16 @@ export function sortByDomNode<T>(
129129
})
130130
}
131131

132-
export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, sorted = true) {
132+
export function focusFrom(current: HTMLElement | null, focus: Focus) {
133+
return focusIn(getFocusableElements(), focus, true, current)
134+
}
135+
136+
export function focusIn(
137+
container: HTMLElement | HTMLElement[],
138+
focus: Focus,
139+
sorted = true,
140+
active: HTMLElement | null = null
141+
) {
133142
let ownerDocument = Array.isArray(container)
134143
? container.length > 0
135144
? container[0].ownerDocument
@@ -141,7 +150,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, so
141150
? sortByDomNode(container)
142151
: container
143152
: getFocusableElements(container)
144-
let active = ownerDocument.activeElement as HTMLElement
153+
active = active ?? (ownerDocument.activeElement as HTMLElement)
145154

146155
let direction = (() => {
147156
if (focus & (Focus.First | Focus.Next)) return Direction.Next

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Don’t close dialog when opened during mouse up event ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
1919
- Don’t close dialog when drag ends outside dialog ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
2020
- Fix outside clicks to close dialog when nested, unopened dialogs are present ([#1667](https://github.com/tailwindlabs/headlessui/pull/1667))
21+
- Close `Menu` component when using `tab` key ([#1673](https://github.com/tailwindlabs/headlessui/pull/1673))
2122

2223
## [1.6.7] - 2022-07-12
2324

packages/@headlessui-vue/src/components/menu/menu.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,7 +1656,7 @@ describe('Keyboard interactions', () => {
16561656
})
16571657

16581658
describe('`Tab` key', () => {
1659-
it('should focus trap when we use Tab', async () => {
1659+
it('should not focus trap when we use Tab', async () => {
16601660
renderTemplate(jsx`
16611661
<Menu>
16621662
<MenuButton>Trigger</MenuButton>
@@ -1697,12 +1697,12 @@ describe('Keyboard interactions', () => {
16971697
// Try to tab
16981698
await press(Keys.Tab)
16991699

1700-
// Verify it is still open
1701-
assertMenuButton({ state: MenuState.Visible })
1702-
assertMenu({ state: MenuState.Visible })
1700+
// Verify it is closed
1701+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
1702+
assertMenu({ state: MenuState.InvisibleUnmounted })
17031703
})
17041704

1705-
it('should focus trap when we use Shift+Tab', async () => {
1705+
it('should not focus trap when we use Shift+Tab', async () => {
17061706
renderTemplate(jsx`
17071707
<Menu>
17081708
<MenuButton>Trigger</MenuButton>
@@ -1743,9 +1743,9 @@ describe('Keyboard interactions', () => {
17431743
// Try to Shift+Tab
17441744
await press(shift(Keys.Tab))
17451745

1746-
// Verify it is still open
1747-
assertMenuButton({ state: MenuState.Visible })
1748-
assertMenu({ state: MenuState.Visible })
1746+
// Verify it is closed
1747+
assertMenuButton({ state: MenuState.InvisibleUnmounted })
1748+
assertMenu({ state: MenuState.InvisibleUnmounted })
17491749
})
17501750
})
17511751

packages/@headlessui-vue/src/components/menu/menu.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ import { useTreeWalker } from '../../hooks/use-tree-walker'
2222
import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
2323
import { match } from '../../utils/match'
2424
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
25-
import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management'
25+
import {
26+
FocusableMode,
27+
isFocusableElement,
28+
sortByDomNode,
29+
Focus as FocusManagementFocus,
30+
focusFrom,
31+
} from '../../utils/focus-management'
2632
import { useOutsideClick } from '../../hooks/use-outside-click'
2733

2834
enum MenuStates {
@@ -413,6 +419,13 @@ export let MenuItems = defineComponent({
413419
case Keys.Tab:
414420
event.preventDefault()
415421
event.stopPropagation()
422+
api.closeMenu()
423+
nextTick(() =>
424+
focusFrom(
425+
dom(api.buttonRef),
426+
event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next
427+
)
428+
)
416429
break
417430

418431
default:

packages/@headlessui-vue/src/utils/focus-management.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,16 @@ export function sortByDomNode<T>(
122122
})
123123
}
124124

125-
export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, sorted = true) {
125+
export function focusFrom(current: HTMLElement | null, focus: Focus) {
126+
return focusIn(getFocusableElements(), focus, true, current)
127+
}
128+
129+
export function focusIn(
130+
container: HTMLElement | HTMLElement[],
131+
focus: Focus,
132+
sorted = true,
133+
active: HTMLElement | null = null
134+
) {
126135
let ownerDocument =
127136
(Array.isArray(container)
128137
? container.length > 0
@@ -135,7 +144,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, so
135144
? sortByDomNode(container)
136145
: container
137146
: getFocusableElements(container)
138-
let active = ownerDocument.activeElement as HTMLElement
147+
active = active ?? (ownerDocument.activeElement as HTMLElement)
139148

140149
let direction = (() => {
141150
if (focus & (Focus.First | Focus.Next)) return Direction.Next
@@ -180,15 +189,6 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, so
180189
offset += direction
181190
} while (next !== ownerDocument.activeElement)
182191

183-
// This is a little weird, but let me try and explain: There are a few scenario's
184-
// in chrome for example where a focused `<a>` tag does not get the default focus
185-
// styles and sometimes they do. This highly depends on whether you started by
186-
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
187-
// then the active element (document.activeElement) is this anchor, which is expected.
188-
// However in that case the default focus styles are not applied *unless* you
189-
// also add this tabindex.
190-
if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0')
191-
192192
// By default if you <Tab> to a text input or a textarea, the browser will
193193
// select all the text once the focus is inside these DOM Nodes. However,
194194
// since we are manually moving focus this behaviour is not happening. This
@@ -201,5 +201,14 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus, so
201201
next.select()
202202
}
203203

204+
// This is a little weird, but let me try and explain: There are a few scenario's
205+
// in chrome for example where a focused `<a>` tag does not get the default focus
206+
// styles and sometimes they do. This highly depends on whether you started by
207+
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
208+
// then the active element (document.activeElement) is this anchor, which is expected.
209+
// However in that case the default focus styles are not applied *unless* you
210+
// also add this tabindex.
211+
if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0')
212+
204213
return FocusResult.Success
205214
}

0 commit comments

Comments
 (0)