Skip to content

Commit fcfd554

Browse files
authored
Ensure we reset the activeOptionIndex if the active option is unmounted (#2274)
* ensure we reset the `activeOptionIndex` if the active option is unmounted Unmounting of the active option can happen when you are in a multi-select Combobox, and you filter out all the selected values. This means that the moment you press "Enter" on an active item, it becomes the selected item and therefore will be filtered out. * update changelog
1 parent b9af614 commit fcfd554

File tree

6 files changed

+137
-1
lines changed

6 files changed

+137
-1
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Ensure we handle `null` dataRef values correctly ([#2258](https://github.com/tailwindlabs/headlessui/pull/2258))
1313
- Move `aria-multiselectable` to `[role=listbox]` in the `Combobox` component ([#2271](https://github.com/tailwindlabs/headlessui/pull/2271))
1414
- Re-focus `Combobox.Input` when a `Combobox.Option` is selected ([#2272](https://github.com/tailwindlabs/headlessui/pull/2272))
15+
- Ensure we reset the `activeOptionIndex` if the active option is unmounted ([#2274](https://github.com/tailwindlabs/headlessui/pull/2274))
1516

1617
## [1.7.10] - 2023-02-06
1718

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4400,6 +4400,10 @@ describe('Keyboard interactions', () => {
44004400

44014401
let options: ReturnType<typeof getComboboxOptions>
44024402

4403+
options = getComboboxOptions()
4404+
expect(options[0]).toHaveTextContent('person a')
4405+
assertActiveComboboxOption(options[0])
4406+
44034407
await press(Keys.ArrowDown)
44044408

44054409
// Person B should be active
@@ -5646,6 +5650,50 @@ describe('Multi-select', () => {
56465650
assertComboboxOption(options[2], { selected: true })
56475651
})
56485652
)
5653+
5654+
it(
5655+
'should reset the active option, if the active option gets unmounted',
5656+
suppressConsoleLogs(async () => {
5657+
let users = ['alice', 'bob', 'charlie']
5658+
function Example() {
5659+
let [value, setValue] = useState<string[]>([])
5660+
5661+
return (
5662+
<Combobox value={value} onChange={(value) => setValue(value)} multiple>
5663+
<Combobox.Input onChange={() => {}} />
5664+
<Combobox.Button>Trigger</Combobox.Button>
5665+
<Combobox.Options>
5666+
{users
5667+
.filter((user) => !value.includes(user))
5668+
.map((user) => (
5669+
<Combobox.Option key={user} value={user}>
5670+
{user}
5671+
</Combobox.Option>
5672+
))}
5673+
</Combobox.Options>
5674+
</Combobox>
5675+
)
5676+
}
5677+
5678+
render(<Example />)
5679+
5680+
// Open combobox
5681+
await click(getComboboxButton())
5682+
assertCombobox({ state: ComboboxState.Visible })
5683+
5684+
let options = getComboboxOptions()
5685+
5686+
// Go to the next option
5687+
await press(Keys.ArrowDown)
5688+
assertActiveComboboxOption(options[1])
5689+
5690+
// Select the option
5691+
await press(Keys.Enter)
5692+
5693+
// The active option is reset to the very first one
5694+
assertActiveComboboxOption(options[0])
5695+
})
5696+
)
56495697
})
56505698

56515699
describe('Form compatibility', () => {

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,17 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
488488
[value, defaultValue, disabled, multiple, nullable, __demoMode, state]
489489
)
490490

491+
let lastActiveOption = useRef(
492+
data.activeOptionIndex !== null ? data.options[data.activeOptionIndex] : null
493+
)
494+
useEffect(() => {
495+
let currentActiveOption =
496+
data.activeOptionIndex !== null ? data.options[data.activeOptionIndex] : null
497+
if (lastActiveOption.current !== currentActiveOption) {
498+
lastActiveOption.current = currentActiveOption
499+
}
500+
})
501+
491502
useIsoMorphicEffect(() => {
492503
state.dataRef.current = data
493504
}, [data])
@@ -553,7 +564,22 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
553564

554565
let registerOption = useEvent((id, dataRef) => {
555566
dispatch({ type: ActionTypes.RegisterOption, id, dataRef })
556-
return () => dispatch({ type: ActionTypes.UnregisterOption, id })
567+
return () => {
568+
// When we are unregistering the currently active option, then we also have to make sure to
569+
// reset the `defaultToFirstOption` flag, so that visually something is selected and the next
570+
// time you press a key on your keyboard it will go to the proper next or previous option in
571+
// the list.
572+
//
573+
// Since this was the active option and it could have been anywhere in the list, resetting to
574+
// the very first option seems like a fine default. We _could_ be smarter about this by going
575+
// to the previous / next item in list if we know the direction of the keyboard navigation,
576+
// but that might be too complex/confusing from an end users perspective.
577+
if (lastActiveOption.current?.id === id) {
578+
defaultToFirstOption.current = true
579+
}
580+
581+
dispatch({ type: ActionTypes.UnregisterOption, id })
582+
}
557583
})
558584

559585
let registerLabel = useEvent((id) => {

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Don’t fire `afterLeave` event more than once for a given transition ([#2267](https://github.com/tailwindlabs/headlessui/pull/2267))
1313
- Move `aria-multiselectable` to `[role=listbox]` in the `Combobox` component ([#2271](https://github.com/tailwindlabs/headlessui/pull/2271))
1414
- Re-focus `Combobox.Input` when a `Combobox.Option` is selected ([#2272](https://github.com/tailwindlabs/headlessui/pull/2272))
15+
- Ensure we reset the `activeOptionIndex` if the active option is unmounted ([#2274](https://github.com/tailwindlabs/headlessui/pull/2274))
1516

1617
## [1.7.9] - 2023-02-03
1718

packages/@headlessui-vue/src/components/combobox/combobox.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5879,6 +5879,50 @@ describe('Multi-select', () => {
58795879
assertComboboxOption(options[2], { selected: true })
58805880
})
58815881
)
5882+
5883+
it(
5884+
'should reset the active option, if the active option gets unmounted',
5885+
suppressConsoleLogs(async () => {
5886+
renderTemplate({
5887+
template: html`
5888+
<Combobox v-model="value" multiple>
5889+
<ComboboxInput />
5890+
<ComboboxButton>Trigger</ComboboxButton>
5891+
<ComboboxOptions>
5892+
<ComboboxOption
5893+
v-for="user in users.filter(p => !value.includes(p))"
5894+
:key="user"
5895+
:value="user"
5896+
>{{ user }}</ComboboxOption
5897+
>
5898+
</ComboboxOptions>
5899+
</Combobox>
5900+
`,
5901+
setup: () => {
5902+
let users = ['alice', 'bob', 'charlie']
5903+
5904+
let value = ref([])
5905+
return { users, value }
5906+
},
5907+
})
5908+
5909+
// Open combobox
5910+
await click(getComboboxButton())
5911+
assertCombobox({ state: ComboboxState.Visible })
5912+
5913+
let options = getComboboxOptions()
5914+
5915+
// Go to the next option
5916+
await press(Keys.ArrowDown)
5917+
assertActiveComboboxOption(options[1])
5918+
5919+
// Select the option
5920+
await press(Keys.Enter)
5921+
5922+
// The active option is reset to the very first one
5923+
assertActiveComboboxOption(options[0])
5924+
})
5925+
)
58825926
})
58835927

58845928
describe('Form compatibility', () => {

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,22 @@ export let Combobox = defineComponent({
391391
activationTrigger.value = ActivationTrigger.Other
392392
},
393393
unregisterOption(id: string) {
394+
// When we are unregistering the currently active option, then we also have to make sure to
395+
// reset the `defaultToFirstOption` flag, so that visually something is selected and the
396+
// next time you press a key on your keyboard it will go to the proper next or previous
397+
// option in the list.
398+
//
399+
// Since this was the active option and it could have been anywhere in the list, resetting
400+
// to the very first option seems like a fine default. We _could_ be smarter about this by
401+
// going to the previous / next item in list if we know the direction of the keyboard
402+
// navigation, but that might be too complex/confusing from an end users perspective.
403+
if (
404+
api.activeOptionIndex.value !== null &&
405+
api.options.value[api.activeOptionIndex.value]?.id === id
406+
) {
407+
defaultToFirstOption.value = true
408+
}
409+
394410
let adjustedState = adjustOrderedState((options) => {
395411
let idx = options.findIndex((a) => a.id === id)
396412
if (idx !== -1) options.splice(idx, 1)

0 commit comments

Comments
 (0)