Skip to content

Commit dcf2f75

Browse files
authored
Ensure typeahead stays on same item if it still matches (#1098)
* ensure typeahead stays on same item if it still matches Fixes: #1090 * update changelog
1 parent 554d04b commit dcf2f75

File tree

9 files changed

+315
-8
lines changed

9 files changed

+315
-8
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
1515
- Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! ([#1055](https://github.com/tailwindlabs/headlessui/pull/1055))
1616
- Improve build files ([#1078](https://github.com/tailwindlabs/headlessui/pull/1078))
17+
- Ensure typeahead stays on same item if it still matches ([#1098](https://github.com/tailwindlabs/headlessui/pull/1098))
1718

1819
### Added
1920

@@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2728
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
2829
- Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! ([#1055](https://github.com/tailwindlabs/headlessui/pull/1055))
2930
- Improve build files ([#1078](https://github.com/tailwindlabs/headlessui/pull/1078))
31+
- Ensure typeahead stays on same item if it still matches ([#1098](https://github.com/tailwindlabs/headlessui/pull/1098))
3032

3133
### Added
3234

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3108,6 +3108,79 @@ describe('Keyboard interactions', () => {
31083108
assertActiveListboxOption(options[3])
31093109
})
31103110
)
3111+
3112+
it(
3113+
'should stay on the same item while keystrokes still match',
3114+
suppressConsoleLogs(async () => {
3115+
render(
3116+
<Listbox value={undefined} onChange={console.log}>
3117+
<Listbox.Button>Trigger</Listbox.Button>
3118+
<Listbox.Options>
3119+
<Listbox.Option value="a">alice</Listbox.Option>
3120+
<Listbox.Option value="b">bob</Listbox.Option>
3121+
<Listbox.Option value="c">charlie</Listbox.Option>
3122+
<Listbox.Option value="d">bob</Listbox.Option>
3123+
</Listbox.Options>
3124+
</Listbox>
3125+
)
3126+
3127+
// Open listbox
3128+
await click(getListboxButton())
3129+
3130+
let options = getListboxOptions()
3131+
3132+
// ---
3133+
3134+
// Reset: Go to first option
3135+
await press(Keys.Home)
3136+
3137+
// Search for "b" in "bob"
3138+
await type(word('b'))
3139+
3140+
// We should be on the first `bob`
3141+
assertActiveListboxOption(options[1])
3142+
3143+
// Search for "b" in "bob" again
3144+
await type(word('b'))
3145+
3146+
// We should be on the next `bob`
3147+
assertActiveListboxOption(options[3])
3148+
3149+
// ---
3150+
3151+
// Reset: Go to first option
3152+
await press(Keys.Home)
3153+
3154+
// Search for "bo" in "bob"
3155+
await type(word('bo'))
3156+
3157+
// We should be on the first `bob`
3158+
assertActiveListboxOption(options[1])
3159+
3160+
// Search for "bo" in "bob" again
3161+
await type(word('bo'))
3162+
3163+
// We should be on the next `bob`
3164+
assertActiveListboxOption(options[3])
3165+
3166+
// ---
3167+
3168+
// Reset: Go to first option
3169+
await press(Keys.Home)
3170+
3171+
// Search for "bob" in "bob"
3172+
await type(word('bob'))
3173+
3174+
// We should be on the first `bob`
3175+
assertActiveListboxOption(options[1])
3176+
3177+
// Search for "bob" in "bob" again
3178+
await type(word('bob'))
3179+
3180+
// We should be on the next `bob`
3181+
assertActiveListboxOption(options[3])
3182+
})
3183+
)
31113184
})
31123185
})
31133186

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,16 @@ let reducers: {
130130
if (state.disabled) return state
131131
if (state.listboxState === ListboxStates.Closed) return state
132132

133+
let wasAlreadySearching = state.searchQuery !== ''
134+
let offset = wasAlreadySearching ? 0 : 1
135+
133136
let searchQuery = state.searchQuery + action.value.toLowerCase()
134137

135138
let reOrderedOptions =
136139
state.activeOptionIndex !== null
137140
? state.options
138-
.slice(state.activeOptionIndex + 1)
139-
.concat(state.options.slice(0, state.activeOptionIndex + 1))
141+
.slice(state.activeOptionIndex + offset)
142+
.concat(state.options.slice(0, state.activeOptionIndex + offset))
140143
: state.options
141144

142145
let matchingOption = reOrderedOptions.find(

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,6 +2698,79 @@ describe('Keyboard interactions', () => {
26982698
assertMenuLinkedWithMenuItem(items[3])
26992699
})
27002700
)
2701+
2702+
it(
2703+
'should stay on the same item while keystrokes still match',
2704+
suppressConsoleLogs(async () => {
2705+
render(
2706+
<Menu>
2707+
<Menu.Button>Trigger</Menu.Button>
2708+
<Menu.Items>
2709+
<Menu.Item as="a">alice</Menu.Item>
2710+
<Menu.Item as="a">bob</Menu.Item>
2711+
<Menu.Item as="a">charlie</Menu.Item>
2712+
<Menu.Item as="a">bob</Menu.Item>
2713+
</Menu.Items>
2714+
</Menu>
2715+
)
2716+
2717+
// Open menu
2718+
await click(getMenuButton())
2719+
2720+
let items = getMenuItems()
2721+
2722+
// ---
2723+
2724+
// Reset: Go to first item
2725+
await press(Keys.Home)
2726+
2727+
// Search for "b" in "bob"
2728+
await type(word('b'))
2729+
2730+
// We should be on the first `bob`
2731+
assertMenuLinkedWithMenuItem(items[1])
2732+
2733+
// Search for "b" in "bob" again
2734+
await type(word('b'))
2735+
2736+
// We should be on the next `bob`
2737+
assertMenuLinkedWithMenuItem(items[3])
2738+
2739+
// ---
2740+
2741+
// Reset: Go to first item
2742+
await press(Keys.Home)
2743+
2744+
// Search for "bo" in "bob"
2745+
await type(word('bo'))
2746+
2747+
// We should be on the first `bob`
2748+
assertMenuLinkedWithMenuItem(items[1])
2749+
2750+
// Search for "bo" in "bob" again
2751+
await type(word('bo'))
2752+
2753+
// We should be on the next `bob`
2754+
assertMenuLinkedWithMenuItem(items[3])
2755+
2756+
// ---
2757+
2758+
// Reset: Go to first item
2759+
await press(Keys.Home)
2760+
2761+
// Search for "bob" in "bob"
2762+
await type(word('bob'))
2763+
2764+
// We should be on the first `bob`
2765+
assertMenuLinkedWithMenuItem(items[1])
2766+
2767+
// Search for "bob" in "bob" again
2768+
await type(word('bob'))
2769+
2770+
// We should be on the next `bob`
2771+
assertMenuLinkedWithMenuItem(items[3])
2772+
})
2773+
)
27012774
})
27022775
})
27032776

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,15 @@ let reducers: {
9999
return { ...state, searchQuery: '', activeItemIndex }
100100
},
101101
[ActionTypes.Search]: (state, action) => {
102+
let wasAlreadySearching = state.searchQuery !== ''
103+
let offset = wasAlreadySearching ? 0 : 1
102104
let searchQuery = state.searchQuery + action.value.toLowerCase()
103105

104106
let reOrderedItems =
105107
state.activeItemIndex !== null
106108
? state.items
107-
.slice(state.activeItemIndex + 1)
108-
.concat(state.items.slice(0, state.activeItemIndex + 1))
109+
.slice(state.activeItemIndex + offset)
110+
.concat(state.items.slice(0, state.activeItemIndex + offset))
109111
: state.items
110112

111113
let matchingItem = reOrderedItems.find(

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3231,6 +3231,82 @@ describe('Keyboard interactions', () => {
32313231
assertActiveListboxOption(options[3])
32323232
})
32333233
)
3234+
3235+
it(
3236+
'should stay on the same item while keystrokes still match',
3237+
suppressConsoleLogs(async () => {
3238+
renderTemplate({
3239+
template: html`
3240+
<Listbox v-model="value">
3241+
<ListboxButton>Trigger</ListboxButton>
3242+
<ListboxOptions>
3243+
<ListboxOption value="a">alice</ListboxOption>
3244+
<ListboxOption value="b">bob</ListboxOption>
3245+
<ListboxOption value="c">charlie</ListboxOption>
3246+
<ListboxOption value="b">bob</ListboxOption>
3247+
</ListboxOptions>
3248+
</Listbox>
3249+
`,
3250+
setup: () => ({ value: ref(null) }),
3251+
})
3252+
3253+
// Open listbox
3254+
await click(getListboxButton())
3255+
3256+
let options = getListboxOptions()
3257+
3258+
// ---
3259+
3260+
// Reset: Go to first option
3261+
await press(Keys.Home)
3262+
3263+
// Search for "b" in "bob"
3264+
await type(word('b'))
3265+
3266+
// We should be on the first `bob`
3267+
assertActiveListboxOption(options[1])
3268+
3269+
// Search for "b" in "bob" again
3270+
await type(word('b'))
3271+
3272+
// We should be on the next `bob`
3273+
assertActiveListboxOption(options[3])
3274+
3275+
// ---
3276+
3277+
// Reset: Go to first option
3278+
await press(Keys.Home)
3279+
3280+
// Search for "bo" in "bob"
3281+
await type(word('bo'))
3282+
3283+
// We should be on the first `bob`
3284+
assertActiveListboxOption(options[1])
3285+
3286+
// Search for "bo" in "bob" again
3287+
await type(word('bo'))
3288+
3289+
// We should be on the next `bob`
3290+
assertActiveListboxOption(options[3])
3291+
3292+
// ---
3293+
3294+
// Reset: Go to first option
3295+
await press(Keys.Home)
3296+
3297+
// Search for "bob" in "bob"
3298+
await type(word('bob'))
3299+
3300+
// We should be on the first `bob`
3301+
assertActiveListboxOption(options[1])
3302+
3303+
// Search for "bob" in "bob" again
3304+
await type(word('bob'))
3305+
3306+
// We should be on the next `bob`
3307+
assertActiveListboxOption(options[3])
3308+
})
3309+
)
32343310
})
32353311
})
32363312

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,16 @@ export let Listbox = defineComponent({
143143
if (props.disabled) return
144144
if (listboxState.value === ListboxStates.Closed) return
145145

146+
let wasAlreadySearching = searchQuery.value !== ''
147+
let offset = wasAlreadySearching ? 0 : 1
148+
146149
searchQuery.value += value.toLowerCase()
147150

148151
let reOrderedOptions =
149152
activeOptionIndex.value !== null
150153
? options.value
151-
.slice(activeOptionIndex.value + 1)
152-
.concat(options.value.slice(0, activeOptionIndex.value + 1))
154+
.slice(activeOptionIndex.value + offset)
155+
.concat(options.value.slice(0, activeOptionIndex.value + offset))
153156
: options.value
154157

155158
let matchingOption = reOrderedOptions.find(

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2792,6 +2792,79 @@ describe('Keyboard interactions', () => {
27922792
// We should be on the second `bob`
27932793
assertMenuLinkedWithMenuItem(items[3])
27942794
})
2795+
2796+
it(
2797+
'should stay on the same item while keystrokes still match',
2798+
suppressConsoleLogs(async () => {
2799+
renderTemplate(jsx`
2800+
<Menu>
2801+
<MenuButton>Trigger</MenuButton>
2802+
<MenuItems>
2803+
<MenuItem as="a">alice</MenuItem>
2804+
<MenuItem as="a">bob</MenuItem>
2805+
<MenuItem as="a">charlie</MenuItem>
2806+
<MenuItem as="a">bob</MenuItem>
2807+
</MenuItems>
2808+
</Menu>
2809+
`)
2810+
2811+
// Open menu
2812+
await click(getMenuButton())
2813+
2814+
let items = getMenuItems()
2815+
2816+
// ---
2817+
2818+
// Reset: Go to first item
2819+
await press(Keys.Home)
2820+
2821+
// Search for "b" in "bob"
2822+
await type(word('b'))
2823+
2824+
// We should be on the first `bob`
2825+
assertMenuLinkedWithMenuItem(items[1])
2826+
2827+
// Search for "b" in "bob" again
2828+
await type(word('b'))
2829+
2830+
// We should be on the next `bob`
2831+
assertMenuLinkedWithMenuItem(items[3])
2832+
2833+
// ---
2834+
2835+
// Reset: Go to first item
2836+
await press(Keys.Home)
2837+
2838+
// Search for "bo" in "bob"
2839+
await type(word('bo'))
2840+
2841+
// We should be on the first `bob`
2842+
assertMenuLinkedWithMenuItem(items[1])
2843+
2844+
// Search for "bo" in "bob" again
2845+
await type(word('bo'))
2846+
2847+
// We should be on the next `bob`
2848+
assertMenuLinkedWithMenuItem(items[3])
2849+
2850+
// ---
2851+
2852+
// Reset: Go to first item
2853+
await press(Keys.Home)
2854+
2855+
// Search for "bob" in "bob"
2856+
await type(word('bob'))
2857+
2858+
// We should be on the first `bob`
2859+
assertMenuLinkedWithMenuItem(items[1])
2860+
2861+
// Search for "bob" in "bob" again
2862+
await type(word('bob'))
2863+
2864+
// We should be on the next `bob`
2865+
assertMenuLinkedWithMenuItem(items[3])
2866+
})
2867+
)
27952868
})
27962869
})
27972870

0 commit comments

Comments
 (0)