diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c05c93fa1..44cccf4e3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,12 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure correct order when conditionally rendering `Menu.Item`, `Listbox.Option` and `RadioGroup.Option` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045))
- Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050))
+- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
## [Unreleased - @headlessui/vue]
### Fixed
- Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045))
+- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
## [@headlessui/react@v1.4.3] - 2022-01-14
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index f766f256f3..90011989f2 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -3073,6 +3073,40 @@ describe('Keyboard interactions', () => {
assertActiveListboxOption(options[1])
})
)
+
+ it(
+ 'should be possible to search for the next occurence',
+ suppressConsoleLogs(async () => {
+ render(
+
+ Trigger
+
+ alice
+ bob
+ charlie
+ bob
+
+
+ )
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // Search for bob
+ await type(word('b'))
+
+ // We should be on the first `bob`
+ assertActiveListboxOption(options[1])
+
+ // Search for bob again
+ await type(word('b'))
+
+ // We should be on the second `bob`
+ assertActiveListboxOption(options[3])
+ })
+ )
})
})
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 5fc4f05012..26432b85b4 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -131,14 +131,24 @@ let reducers: {
if (state.listboxState === ListboxStates.Closed) return state
let searchQuery = state.searchQuery + action.value.toLowerCase()
- let match = state.options.findIndex(
+
+ let reOrderedOptions =
+ state.activeOptionIndex !== null
+ ? state.options
+ .slice(state.activeOptionIndex + 1)
+ .concat(state.options.slice(0, state.activeOptionIndex + 1))
+ : state.options
+
+ let matchingOption = reOrderedOptions.find(
option =>
!option.dataRef.current.disabled &&
option.dataRef.current.textValue?.startsWith(searchQuery)
)
- if (match === -1 || match === state.activeOptionIndex) return { ...state, searchQuery }
- return { ...state, searchQuery, activeOptionIndex: match }
+ let matchIdx = matchingOption ? state.options.indexOf(matchingOption) : -1
+
+ if (matchIdx === -1 || matchIdx === state.activeOptionIndex) return { ...state, searchQuery }
+ return { ...state, searchQuery, activeOptionIndex: matchIdx }
},
[ActionTypes.ClearSearch](state) {
if (state.disabled) return state
diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx
index 0080e97884..a5a2a9b67c 100644
--- a/packages/@headlessui-react/src/components/menu/menu.test.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx
@@ -2631,6 +2631,7 @@ describe('Keyboard interactions', () => {
assertMenuLinkedWithMenuItem(items[2])
})
)
+
it(
'should be possible to search for a word (case insensitive)',
suppressConsoleLogs(async () => {
@@ -2663,6 +2664,40 @@ describe('Keyboard interactions', () => {
assertMenuLinkedWithMenuItem(items[1])
})
)
+
+ it(
+ 'should be possible to search for the next occurence',
+ suppressConsoleLogs(async () => {
+ render(
+
+ )
+
+ // Open menu
+ await click(getMenuButton())
+
+ let items = getMenuItems()
+
+ // Search for bob
+ await type(word('b'))
+
+ // We should be on the first `bob`
+ assertMenuLinkedWithMenuItem(items[1])
+
+ // Search for bob again
+ await type(word('b'))
+
+ // We should be on the second `bob`
+ assertMenuLinkedWithMenuItem(items[3])
+ })
+ )
})
})
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index c3299f36f0..3e871fe15a 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -100,17 +100,26 @@ let reducers: {
},
[ActionTypes.Search]: (state, action) => {
let searchQuery = state.searchQuery + action.value.toLowerCase()
- let match = state.items.findIndex(
+
+ let reOrderedItems =
+ state.activeItemIndex !== null
+ ? state.items
+ .slice(state.activeItemIndex + 1)
+ .concat(state.items.slice(0, state.activeItemIndex + 1))
+ : state.items
+
+ let matchingItem = reOrderedItems.find(
item =>
item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled
)
- if (match === -1 || match === state.activeItemIndex) return { ...state, searchQuery }
- return { ...state, searchQuery, activeItemIndex: match }
+ let matchIdx = matchingItem ? state.items.indexOf(matchingItem) : -1
+ if (matchIdx === -1 || matchIdx === state.activeItemIndex) return { ...state, searchQuery }
+ return { ...state, searchQuery, activeItemIndex: matchIdx }
},
[ActionTypes.ClearSearch](state) {
if (state.searchQuery === '') return state
- return { ...state, searchQuery: '' }
+ return { ...state, searchQuery: '', searchActiveItemIndex: null }
},
[ActionTypes.RegisterItem]: (state, action) => {
let orderMap = Array.from(
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index 73a6a55d20..c0a437e995 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -3316,6 +3316,43 @@ describe('Keyboard interactions', () => {
assertActiveListboxOption(options[1])
})
)
+
+ it(
+ 'should be possible to search for the next occurence',
+ suppressConsoleLogs(async () => {
+ renderTemplate({
+ template: html`
+
+ Trigger
+
+ alice
+ bob
+ charlie
+ bob
+
+
+ `,
+ setup: () => ({ value: ref(null) }),
+ })
+
+ // Open listbox
+ await click(getListboxButton())
+
+ let options = getListboxOptions()
+
+ // Search for bob
+ await type(word('b'))
+
+ // We should be on the first `bob`
+ assertActiveListboxOption(options[1])
+
+ // Search for bob again
+ await type(word('b'))
+
+ // We should be on the second `bob`
+ assertActiveListboxOption(options[3])
+ })
+ )
})
})
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index 5ec48a4f6a..cbc244d0de 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -145,13 +145,22 @@ export let Listbox = defineComponent({
searchQuery.value += value.toLowerCase()
- let match = options.value.findIndex(
+ let reOrderedOptions =
+ activeOptionIndex.value !== null
+ ? options.value
+ .slice(activeOptionIndex.value + 1)
+ .concat(options.value.slice(0, activeOptionIndex.value + 1))
+ : options.value
+
+ let matchingOption = reOrderedOptions.find(
option =>
- !option.dataRef.disabled && option.dataRef.textValue.startsWith(searchQuery.value)
+ option.dataRef.textValue.startsWith(searchQuery.value) && !option.dataRef.disabled
)
- if (match === -1 || match === activeOptionIndex.value) return
- activeOptionIndex.value = match
+ let matchIdx = matchingOption ? options.value.indexOf(matchingOption) : -1
+ if (matchIdx === -1 || matchIdx === activeOptionIndex.value) return
+
+ activeOptionIndex.value = matchIdx
},
clearSearch() {
if (props.disabled) return
diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx
index 478cd7e5f4..fe34508d35 100644
--- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx
+++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx
@@ -2753,6 +2753,37 @@ describe('Keyboard interactions', () => {
// We should be on `bob`
assertMenuLinkedWithMenuItem(items[1])
})
+
+ it('should be possible to search for the next occurence', async () => {
+ renderTemplate(jsx`
+
+ `)
+
+ // Open menu
+ await click(getMenuButton())
+
+ let items = getMenuItems()
+
+ // Search for bob
+ await type(word('b'))
+
+ // We should be on the first `bob`
+ assertMenuLinkedWithMenuItem(items[1])
+
+ // Search for bob again
+ await type(word('b'))
+
+ // We should be on the second `bob`
+ assertMenuLinkedWithMenuItem(items[3])
+ })
})
})
diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts
index 42fc04ac16..1b25dcbc09 100644
--- a/packages/@headlessui-vue/src/components/menu/menu.ts
+++ b/packages/@headlessui-vue/src/components/menu/menu.ts
@@ -108,13 +108,21 @@ export let Menu = defineComponent({
search(value: string) {
searchQuery.value += value.toLowerCase()
- let match = items.value.findIndex(
+ let reOrderedItems =
+ activeItemIndex.value !== null
+ ? items.value
+ .slice(activeItemIndex.value + 1)
+ .concat(items.value.slice(0, activeItemIndex.value + 1))
+ : items.value
+
+ let matchingItem = reOrderedItems.find(
item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled
)
- if (match === -1 || match === activeItemIndex.value) return
+ let matchIdx = matchingItem ? items.value.indexOf(matchingItem) : -1
+ if (matchIdx === -1 || matchIdx === activeItemIndex.value) return
- activeItemIndex.value = match
+ activeItemIndex.value = matchIdx
},
clearSearch() {
searchQuery.value = ''