Skip to content

Commit 419ffda

Browse files
authored
Ensure that there is always an active option in the Combobox (#1279)
* ensure that the first option is always active This will ensure that the first non-disabled option is the active one if no other active options exist. This means that any time you search for something that the first result is the active one and you can just press <kbd>Enter</kbd> to activate the option. However, there are a few rules that we have to take into account: - If you just open the Combobox, and there is a `selected` Combobox.Option, then we can't make the first option the active one. The first selected Combobox.Option has precedence over this one. This is important and rather tricky because Combobox.Option's register themselves at some point (later) in time. - If you already have an active option, then that option should stay active. If it changes position, then the activeOptionIndex is adjusted to account for that. - If you "mouse leave" an option, then no option should be active. It will be re-enabled the moment you start typing OR if you re-open the Combobox. Otherwise, it can happen that you are at the bottom of the list, mouse leave, and we scroll all the way back up to make the first item the active one which is not good for UX reasons. * filter list based on query in the playground * update changelog
1 parent 6d8235e commit 419ffda

File tree

8 files changed

+266
-160
lines changed

8 files changed

+266
-160
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- Properly merge incoming props ([#1265](https://github.com/tailwindlabs/headlessui/pull/1265))
3434
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))
3535
- Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272))
36+
- Ensure that there is always an active option in the `Combobox` ([#1279](https://github.com/tailwindlabs/headlessui/pull/1279))
3637

3738
### Added
3839

@@ -68,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6869
- Fix incorrect closing while interacting with third party libraries in `Dialog` component ([#1268](https://github.com/tailwindlabs/headlessui/pull/1268))
6970
- Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272))
7071
- Resolve `initialFocusRef` correctly ([#1276](https://github.com/tailwindlabs/headlessui/pull/1276))
72+
- Ensure that there is always an active option in the `Combobox` ([#1279](https://github.com/tailwindlabs/headlessui/pull/1279))
7173

7274
### Added
7375

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

Lines changed: 61 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
getComboboxes,
3939
assertCombobox,
4040
ComboboxMode,
41+
assertNotActiveComboboxOption,
4142
} from '../../test-utils/accessibility-assertions'
4243
import { Transition } from '../transitions/transition'
4344

@@ -579,7 +580,7 @@ describe('Rendering', () => {
579580
})
580581
assertComboboxList({
581582
state: ComboboxState.Visible,
582-
textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
583+
textContent: JSON.stringify({ active: true, selected: false, disabled: false }),
583584
})
584585
})
585586
)
@@ -667,16 +668,13 @@ describe('Rendering composition', () => {
667668

668669
// Verify correct classNames
669670
expect('' + options[0].classList).toEqual(
670-
JSON.stringify({ active: false, selected: false, disabled: false })
671+
JSON.stringify({ active: true, selected: false, disabled: false })
671672
)
672673
expect('' + options[1].classList).toEqual(
673674
JSON.stringify({ active: false, selected: false, disabled: true })
674675
)
675676
expect('' + options[2].classList).toEqual('no-special-treatment')
676677

677-
// Double check that nothing is active
678-
assertNoActiveComboboxOption(getComboboxInput())
679-
680678
// Make the first option active
681679
await press(Keys.ArrowDown)
682680

@@ -841,7 +839,7 @@ describe('Composition', () => {
841839
})
842840
assertComboboxList({
843841
state: ComboboxState.Visible,
844-
textContent: JSON.stringify({ active: false, selected: false, disabled: false }),
842+
textContent: JSON.stringify({ active: true, selected: false, disabled: false }),
845843
})
846844

847845
await click(getComboboxButton())
@@ -905,7 +903,7 @@ describe('Keyboard interactions', () => {
905903
expect(options).toHaveLength(3)
906904
options.forEach((option) => assertComboboxOption(option, { selected: false }))
907905

908-
assertNoActiveComboboxOption()
906+
assertActiveComboboxOption(options[0])
909907
assertNoSelectedComboboxOption()
910908
})
911909
)
@@ -1191,7 +1189,7 @@ describe('Keyboard interactions', () => {
11911189
let options = getComboboxOptions()
11921190
expect(options).toHaveLength(3)
11931191
options.forEach((option) => assertComboboxOption(option))
1194-
assertNoActiveComboboxOption()
1192+
assertActiveComboboxOption(options[0])
11951193
})
11961194
)
11971195

@@ -1434,7 +1432,7 @@ describe('Keyboard interactions', () => {
14341432
options.forEach((option) => assertComboboxOption(option))
14351433

14361434
// Verify that the first combobox option is active
1437-
assertNoActiveComboboxOption()
1435+
assertActiveComboboxOption(options[0])
14381436
})
14391437
)
14401438

@@ -2133,7 +2131,7 @@ describe('Keyboard interactions', () => {
21332131
options.forEach((option) => assertComboboxOption(option))
21342132

21352133
// Verify that the first combobox option is active
2136-
assertNoActiveComboboxOption()
2134+
assertActiveComboboxOption(options[0])
21372135
})
21382136
)
21392137

@@ -2272,7 +2270,7 @@ describe('Keyboard interactions', () => {
22722270
let options = getComboboxOptions()
22732271
expect(options).toHaveLength(3)
22742272
options.forEach((option) => assertComboboxOption(option))
2275-
assertNoActiveComboboxOption()
2273+
assertActiveComboboxOption(options[0])
22762274

22772275
// We should be able to go down once
22782276
await press(Keys.ArrowDown)
@@ -2322,7 +2320,7 @@ describe('Keyboard interactions', () => {
23222320
let options = getComboboxOptions()
23232321
expect(options).toHaveLength(3)
23242322
options.forEach((option) => assertComboboxOption(option))
2325-
assertNoActiveComboboxOption()
2323+
assertActiveComboboxOption(options[1])
23262324

23272325
// We should be able to go down once
23282326
await press(Keys.ArrowDown)
@@ -2362,7 +2360,7 @@ describe('Keyboard interactions', () => {
23622360
let options = getComboboxOptions()
23632361
expect(options).toHaveLength(3)
23642362
options.forEach((option) => assertComboboxOption(option))
2365-
assertNoActiveComboboxOption()
2363+
assertActiveComboboxOption(options[2])
23662364

23672365
// Open combobox
23682366
await press(Keys.ArrowDown)
@@ -2596,7 +2594,7 @@ describe('Keyboard interactions', () => {
25962594
let options = getComboboxOptions()
25972595
expect(options).toHaveLength(3)
25982596
options.forEach((option) => assertComboboxOption(option))
2599-
assertNoActiveComboboxOption()
2597+
assertActiveComboboxOption(options[2])
26002598

26012599
// Going up or down should select the single available option
26022600
await press(Keys.ArrowUp)
@@ -2689,8 +2687,8 @@ describe('Keyboard interactions', () => {
26892687

26902688
let options = getComboboxOptions()
26912689

2692-
// We should have no option selected
2693-
assertNoActiveComboboxOption()
2690+
// We should be on the first non-disabled option
2691+
assertActiveComboboxOption(options[0])
26942692

26952693
// We should be able to go to the last option
26962694
await press(Keys.End)
@@ -2723,8 +2721,8 @@ describe('Keyboard interactions', () => {
27232721

27242722
let options = getComboboxOptions()
27252723

2726-
// We should have no option selected
2727-
assertNoActiveComboboxOption()
2724+
// We should be on the first non-disabled option
2725+
assertActiveComboboxOption(options[0])
27282726

27292727
// We should be able to go to the last non-disabled option
27302728
await press(Keys.End)
@@ -2757,13 +2755,14 @@ describe('Keyboard interactions', () => {
27572755
// Open combobox
27582756
await click(getComboboxButton())
27592757

2760-
// We opened via click, we don't have an active option
2761-
assertNoActiveComboboxOption()
2758+
let options = getComboboxOptions()
27622759

2763-
// We should not be able to go to the end
2760+
// We should be on the first non-disabled option
2761+
assertActiveComboboxOption(options[0])
2762+
2763+
// We should not be able to go to the end (no-op)
27642764
await press(Keys.End)
27652765

2766-
let options = getComboboxOptions()
27672766
assertActiveComboboxOption(options[0])
27682767
})
27692768
)
@@ -2828,7 +2827,7 @@ describe('Keyboard interactions', () => {
28282827
let options = getComboboxOptions()
28292828

28302829
// We should be on the first option
2831-
assertNoActiveComboboxOption()
2830+
assertActiveComboboxOption(options[0])
28322831

28332832
// We should be able to go to the last option
28342833
await press(Keys.PageDown)
@@ -2864,8 +2863,8 @@ describe('Keyboard interactions', () => {
28642863

28652864
let options = getComboboxOptions()
28662865

2867-
// We should have nothing active
2868-
assertNoActiveComboboxOption()
2866+
// We should be on the first non-disabled option
2867+
assertActiveComboboxOption(options[0])
28692868

28702869
// We should be able to go to the last non-disabled option
28712870
await press(Keys.PageDown)
@@ -2898,13 +2897,14 @@ describe('Keyboard interactions', () => {
28982897
// Open combobox
28992898
await click(getComboboxButton())
29002899

2901-
// We opened via click, we don't have an active option
2902-
assertNoActiveComboboxOption()
2900+
let options = getComboboxOptions()
2901+
2902+
// We should be on the first non-disabled option
2903+
assertActiveComboboxOption(options[0])
29032904

29042905
// We should not be able to go to the end
29052906
await press(Keys.PageDown)
29062907

2907-
let options = getComboboxOptions()
29082908
assertActiveComboboxOption(options[0])
29092909
})
29102910
)
@@ -3003,14 +3003,14 @@ describe('Keyboard interactions', () => {
30033003
// Open combobox
30043004
await click(getComboboxButton())
30053005

3006-
// We opened via click, we don't have an active option
3007-
assertNoActiveComboboxOption()
3006+
let options = getComboboxOptions()
3007+
3008+
// We should be on the first non-disabled option
3009+
assertActiveComboboxOption(options[2])
30083010

30093011
// We should not be able to go to the end
30103012
await press(Keys.Home)
30113013

3012-
let options = getComboboxOptions()
3013-
30143014
// We should be on the first non-disabled option
30153015
assertActiveComboboxOption(options[2])
30163016
})
@@ -3041,13 +3041,14 @@ describe('Keyboard interactions', () => {
30413041
// Open combobox
30423042
await click(getComboboxButton())
30433043

3044-
// We opened via click, we don't have an active option
3045-
assertNoActiveComboboxOption()
3044+
let options = getComboboxOptions()
3045+
3046+
// We should be on the last option
3047+
assertActiveComboboxOption(options[3])
30463048

30473049
// We should not be able to go to the end
30483050
await press(Keys.Home)
30493051

3050-
let options = getComboboxOptions()
30513052
assertActiveComboboxOption(options[3])
30523053
})
30533054
)
@@ -3146,15 +3147,14 @@ describe('Keyboard interactions', () => {
31463147
// Open combobox
31473148
await click(getComboboxButton())
31483149

3149-
// We opened via click, we don't have an active option
3150-
assertNoActiveComboboxOption()
3150+
let options = getComboboxOptions()
31513151

3152-
// We should not be able to go to the end
3153-
await press(Keys.PageUp)
3152+
// We opened via click, we default to the first non-disabled option
3153+
assertActiveComboboxOption(options[2])
31543154

3155-
let options = getComboboxOptions()
3155+
// We should not be able to go to the end (no-op — already there)
3156+
await press(Keys.PageUp)
31563157

3157-
// We should be on the first non-disabled option
31583158
assertActiveComboboxOption(options[2])
31593159
})
31603160
)
@@ -3184,13 +3184,14 @@ describe('Keyboard interactions', () => {
31843184
// Open combobox
31853185
await click(getComboboxButton())
31863186

3187-
// We opened via click, we don't have an active option
3188-
assertNoActiveComboboxOption()
3187+
let options = getComboboxOptions()
31893188

3190-
// We should not be able to go to the end
3189+
// We opened via click, we default to the first non-disabled option
3190+
assertActiveComboboxOption(options[3])
3191+
3192+
// We should not be able to go to the end (no-op — already there)
31913193
await press(Keys.PageUp)
31923194

3193-
let options = getComboboxOptions()
31943195
assertActiveComboboxOption(options[3])
31953196
})
31963197
)
@@ -4019,7 +4020,7 @@ describe('Mouse interactions', () => {
40194020
let options = getComboboxOptions()
40204021

40214022
await mouseMove(options[1])
4022-
assertNoActiveComboboxOption()
4023+
assertNotActiveComboboxOption(options[1])
40234024
})
40244025
)
40254026

@@ -4048,8 +4049,8 @@ describe('Mouse interactions', () => {
40484049
// Try to hover over option 1, which is disabled
40494050
await mouseMove(options[1])
40504051

4051-
// We should not have an active option now
4052-
assertNoActiveComboboxOption()
4052+
// We should not have option 1 as the active option now
4053+
assertNotActiveComboboxOption(options[1])
40534054
})
40544055
)
40554056

@@ -4120,10 +4121,10 @@ describe('Mouse interactions', () => {
41204121

41214122
// Try to hover over option 1, which is disabled
41224123
await mouseMove(options[1])
4123-
assertNoActiveComboboxOption()
4124+
assertNotActiveComboboxOption(options[1])
41244125

41254126
await mouseLeave(options[1])
4126-
assertNoActiveComboboxOption()
4127+
assertNotActiveComboboxOption(options[1])
41274128
})
41284129
)
41294130

@@ -4216,9 +4217,10 @@ describe('Mouse interactions', () => {
42164217

42174218
let options = getComboboxOptions()
42184219

4219-
// We should be able to click the first option
4220+
// We should not be able to click the disabled option
42204221
await click(options[1])
42214222
assertComboboxList({ state: ComboboxState.Visible })
4223+
assertNotActiveComboboxOption(options[1])
42224224
assertActiveElement(getComboboxInput())
42234225
expect(handleChange).toHaveBeenCalledTimes(0)
42244226

@@ -4228,8 +4230,10 @@ describe('Mouse interactions', () => {
42284230
// Open combobox again
42294231
await click(getComboboxButton())
42304232

4231-
// Verify the active option is non existing
4232-
assertNoActiveComboboxOption()
4233+
options = getComboboxOptions()
4234+
4235+
// Verify the active option is not the disabled one
4236+
assertNotActiveComboboxOption(options[1])
42334237
})
42344238
)
42354239

@@ -4261,10 +4265,10 @@ describe('Mouse interactions', () => {
42614265

42624266
let options = getComboboxOptions()
42634267

4264-
// Verify that nothing is active yet
4265-
assertNoActiveComboboxOption()
4268+
// Verify that the first item is active
4269+
assertActiveComboboxOption(options[0])
42664270

4267-
// We should be able to focus the first option
4271+
// We should be able to focus the second option
42684272
await focus(options[1])
42694273
assertActiveComboboxOption(options[1])
42704274
})
@@ -4296,7 +4300,7 @@ describe('Mouse interactions', () => {
42964300

42974301
// We should not be able to focus the first option
42984302
await focus(options[1])
4299-
assertNoActiveComboboxOption()
4303+
assertNotActiveComboboxOption(options[1])
43004304
})
43014305
)
43024306

0 commit comments

Comments
 (0)