Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions packages/core/src/Listbox/Listbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { defineComponent, h, nextTick, ref } from 'vue'
import { useKbd } from '@/shared'
import { handleSubmit } from '@/test'
import { ListboxItem, ListboxRoot } from '.'
import { ListboxContent, ListboxItem, ListboxRoot, ListboxVirtualizer } from '.'
import Listbox from './story/_Listbox.vue'

describe('given default Listbox', () => {
Expand Down Expand Up @@ -167,6 +167,104 @@ describe('given a Listbox on initial mount', () => {
})
})

describe('given a virtualized Listbox on initial mount', () => {
let scrollSpy: ReturnType<typeof vi.fn>

window.HTMLElement.prototype.releasePointerCapture = vi.fn()
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
window.HTMLElement.prototype.scrollTo = vi.fn()
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}

// jsdom reports zero-sized rects, so `@tanstack/virtual-core` would render no
// items. Give the virtualizer a non-zero viewport so items actually mount.
const originalGetBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect
beforeAll(() => {
window.HTMLElement.prototype.getBoundingClientRect = function () {
return { width: 200, height: 200, top: 0, left: 0, right: 200, bottom: 200, x: 0, y: 0, toJSON() {} }
}
})
afterAll(() => {
window.HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect
})

const VirtualListbox = defineComponent({
props: { multiple: Boolean, modelValue: { type: null, default: undefined } },
setup(props) {
const options = Array.from({ length: 100 }, (_, i) => ({ label: `Item ${i}`, value: i }))
return () => h(ListboxRoot, { multiple: props.multiple, modelValue: props.modelValue }, () =>
h(ListboxContent, { style: 'height: 200px; overflow: auto' }, () =>
h(ListboxVirtualizer, { options, textContent: (o: any) => o.label }, {
default: ({ option }: any) => h(ListboxItem, { value: option }, () => option.label),
})))
},
})

async function flush() {
// watcher → nextTick → highlightSelected (await nextTick) → virtualFocusHook → rAF
await nextTick()
await nextTick()
await new Promise(resolve => requestAnimationFrame(() => resolve(null)))
await nextTick()
}

beforeEach(() => {
scrollSpy = vi.fn()
window.HTMLElement.prototype.scrollIntoView = scrollSpy
document.body.innerHTML = ''
})

it('should highlight the first item without scrolling the page or stealing focus', async () => {
const wrapper = mount(VirtualListbox, { props: { multiple: true }, attachTo: document.body })
await flush()

const items = wrapper.findAll('[role=option]')
expect(items.length).toBeGreaterThan(0)
// the first item is highlighted for keyboard entry...
expect(items[0].attributes('data-highlighted')).toBe('')
// ...but the mount highlight must not focus it or scroll, otherwise a
// virtualized Listbox below the fold scrolls the whole page on load.
expect(scrollSpy).not.toHaveBeenCalled()
expect(document.activeElement).not.toBe(items[0].element)
})

it('should highlight a pre-selected item on mount without scrolling the page or stealing focus', async () => {
// A selected value below the fold must not pull the page to the listbox on
// mount. The checked item is brought into the internal scroll container
// (`scrollToIndex`) and made the roving-tabindex target, but it is neither
// focused nor scrolled into view at the document level.
// `modelValue` is the option object (items hold the whole option as value);
// it matches option #3 structurally via the default `isEqual` comparison.
const wrapper = mount(VirtualListbox, { props: { modelValue: { label: 'Item 3', value: 3 } }, attachTo: document.body })
await flush()

const checked = wrapper.find('[data-index="3"]')
expect(checked.exists()).toBe(true)
// the checked item (not the first item) becomes the highlight target...
expect(checked.attributes('data-highlighted')).toBe('')
expect(wrapper.find('[data-index="0"]').attributes('data-highlighted')).toBeUndefined()
// ...without focusing it or scrolling the page.
expect(scrollSpy).not.toHaveBeenCalled()
expect(document.activeElement).not.toBe(checked.element)
})

it('should focus and scroll the first item when the user enters the listbox', async () => {
// Entry focus (`onEnter`) is user-driven, so focusing and scrolling the
// first item into view is expected here — unlike the mount highlight above.
const wrapper = mount(VirtualListbox, { props: { multiple: true }, attachTo: document.body })
await flush()
scrollSpy.mockClear()

await wrapper.find('[role=listbox]').trigger('focus')
const items = wrapper.findAll('[role=option]')
expect(document.activeElement).toBe(items[0].element)
expect(scrollSpy).toHaveBeenCalled()
})
})

describe('given multiple `true` Listbox', () => {
const kbd = useKbd()
let wrapper: VueWrapper<InstanceType<typeof Listbox>>
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/Listbox/ListboxRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type ListboxRootContext<T> = {
highlightOnHover: Ref<boolean>
highlightedElement: Ref<HTMLElement | null>
isVirtual: Ref<boolean>
virtualFocusHook: EventHook<Event | null | undefined>
virtualFocusHook: EventHook<{ event?: Event, scroll: boolean }>
virtualKeydownHook: EventHook<KeyboardEvent>
virtualHighlightHook: EventHook<any>
by?: string | ((a: T, b: T) => boolean)
Expand Down Expand Up @@ -150,7 +150,7 @@ const highlightedElement = ref<HTMLElement | null>(null)
const previousElement = ref<HTMLElement | null>(null)
const isVirtual = ref(false)
const isComposing = ref(false)
const virtualFocusHook = createEventHook<Event | null | undefined>()
const virtualFocusHook = createEventHook<{ event?: Event, scroll: boolean }>()
const virtualKeydownHook = createEventHook<KeyboardEvent>()
const virtualHighlightHook = createEventHook<T>()

Expand Down Expand Up @@ -333,8 +333,11 @@ async function highlightSelected(event?: Event, scroll = true) {
return
await nextTick()
if (isVirtual.value) {
// Trigger on nextTick for Virtualizer to be mounted
virtualFocusHook.trigger(event)
// Trigger on nextTick for Virtualizer to be mounted.
// `scroll` is `false` on the initial mount highlight, so the virtualizer sets
// its roving-tabindex target without focusing/scrolling — otherwise a
// virtualized Listbox below the fold would pull the page to it on load.
virtualFocusHook.trigger({ event, scroll })
}
else {
const collection = getCollectionItem()
Expand Down
22 changes: 19 additions & 3 deletions packages/core/src/Listbox/ListboxVirtualizer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ const virtualizedItems = computed(() => virtualizer.value.getVirtualItems().map(
}
}))

rootContext.virtualFocusHook.on((event) => {
rootContext.virtualFocusHook.on(({ event, scroll }) => {
// `scroll` is `false` only for the initial mount highlight. There we set the
// roving-tabindex target without focusing or scrolling, so a virtualized
// Listbox below the fold doesn't pull the page to it on load. User-driven
// highlights (keyboard, typeahead, select) keep scrolling as before.
const index = props.options.findIndex((option) => {
if (Array.isArray(rootContext.modelValue.value))
return compare(option, rootContext.modelValue.value[0], rootContext.by)
Expand All @@ -114,19 +118,31 @@ rootContext.virtualFocusHook.on((event) => {
if (index !== -1) {
event?.preventDefault()

// Bringing the checked item into the (internal) scroll viewport is safe — it
// only scrolls the listbox container, never the page.
virtualizer.value.scrollToIndex(index, { align: 'start' })
requestAnimationFrame(() => {
const item = queryCheckedElement(parentEl.value)
if (item) {
rootContext.changeHighlight(item)
rootContext.changeHighlight(item, scroll, scroll ? undefined : false)
if (event)
item?.focus()
}
})
}
else {
else if (scroll) {
rootContext.highlightFirstItem()
}
else {
// Mount highlight with no checked item: highlight the first enabled item only,
// mirroring the non-virtual path. `highlightFirstItem` is reserved for
// user-driven PageUp/Home navigation, which focuses and scrolls.
requestAnimationFrame(() => {
const item = getItems().find(i => i.ref.dataset.disabled !== '')?.ref
if (item)
rootContext.changeHighlight(item, false, false)
})
}
})

rootContext.virtualHighlightHook.on((value) => {
Expand Down
Loading