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
This commit is contained in:
Robin Malfait
2022-01-19 13:49:57 +01:00
committed by GitHub
parent 2dd57f1cd3
commit 186a4cfcef
9 changed files with 189 additions and 14 deletions
+2
View File
@@ -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
@@ -3073,6 +3073,40 @@ describe('Keyboard interactions', () => {
assertActiveListboxOption(options[1])
})
)
it(
'should be possible to search for the next occurence',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">alice</Listbox.Option>
<Listbox.Option value="b">bob</Listbox.Option>
<Listbox.Option value="c">charlie</Listbox.Option>
<Listbox.Option value="d">bob</Listbox.Option>
</Listbox.Options>
</Listbox>
)
// 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])
})
)
})
})
@@ -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
@@ -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(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a">alice</Menu.Item>
<Menu.Item as="a">bob</Menu.Item>
<Menu.Item as="a">charlie</Menu.Item>
<Menu.Item as="a">bob</Menu.Item>
</Menu.Items>
</Menu>
)
// 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])
})
)
})
})
@@ -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(
@@ -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`
<Listbox v-model="value">
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">alice</ListboxOption>
<ListboxOption value="b">bob</ListboxOption>
<ListboxOption value="c">charlie</ListboxOption>
<ListboxOption value="b">bob</ListboxOption>
</ListboxOptions>
</Listbox>
`,
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])
})
)
})
})
@@ -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
@@ -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`
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem as="a">alice</MenuItem>
<MenuItem as="a">bob</MenuItem>
<MenuItem as="a">charlie</MenuItem>
<MenuItem as="a">bob</MenuItem>
</MenuItems>
</Menu>
`)
// 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])
})
})
})
@@ -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 = ''