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( + + Trigger + + alice + bob + charlie + bob + + + ) + + // 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` + + Trigger + + alice + bob + charlie + bob + + + `) + + // 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 = ''