From 664073dd57ebf23e573978b37a9b9bdacdad924f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 8 Feb 2022 13:56:20 +0100 Subject: [PATCH] ensure typeahead stays on same item if it still matches Fixes: #1090 --- .../src/components/listbox/listbox.test.tsx | 73 ++++++++++++++++++ .../src/components/listbox/listbox.tsx | 7 +- .../src/components/menu/menu.test.tsx | 73 ++++++++++++++++++ .../src/components/menu/menu.tsx | 6 +- .../src/components/listbox/listbox.test.tsx | 76 +++++++++++++++++++ .../src/components/listbox/listbox.ts | 7 +- .../src/components/menu/menu.test.tsx | 73 ++++++++++++++++++ .../src/components/menu/menu.ts | 6 +- 8 files changed, 313 insertions(+), 8 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index eea9fc118c..2a48fca509 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -3108,6 +3108,79 @@ describe('Keyboard interactions', () => { assertActiveListboxOption(options[3]) }) ) + + it( + 'should stay on the same item while keystrokes still match', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + bob + + + ) + + // Open listbox + await click(getListboxButton()) + + let options = getListboxOptions() + + // --- + + // Reset: Go to first option + await press(Keys.Home) + + // Search for "b" in "bob" + await type(word('b')) + + // We should be on the first `bob` + assertActiveListboxOption(options[1]) + + // Search for "b" in "bob" again + await type(word('b')) + + // We should be on the next `bob` + assertActiveListboxOption(options[3]) + + // --- + + // Reset: Go to first option + await press(Keys.Home) + + // Search for "bo" in "bob" + await type(word('bo')) + + // We should be on the first `bob` + assertActiveListboxOption(options[1]) + + // Search for "bo" in "bob" again + await type(word('bo')) + + // We should be on the next `bob` + assertActiveListboxOption(options[3]) + + // --- + + // Reset: Go to first option + await press(Keys.Home) + + // Search for "bob" in "bob" + await type(word('bob')) + + // We should be on the first `bob` + assertActiveListboxOption(options[1]) + + // Search for "bob" in "bob" again + await type(word('bob')) + + // We should be on the next `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 da2741cef6..1931534f6d 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -130,13 +130,16 @@ let reducers: { if (state.disabled) return state if (state.listboxState === ListboxStates.Closed) return state + let wasAlreadySearching = state.searchQuery !== '' + let offset = wasAlreadySearching ? 0 : 1 + let searchQuery = state.searchQuery + action.value.toLowerCase() let reOrderedOptions = state.activeOptionIndex !== null ? state.options - .slice(state.activeOptionIndex + 1) - .concat(state.options.slice(0, state.activeOptionIndex + 1)) + .slice(state.activeOptionIndex + offset) + .concat(state.options.slice(0, state.activeOptionIndex + offset)) : state.options let matchingOption = reOrderedOptions.find( diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 1fc853d9b6..f8c36f60b1 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -2698,6 +2698,79 @@ describe('Keyboard interactions', () => { assertMenuLinkedWithMenuItem(items[3]) }) ) + + it( + 'should stay on the same item while keystrokes still match', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + bob + + + ) + + // Open menu + await click(getMenuButton()) + + let items = getMenuItems() + + // --- + + // Reset: Go to first item + await press(Keys.Home) + + // Search for "b" in "bob" + await type(word('b')) + + // We should be on the first `bob` + assertMenuLinkedWithMenuItem(items[1]) + + // Search for "b" in "bob" again + await type(word('b')) + + // We should be on the next `bob` + assertMenuLinkedWithMenuItem(items[3]) + + // --- + + // Reset: Go to first item + await press(Keys.Home) + + // Search for "bo" in "bob" + await type(word('bo')) + + // We should be on the first `bob` + assertMenuLinkedWithMenuItem(items[1]) + + // Search for "bo" in "bob" again + await type(word('bo')) + + // We should be on the next `bob` + assertMenuLinkedWithMenuItem(items[3]) + + // --- + + // Reset: Go to first item + await press(Keys.Home) + + // Search for "bob" in "bob" + await type(word('bob')) + + // We should be on the first `bob` + assertMenuLinkedWithMenuItem(items[1]) + + // Search for "bob" in "bob" again + await type(word('bob')) + + // We should be on the next `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 962549fdf6..04f34b39b9 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -99,13 +99,15 @@ let reducers: { return { ...state, searchQuery: '', activeItemIndex } }, [ActionTypes.Search]: (state, action) => { + let wasAlreadySearching = state.searchQuery !== '' + let offset = wasAlreadySearching ? 0 : 1 let searchQuery = state.searchQuery + action.value.toLowerCase() let reOrderedItems = state.activeItemIndex !== null ? state.items - .slice(state.activeItemIndex + 1) - .concat(state.items.slice(0, state.activeItemIndex + 1)) + .slice(state.activeItemIndex + offset) + .concat(state.items.slice(0, state.activeItemIndex + offset)) : state.items let matchingItem = reOrderedItems.find( diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index c3cd561fef..4b4c187cfe 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -3231,6 +3231,82 @@ describe('Keyboard interactions', () => { assertActiveListboxOption(options[3]) }) ) + + it( + 'should stay on the same item while keystrokes still match', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Trigger + + alice + bob + charlie + bob + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + let options = getListboxOptions() + + // --- + + // Reset: Go to first option + await press(Keys.Home) + + // Search for "b" in "bob" + await type(word('b')) + + // We should be on the first `bob` + assertActiveListboxOption(options[1]) + + // Search for "b" in "bob" again + await type(word('b')) + + // We should be on the next `bob` + assertActiveListboxOption(options[3]) + + // --- + + // Reset: Go to first option + await press(Keys.Home) + + // Search for "bo" in "bob" + await type(word('bo')) + + // We should be on the first `bob` + assertActiveListboxOption(options[1]) + + // Search for "bo" in "bob" again + await type(word('bo')) + + // We should be on the next `bob` + assertActiveListboxOption(options[3]) + + // --- + + // Reset: Go to first option + await press(Keys.Home) + + // Search for "bob" in "bob" + await type(word('bob')) + + // We should be on the first `bob` + assertActiveListboxOption(options[1]) + + // Search for "bob" in "bob" again + await type(word('bob')) + + // We should be on the next `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 812192da8f..1032dade3c 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -143,13 +143,16 @@ export let Listbox = defineComponent({ if (props.disabled) return if (listboxState.value === ListboxStates.Closed) return + let wasAlreadySearching = searchQuery.value !== '' + let offset = wasAlreadySearching ? 0 : 1 + searchQuery.value += value.toLowerCase() let reOrderedOptions = activeOptionIndex.value !== null ? options.value - .slice(activeOptionIndex.value + 1) - .concat(options.value.slice(0, activeOptionIndex.value + 1)) + .slice(activeOptionIndex.value + offset) + .concat(options.value.slice(0, activeOptionIndex.value + offset)) : options.value let matchingOption = reOrderedOptions.find( diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index 4d033b457d..3f5b4e7c75 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -2792,6 +2792,79 @@ describe('Keyboard interactions', () => { // We should be on the second `bob` assertMenuLinkedWithMenuItem(items[3]) }) + + it( + 'should stay on the same item while keystrokes still match', + suppressConsoleLogs(async () => { + renderTemplate(jsx` + + Trigger + + alice + bob + charlie + bob + + + `) + + // Open menu + await click(getMenuButton()) + + let items = getMenuItems() + + // --- + + // Reset: Go to first item + await press(Keys.Home) + + // Search for "b" in "bob" + await type(word('b')) + + // We should be on the first `bob` + assertMenuLinkedWithMenuItem(items[1]) + + // Search for "b" in "bob" again + await type(word('b')) + + // We should be on the next `bob` + assertMenuLinkedWithMenuItem(items[3]) + + // --- + + // Reset: Go to first item + await press(Keys.Home) + + // Search for "bo" in "bob" + await type(word('bo')) + + // We should be on the first `bob` + assertMenuLinkedWithMenuItem(items[1]) + + // Search for "bo" in "bob" again + await type(word('bo')) + + // We should be on the next `bob` + assertMenuLinkedWithMenuItem(items[3]) + + // --- + + // Reset: Go to first item + await press(Keys.Home) + + // Search for "bob" in "bob" + await type(word('bob')) + + // We should be on the first `bob` + assertMenuLinkedWithMenuItem(items[1]) + + // Search for "bob" in "bob" again + await type(word('bob')) + + // We should be on the next `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 f154625ca2..7fc505d27e 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -106,13 +106,15 @@ export let Menu = defineComponent({ activeItemIndex.value = nextActiveItemIndex }, search(value: string) { + let wasAlreadySearching = searchQuery.value !== '' + let offset = wasAlreadySearching ? 0 : 1 searchQuery.value += value.toLowerCase() let reOrderedItems = activeItemIndex.value !== null ? items.value - .slice(activeItemIndex.value + 1) - .concat(items.value.slice(0, activeItemIndex.value + 1)) + .slice(activeItemIndex.value + offset) + .concat(items.value.slice(0, activeItemIndex.value + offset)) : items.value let matchingItem = reOrderedItems.find(