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(
+
+ )
+
+ // 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`
+
+ `)
+
+ // 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(