Skip to content

Commit bb6f68e

Browse files
authored
perf(Listbox): avoid collection scan when highlighting (#2912)
1 parent 9a07946 commit bb6f68e

4 files changed

Lines changed: 90 additions & 3 deletions

File tree

‎packages/core/src/Collection/Collection.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,43 @@ describe('useCollection', () => {
155155
wrapper.unmount()
156156
})
157157

158+
it('looks up registered items by element and preserves disabled filtering', async () => {
159+
const { getCollection, wrapper } = createWrapper([
160+
{ value: 'enabled' },
161+
{ value: 'disabled', disabled: true },
162+
])
163+
await nextTick()
164+
165+
const { getItem, getItems } = getCollection()
166+
const enabled = wrapper.get<HTMLElement>('[data-testid=enabled]').element
167+
const disabled = wrapper.get<HTMLElement>('[data-testid=disabled]').element
168+
169+
expect(getItem(enabled)).toBe(getItems().find(item => item.ref === enabled))
170+
expect(getItem(disabled)).toBeUndefined()
171+
expect(getItem(disabled, true)).toBe(getItems(true).find(item => item.ref === disabled))
172+
expect(getItem(document.createElement('div'))).toBeUndefined()
173+
174+
wrapper.unmount()
175+
})
176+
177+
it('finds detached registered items but not unmounted items', async () => {
178+
const { getCollection, items, wrapper } = createWrapper([{ value: 'item' }])
179+
await nextTick()
180+
181+
const { getItem, getItems } = getCollection()
182+
const element = wrapper.get<HTMLElement>('[data-testid=item]').element
183+
const item = getItems()[0]
184+
element.remove()
185+
expect(getItem(element)).toBe(item)
186+
187+
wrapper.element.appendChild(element)
188+
items.splice(0, 1)
189+
await nextTick()
190+
expect(getItem(element)).toBeUndefined()
191+
192+
wrapper.unmount()
193+
})
194+
158195
it('getItems() returns [] when no CollectionSlot element is mounted', async () => {
159196
// A provider context whose `collectionRef` is never set (no CollectionSlot
160197
// currentElement). `getItems()` short-circuits to `[]`.
@@ -171,6 +208,7 @@ describe('useCollection', () => {
171208
const wrapper = mount(Parent, { attachTo: document.body })
172209
await nextTick()
173210

211+
expect(collection.getItem(document.createElement('div'))).toBeUndefined()
174212
expect(collection.getItems()).toEqual([])
175213
expect(collection.getItems(true)).toEqual([])
176214
expect(collection.itemMapSize.value).toBe(0)

‎packages/core/src/Collection/Collection.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export function useCollection<ItemData = {}>(options: { key?: string, isProvider
2828
context = inject(injectionKey) as CollectionContext<ItemData>
2929
}
3030

31+
const getItem = (element: HTMLElement, includeDisabledItem = false) => {
32+
if (!context.collectionRef.value)
33+
return undefined
34+
35+
const item = context.itemMap.value.get(element)
36+
return item && (includeDisabledItem || item.ref.dataset.disabled !== '') ? item : undefined
37+
}
38+
3139
const getItems = (includeDisabledItem = false) => {
3240
const collectionNode = context.collectionRef.value
3341
if (!collectionNode)
@@ -85,5 +93,5 @@ export function useCollection<ItemData = {}>(options: { key?: string, isProvider
8593
const reactiveItems = computed(() => Array.from(context.itemMap.value.values()))
8694
const itemMapSize = computed(() => context.itemMap.value.size)
8795

88-
return { getItems, reactiveItems, itemMapSize, CollectionSlot, CollectionItem }
96+
return { getItems, getItem, reactiveItems, itemMapSize, CollectionSlot, CollectionItem }
8997
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,47 @@ describe('given default Listbox', () => {
132132
})
133133
})
134134

135+
describe('given a Listbox with hover highlighting', () => {
136+
it('emits the highlighted item without querying the collection', async () => {
137+
const wrapper = mount(ListboxRoot, {
138+
attachTo: document.body,
139+
props: { highlightOnHover: true },
140+
slots: {
141+
default: () => h(ListboxContent, null, {
142+
default: () => ['first', 'second', 'third'].map(value =>
143+
h(ListboxItem, { value }, () => value)),
144+
}),
145+
},
146+
})
147+
await nextTick()
148+
await nextTick()
149+
150+
const content = wrapper.find('[role=listbox]')
151+
const items = wrapper.findAll('[role=option]')
152+
const querySpy = vi.spyOn(content.element, 'querySelectorAll')
153+
try {
154+
for (const item of items.slice(1)) {
155+
await item.trigger('pointermove', { pointerType: 'mouse' })
156+
expect(wrapper.emitted('highlight')?.at(-1)?.[0]).toEqual({
157+
ref: item.element,
158+
value: item.text(),
159+
})
160+
expect(item.attributes('data-highlighted')).toBe('')
161+
}
162+
expect(querySpy).not.toHaveBeenCalled()
163+
164+
const eventCount = wrapper.emitted('highlight')?.length
165+
await items[2].trigger('pointermove', { pointerType: 'mouse' })
166+
expect(wrapper.emitted('highlight')).toHaveLength(eventCount!)
167+
expect(querySpy).not.toHaveBeenCalled()
168+
}
169+
finally {
170+
querySpy.mockRestore()
171+
wrapper.unmount()
172+
}
173+
})
174+
})
175+
135176
describe('given a Listbox on initial mount', () => {
136177
let wrapper: VueWrapper<InstanceType<typeof Listbox>>
137178
let scrollSpy: ReturnType<typeof vi.fn>

‎packages/core/src/Listbox/ListboxRoot.vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ defineSlots<{
108108
}>()
109109
110110
const { multiple, highlightOnHover, orientation, disabled, selectionBehavior, dir: propDir } = toRefs(props)
111-
const { getItems } = useCollection<{ value: T }>({ isProvider: true })
111+
const { getItems, getItem } = useCollection<{ value: T }>({ isProvider: true })
112112
const { handleTypeaheadSearch } = useTypeahead()
113113
const { primitiveElement, currentElement } = usePrimitiveElement()
114114
const kbd = useKbd()
@@ -200,7 +200,7 @@ function changeHighlight(el: HTMLElement, scrollIntoView = true, focus?: boolean
200200
highlightedElement.value.scrollIntoView({ block: 'nearest' })
201201
}
202202
203-
const highlightedItem = getItems().find(i => i.ref === el)
203+
const highlightedItem = getItem(el)
204204
emits('highlight', highlightedItem)
205205
}
206206

0 commit comments

Comments
 (0)