Skip to content

Commit 1e9a3f1

Browse files
authored
Allow Tab and Shift+Tab when Listbox is open (#3284)
* allow `Tab` and `Shift+Tab` in `Listbox` component This will make it consistent with the `Menu` and the ARIA Authoring Practices Guide. * update tests * update changelog
1 parent 6b6c259 commit 1e9a3f1

File tree

4 files changed

+58
-31
lines changed

4 files changed

+58
-31
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
2323
- Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
2424

25+
### Changed
26+
27+
- Allow using the `Tab` and `Shift+Tab` keys when the `Listbox` component is open ([#3284](https://github.com/tailwindlabs/headlessui/pull/3284))
28+
2529
## [2.0.4] - 2024-05-25
2630

2731
### Fixed

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

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,17 +1956,20 @@ describe('Keyboard interactions', () => {
19561956

19571957
describe('`Tab` key', () => {
19581958
it(
1959-
'should focus trap when we use Tab',
1959+
'should not focus trap when we use Tab',
19601960
suppressConsoleLogs(async () => {
19611961
render(
1962-
<Listbox value={undefined} onChange={(x) => console.log(x)}>
1963-
<Listbox.Button>Trigger</Listbox.Button>
1964-
<Listbox.Options>
1965-
<Listbox.Option value="a">Option A</Listbox.Option>
1966-
<Listbox.Option value="b">Option B</Listbox.Option>
1967-
<Listbox.Option value="c">Option C</Listbox.Option>
1968-
</Listbox.Options>
1969-
</Listbox>
1962+
<>
1963+
<Listbox value={undefined} onChange={(x) => console.log(x)}>
1964+
<Listbox.Button>Trigger</Listbox.Button>
1965+
<Listbox.Options>
1966+
<Listbox.Option value="a">Option A</Listbox.Option>
1967+
<Listbox.Option value="b">Option B</Listbox.Option>
1968+
<Listbox.Option value="c">Option C</Listbox.Option>
1969+
</Listbox.Options>
1970+
</Listbox>
1971+
<a href="#">After</a>
1972+
</>
19701973
)
19711974

19721975
assertListboxButton({
@@ -1996,28 +1999,31 @@ describe('Keyboard interactions', () => {
19961999
options.forEach((option) => assertListboxOption(option))
19972000
assertActiveListboxOption(options[0])
19982001

1999-
// Try to tab
2002+
// Tab to the next element
20002003
await press(Keys.Tab)
20012004

2002-
// Verify it is still open
2003-
assertListboxButton({ state: ListboxState.Visible })
2004-
assertListbox({ state: ListboxState.Visible })
2005-
assertActiveElement(getListbox())
2005+
// Verify the listbox is closed
2006+
assertListboxButton({ state: ListboxState.InvisibleUnmounted })
2007+
assertListbox({ state: ListboxState.InvisibleUnmounted })
2008+
assertActiveElement(getByText('After'))
20062009
})
20072010
)
20082011

20092012
it(
2010-
'should focus trap when we use Shift+Tab',
2013+
'should not focus trap when we use Shift+Tab',
20112014
suppressConsoleLogs(async () => {
20122015
render(
2013-
<Listbox value={undefined} onChange={(x) => console.log(x)}>
2014-
<Listbox.Button>Trigger</Listbox.Button>
2015-
<Listbox.Options>
2016-
<Listbox.Option value="a">Option A</Listbox.Option>
2017-
<Listbox.Option value="b">Option B</Listbox.Option>
2018-
<Listbox.Option value="c">Option C</Listbox.Option>
2019-
</Listbox.Options>
2020-
</Listbox>
2016+
<>
2017+
<a href="#">Before</a>
2018+
<Listbox value={undefined} onChange={(x) => console.log(x)}>
2019+
<Listbox.Button>Trigger</Listbox.Button>
2020+
<Listbox.Options>
2021+
<Listbox.Option value="a">Option A</Listbox.Option>
2022+
<Listbox.Option value="b">Option B</Listbox.Option>
2023+
<Listbox.Option value="c">Option C</Listbox.Option>
2024+
</Listbox.Options>
2025+
</Listbox>
2026+
</>
20212027
)
20222028

20232029
assertListboxButton({
@@ -2050,10 +2056,10 @@ describe('Keyboard interactions', () => {
20502056
// Try to Shift+Tab
20512057
await press(shift(Keys.Tab))
20522058

2053-
// Verify it is still open
2054-
assertListboxButton({ state: ListboxState.Visible })
2055-
assertListbox({ state: ListboxState.Visible })
2056-
assertActiveElement(getListbox())
2059+
// Verify the listbox is closed
2060+
assertListboxButton({ state: ListboxState.InvisibleUnmounted })
2061+
assertListbox({ state: ListboxState.InvisibleUnmounted })
2062+
assertActiveElement(getByText('Before'))
20572063
})
20582064
)
20592065
})

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ import type { EnsureArray, Props } from '../../types'
6060
import { isDisabledReactIssue7711 } from '../../utils/bugs'
6161
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
6262
import { disposables } from '../../utils/disposables'
63-
import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management'
63+
import {
64+
Focus as FocusManagementFocus,
65+
FocusableMode,
66+
focusFrom,
67+
isFocusableElement,
68+
sortByDomNode,
69+
} from '../../utils/focus-management'
6470
import { attemptSubmit } from '../../utils/form'
6571
import { match } from '../../utils/match'
6672
import { getOwnerDocument } from '../../utils/owner'
@@ -1064,6 +1070,11 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10641070
case Keys.Tab:
10651071
event.preventDefault()
10661072
event.stopPropagation()
1073+
flushSync(() => actions.closeListbox())
1074+
focusFrom(
1075+
data.buttonRef.current!,
1076+
event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next
1077+
)
10671078
break
10681079

10691080
default:
@@ -1093,7 +1104,10 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10931104
'aria-orientation': data.orientation,
10941105
onKeyDown: handleKeyDown,
10951106
role: 'listbox',
1096-
tabIndex: 0,
1107+
// When the `Listbox` is closed, it should not be focusable. This allows us
1108+
// to skip focusing the `ListboxOptions` when pressing the tab key on an
1109+
// open `Listbox`, and go to the next focusable element.
1110+
tabIndex: data.listboxState === ListboxStates.Open ? 0 : undefined,
10971111
style: {
10981112
...theirProps.style,
10991113
...style,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
762762
open: state.menuState === MenuStates.Open,
763763
...transitionData,
764764
} satisfies ItemsRenderPropArg
765-
}, [state, transitionData])
765+
}, [state.menuState, transitionData])
766766

767767
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
768768
'aria-activedescendant':
@@ -772,7 +772,10 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
772772
onKeyDown: handleKeyDown,
773773
onKeyUp: handleKeyUp,
774774
role: 'menu',
775-
tabIndex: 0,
775+
// When the `Menu` is closed, it should not be focusable. This allows us
776+
// to skip focusing the `MenuItems` when pressing the tab key on an
777+
// open `Menu`, and go to the next focusable element.
778+
tabIndex: state.menuState === MenuStates.Open ? 0 : undefined,
776779
ref: itemsRef,
777780
style: {
778781
...theirProps.style,

0 commit comments

Comments
 (0)