diff --git a/packages/core/src/Listbox/Listbox.test.ts b/packages/core/src/Listbox/Listbox.test.ts index fb20c5fe8..758bf2eb7 100644 --- a/packages/core/src/Listbox/Listbox.test.ts +++ b/packages/core/src/Listbox/Listbox.test.ts @@ -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', () => { @@ -167,6 +167,104 @@ describe('given a Listbox on initial mount', () => { }) }) +describe('given a virtualized Listbox on initial mount', () => { + let scrollSpy: ReturnType + + 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> diff --git a/packages/core/src/Listbox/ListboxRoot.vue b/packages/core/src/Listbox/ListboxRoot.vue index 4bedb355d..5eda1ff20 100644 --- a/packages/core/src/Listbox/ListboxRoot.vue +++ b/packages/core/src/Listbox/ListboxRoot.vue @@ -16,7 +16,7 @@ type ListboxRootContext = { highlightOnHover: Ref highlightedElement: Ref isVirtual: Ref - virtualFocusHook: EventHook + virtualFocusHook: EventHook<{ event?: Event, scroll: boolean }> virtualKeydownHook: EventHook virtualHighlightHook: EventHook by?: string | ((a: T, b: T) => boolean) @@ -150,7 +150,7 @@ const highlightedElement = ref(null) const previousElement = ref(null) const isVirtual = ref(false) const isComposing = ref(false) -const virtualFocusHook = createEventHook() +const virtualFocusHook = createEventHook<{ event?: Event, scroll: boolean }>() const virtualKeydownHook = createEventHook() const virtualHighlightHook = createEventHook() @@ -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() diff --git a/packages/core/src/Listbox/ListboxVirtualizer.vue b/packages/core/src/Listbox/ListboxVirtualizer.vue index 82aa31677..b7b045af9 100644 --- a/packages/core/src/Listbox/ListboxVirtualizer.vue +++ b/packages/core/src/Listbox/ListboxVirtualizer.vue @@ -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) @@ -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) => {