Skip to content

Commit

Permalink
Ensure typeahead stays on same item if it still matches (#1098)
Browse files Browse the repository at this point in the history
* ensure typeahead stays on same item if it still matches

Fixes: #1090

* update changelog
  • Loading branch information
RobinMalfait committed Feb 8, 2022
1 parent 554d04b commit dcf2f75
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 8 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
- Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! ([#1055](https://github.com/tailwindlabs/headlessui/pull/1055))
- Improve build files ([#1078](https://github.com/tailwindlabs/headlessui/pull/1078))
- Ensure typeahead stays on same item if it still matches ([#1098](https://github.com/tailwindlabs/headlessui/pull/1098))

### Added

Expand All @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051))
- Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! ([#1055](https://github.com/tailwindlabs/headlessui/pull/1055))
- Improve build files ([#1078](https://github.com/tailwindlabs/headlessui/pull/1078))
- Ensure typeahead stays on same item if it still matches ([#1098](https://github.com/tailwindlabs/headlessui/pull/1098))

### Added

Expand Down
73 changes: 73 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3108,6 +3108,79 @@ describe('Keyboard interactions', () => {
assertActiveListboxOption(options[3])
})
)

it(
'should stay on the same item while keystrokes still match',
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()

// ---

// 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])
})
)
})
})

Expand Down
7 changes: 5 additions & 2 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
73 changes: 73 additions & 0 deletions packages/@headlessui-react/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2698,6 +2698,79 @@ describe('Keyboard interactions', () => {
assertMenuLinkedWithMenuItem(items[3])
})
)

it(
'should stay on the same item while keystrokes still match',
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()

// ---

// 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])
})
)
})
})

Expand Down
6 changes: 4 additions & 2 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
76 changes: 76 additions & 0 deletions packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<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()

// ---

// 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])
})
)
})
})

Expand Down
7 changes: 5 additions & 2 deletions packages/@headlessui-vue/src/components/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
73 changes: 73 additions & 0 deletions packages/@headlessui-vue/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<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()

// ---

// 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])
})
)
})
})

Expand Down
Loading

0 comments on commit dcf2f75

Please sign in to comment.