Skip to content

Commit 186a4cf

Browse files
authored
Improve typeahead search logic (#1051)
* improve typeahead search logic This ensures that if you have 4 items: - Alice - Bob - Charlie - Bob And you search for `b`, then you jump to the first `Bob`, but if yuo search again for `b` then we used to go to the very first `Bob` because we always searched from the top. Now we will search from the active item and onwards. Which means that we will now jump to the second `Bob`. * update changelog
1 parent 2dd57f1 commit 186a4cf

File tree

9 files changed

+189
-14
lines changed

9 files changed

+189
-14
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Ensure correct order when conditionally rendering `Menu.Item`, `Listbox.Option` and `RadioGroup.Option` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045))
1313
- Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050))
14+
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
1415

1516
## [Unreleased - @headlessui/vue]
1617

1718
### Fixed
1819

1920
- Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045))
21+
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
2022

2123
## [@headlessui/react@v1.4.3] - 2022-01-14
2224

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3073,6 +3073,40 @@ describe('Keyboard interactions', () => {
30733073
assertActiveListboxOption(options[1])
30743074
})
30753075
)
3076+
3077+
it(
3078+
'should be possible to search for the next occurence',
3079+
suppressConsoleLogs(async () => {
3080+
render(
3081+
<Listbox value={undefined} onChange={console.log}>
3082+
<Listbox.Button>Trigger</Listbox.Button>
3083+
<Listbox.Options>
3084+
<Listbox.Option value="a">alice</Listbox.Option>
3085+
<Listbox.Option value="b">bob</Listbox.Option>
3086+
<Listbox.Option value="c">charlie</Listbox.Option>
3087+
<Listbox.Option value="d">bob</Listbox.Option>
3088+
</Listbox.Options>
3089+
</Listbox>
3090+
)
3091+
3092+
// Open listbox
3093+
await click(getListboxButton())
3094+
3095+
let options = getListboxOptions()
3096+
3097+
// Search for bob
3098+
await type(word('b'))
3099+
3100+
// We should be on the first `bob`
3101+
assertActiveListboxOption(options[1])
3102+
3103+
// Search for bob again
3104+
await type(word('b'))
3105+
3106+
// We should be on the second `bob`
3107+
assertActiveListboxOption(options[3])
3108+
})
3109+
)
30763110
})
30773111
})
30783112

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,24 @@ let reducers: {
131131
if (state.listboxState === ListboxStates.Closed) return state
132132

133133
let searchQuery = state.searchQuery + action.value.toLowerCase()
134-
let match = state.options.findIndex(
134+
135+
let reOrderedOptions =
136+
state.activeOptionIndex !== null
137+
? state.options
138+
.slice(state.activeOptionIndex + 1)
139+
.concat(state.options.slice(0, state.activeOptionIndex + 1))
140+
: state.options
141+
142+
let matchingOption = reOrderedOptions.find(
135143
option =>
136144
!option.dataRef.current.disabled &&
137145
option.dataRef.current.textValue?.startsWith(searchQuery)
138146
)
139147

140-
if (match === -1 || match === state.activeOptionIndex) return { ...state, searchQuery }
141-
return { ...state, searchQuery, activeOptionIndex: match }
148+
let matchIdx = matchingOption ? state.options.indexOf(matchingOption) : -1
149+
150+
if (matchIdx === -1 || matchIdx === state.activeOptionIndex) return { ...state, searchQuery }
151+
return { ...state, searchQuery, activeOptionIndex: matchIdx }
142152
},
143153
[ActionTypes.ClearSearch](state) {
144154
if (state.disabled) return state

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2631,6 +2631,7 @@ describe('Keyboard interactions', () => {
26312631
assertMenuLinkedWithMenuItem(items[2])
26322632
})
26332633
)
2634+
26342635
it(
26352636
'should be possible to search for a word (case insensitive)',
26362637
suppressConsoleLogs(async () => {
@@ -2663,6 +2664,40 @@ describe('Keyboard interactions', () => {
26632664
assertMenuLinkedWithMenuItem(items[1])
26642665
})
26652666
)
2667+
2668+
it(
2669+
'should be possible to search for the next occurence',
2670+
suppressConsoleLogs(async () => {
2671+
render(
2672+
<Menu>
2673+
<Menu.Button>Trigger</Menu.Button>
2674+
<Menu.Items>
2675+
<Menu.Item as="a">alice</Menu.Item>
2676+
<Menu.Item as="a">bob</Menu.Item>
2677+
<Menu.Item as="a">charlie</Menu.Item>
2678+
<Menu.Item as="a">bob</Menu.Item>
2679+
</Menu.Items>
2680+
</Menu>
2681+
)
2682+
2683+
// Open menu
2684+
await click(getMenuButton())
2685+
2686+
let items = getMenuItems()
2687+
2688+
// Search for bob
2689+
await type(word('b'))
2690+
2691+
// We should be on the first `bob`
2692+
assertMenuLinkedWithMenuItem(items[1])
2693+
2694+
// Search for bob again
2695+
await type(word('b'))
2696+
2697+
// We should be on the second `bob`
2698+
assertMenuLinkedWithMenuItem(items[3])
2699+
})
2700+
)
26662701
})
26672702
})
26682703

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,26 @@ let reducers: {
100100
},
101101
[ActionTypes.Search]: (state, action) => {
102102
let searchQuery = state.searchQuery + action.value.toLowerCase()
103-
let match = state.items.findIndex(
103+
104+
let reOrderedItems =
105+
state.activeItemIndex !== null
106+
? state.items
107+
.slice(state.activeItemIndex + 1)
108+
.concat(state.items.slice(0, state.activeItemIndex + 1))
109+
: state.items
110+
111+
let matchingItem = reOrderedItems.find(
104112
item =>
105113
item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled
106114
)
107115

108-
if (match === -1 || match === state.activeItemIndex) return { ...state, searchQuery }
109-
return { ...state, searchQuery, activeItemIndex: match }
116+
let matchIdx = matchingItem ? state.items.indexOf(matchingItem) : -1
117+
if (matchIdx === -1 || matchIdx === state.activeItemIndex) return { ...state, searchQuery }
118+
return { ...state, searchQuery, activeItemIndex: matchIdx }
110119
},
111120
[ActionTypes.ClearSearch](state) {
112121
if (state.searchQuery === '') return state
113-
return { ...state, searchQuery: '' }
122+
return { ...state, searchQuery: '', searchActiveItemIndex: null }
114123
},
115124
[ActionTypes.RegisterItem]: (state, action) => {
116125
let orderMap = Array.from(

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3316,6 +3316,43 @@ describe('Keyboard interactions', () => {
33163316
assertActiveListboxOption(options[1])
33173317
})
33183318
)
3319+
3320+
it(
3321+
'should be possible to search for the next occurence',
3322+
suppressConsoleLogs(async () => {
3323+
renderTemplate({
3324+
template: html`
3325+
<Listbox v-model="value">
3326+
<ListboxButton>Trigger</ListboxButton>
3327+
<ListboxOptions>
3328+
<ListboxOption value="a">alice</ListboxOption>
3329+
<ListboxOption value="b">bob</ListboxOption>
3330+
<ListboxOption value="c">charlie</ListboxOption>
3331+
<ListboxOption value="b">bob</ListboxOption>
3332+
</ListboxOptions>
3333+
</Listbox>
3334+
`,
3335+
setup: () => ({ value: ref(null) }),
3336+
})
3337+
3338+
// Open listbox
3339+
await click(getListboxButton())
3340+
3341+
let options = getListboxOptions()
3342+
3343+
// Search for bob
3344+
await type(word('b'))
3345+
3346+
// We should be on the first `bob`
3347+
assertActiveListboxOption(options[1])
3348+
3349+
// Search for bob again
3350+
await type(word('b'))
3351+
3352+
// We should be on the second `bob`
3353+
assertActiveListboxOption(options[3])
3354+
})
3355+
)
33193356
})
33203357
})
33213358

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,22 @@ export let Listbox = defineComponent({
145145

146146
searchQuery.value += value.toLowerCase()
147147

148-
let match = options.value.findIndex(
148+
let reOrderedOptions =
149+
activeOptionIndex.value !== null
150+
? options.value
151+
.slice(activeOptionIndex.value + 1)
152+
.concat(options.value.slice(0, activeOptionIndex.value + 1))
153+
: options.value
154+
155+
let matchingOption = reOrderedOptions.find(
149156
option =>
150-
!option.dataRef.disabled && option.dataRef.textValue.startsWith(searchQuery.value)
157+
option.dataRef.textValue.startsWith(searchQuery.value) && !option.dataRef.disabled
151158
)
152159

153-
if (match === -1 || match === activeOptionIndex.value) return
154-
activeOptionIndex.value = match
160+
let matchIdx = matchingOption ? options.value.indexOf(matchingOption) : -1
161+
if (matchIdx === -1 || matchIdx === activeOptionIndex.value) return
162+
163+
activeOptionIndex.value = matchIdx
155164
},
156165
clearSearch() {
157166
if (props.disabled) return

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2753,6 +2753,37 @@ describe('Keyboard interactions', () => {
27532753
// We should be on `bob`
27542754
assertMenuLinkedWithMenuItem(items[1])
27552755
})
2756+
2757+
it('should be possible to search for the next occurence', async () => {
2758+
renderTemplate(jsx`
2759+
<Menu>
2760+
<MenuButton>Trigger</MenuButton>
2761+
<MenuItems>
2762+
<MenuItem as="a">alice</MenuItem>
2763+
<MenuItem as="a">bob</MenuItem>
2764+
<MenuItem as="a">charlie</MenuItem>
2765+
<MenuItem as="a">bob</MenuItem>
2766+
</MenuItems>
2767+
</Menu>
2768+
`)
2769+
2770+
// Open menu
2771+
await click(getMenuButton())
2772+
2773+
let items = getMenuItems()
2774+
2775+
// Search for bob
2776+
await type(word('b'))
2777+
2778+
// We should be on the first `bob`
2779+
assertMenuLinkedWithMenuItem(items[1])
2780+
2781+
// Search for bob again
2782+
await type(word('b'))
2783+
2784+
// We should be on the second `bob`
2785+
assertMenuLinkedWithMenuItem(items[3])
2786+
})
27562787
})
27572788
})
27582789

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,21 @@ export let Menu = defineComponent({
108108
search(value: string) {
109109
searchQuery.value += value.toLowerCase()
110110

111-
let match = items.value.findIndex(
111+
let reOrderedItems =
112+
activeItemIndex.value !== null
113+
? items.value
114+
.slice(activeItemIndex.value + 1)
115+
.concat(items.value.slice(0, activeItemIndex.value + 1))
116+
: items.value
117+
118+
let matchingItem = reOrderedItems.find(
112119
item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled
113120
)
114121

115-
if (match === -1 || match === activeItemIndex.value) return
122+
let matchIdx = matchingItem ? items.value.indexOf(matchingItem) : -1
123+
if (matchIdx === -1 || matchIdx === activeItemIndex.value) return
116124

117-
activeItemIndex.value = match
125+
activeItemIndex.value = matchIdx
118126
},
119127
clearSearch() {
120128
searchQuery.value = ''

0 commit comments

Comments
 (0)