Skip to content

Commit bf1f9e3

Browse files
authored
fix(Listbox): include disabled and focusable in ListboxItem v-memo deps (#2653)
The `v-memo` on `ListboxItem` only listed `[isHighlighted, isSelected]`, but the cached VNode also reads `disabled` (for both the `disabled` and `data-disabled` attributes) and `rootContext.focusable.value` (for the `tabindex` ternary). Because `v-memo` skips re-render when none of its listed deps change, toggling `disabled` at runtime left the DOM stale until something else also flipped the highlight or selection. Since `Listbox.getCollectionItem()` filters the navigable collection by reading `dataset.disabled` from the live DOM, the stale attribute caused disabled items to keep participating in keyboard navigation — which in Combobox surfaced as ArrowDown "snapping back" to the originally selected item. Closes #2644
1 parent 8814a1e commit bf1f9e3

2 files changed

Lines changed: 42 additions & 2 deletions

File tree

packages/core/src/Listbox/Listbox.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
22
import { mount } from '@vue/test-utils'
33
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
44
import { axe } from 'vitest-axe'
5-
import { nextTick } from 'vue'
5+
import { defineComponent, h, nextTick, ref } from 'vue'
66
import { useKbd } from '@/shared'
77
import { handleSubmit } from '@/test'
8+
import { ListboxItem, ListboxRoot } from '.'
89
import Listbox from './story/_Listbox.vue'
910

1011
describe('given default Listbox', () => {
@@ -319,6 +320,45 @@ describe('given horizontal Listbox', () => {
319320
})
320321
})
321322

323+
// Regression test for https://github.com/unovue/reka-ui/issues/2644
324+
// `v-memo` on ListboxItem must invalidate when `disabled` (or
325+
// `rootContext.focusable.value`) changes, otherwise `data-disabled` / `disabled`
326+
// attributes go stale and the item still participates in keyboard navigation.
327+
describe('given ListboxItem with reactive `disabled` prop', () => {
328+
it('should update DOM attributes when `disabled` toggles without highlight/selection change', async () => {
329+
const isDisabled = ref(false)
330+
const ReactiveDisabledListbox = defineComponent({
331+
setup() {
332+
return () =>
333+
h(ListboxRoot, null, {
334+
default: () => [
335+
h(ListboxItem, { value: { id: 1 }, disabled: isDisabled.value }, () => 'toggleable'),
336+
h(ListboxItem, { value: { id: 2 } }, () => 'other'),
337+
],
338+
})
339+
},
340+
})
341+
342+
const wrapper = mount(ReactiveDisabledListbox, { attachTo: document.body })
343+
const items = wrapper.findAll('[role=option]')
344+
345+
expect(items[0].attributes('data-disabled')).toBeUndefined()
346+
expect(items[0].attributes('disabled')).toBeUndefined()
347+
348+
isDisabled.value = true
349+
await nextTick()
350+
351+
expect(items[0].attributes('data-disabled')).toBe('')
352+
expect(items[0].attributes('disabled')).toBe('')
353+
354+
isDisabled.value = false
355+
await nextTick()
356+
357+
expect(items[0].attributes('data-disabled')).toBeUndefined()
358+
expect(items[0].attributes('disabled')).toBeUndefined()
359+
})
360+
})
361+
322362
describe('given Listbox in a form', async () => {
323363
let items: DOMWrapper<Element>[]
324364

packages/core/src/Listbox/ListboxItem.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ provideListboxItemContext({
7676
:id="id"
7777
v-bind="$attrs"
7878
:ref="forwardRef"
79-
v-memo="[isHighlighted, isSelected]"
79+
v-memo="[isHighlighted, isSelected, disabled, rootContext.focusable.value]"
8080
role="option"
8181
:tabindex="rootContext.focusable.value ? isHighlighted ? '0' : '-1' : -1"
8282
:aria-selected="isSelected"

0 commit comments

Comments
 (0)