import type { VueWrapper } from '@vue/test-utils'; import { mount } from '@vue/test-utils'; import { afterEach, describe, expect, it } from 'vitest'; import { defineComponent, h, nextTick, ref } from 'vue'; import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport, } from '../../../index'; const wrappers: Array> = []; afterEach(() => { while (wrappers.length) wrappers.pop()!.unmount(); document.body.innerHTML = ''; document.body.removeAttribute('style'); }); function track>(w: T): T { wrappers.push(w); return w; } function makeApp(rootProps: Record = {}, opts: { scrollbarRef?: any } = {}) { return defineComponent({ setup() { return () => h( ScrollAreaRoot, { ...rootProps, style: { width: '100px', height: '100px' } }, { default: () => [ h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, { default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'), }), h(ScrollAreaScrollbar, { orientation: 'vertical', ...(opts.scrollbarRef ? { ref: opts.scrollbarRef } : {}) }, { default: () => h(ScrollAreaThumb), }), h(ScrollAreaScrollbar, { orientation: 'horizontal' }, { default: () => h(ScrollAreaThumb), }), h(ScrollAreaCorner), ], }, ); }, }); } async function waitFrames(n = 3) { for (let i = 0; i < n; i++) { await new Promise(r => requestAnimationFrame(() => r())); await nextTick(); } } function getScrollbar(orientation: 'horizontal' | 'vertical'): HTMLElement { return document.querySelector(`[role="scrollbar"][aria-orientation="${orientation}"]`) as HTMLElement; } function getViewport(): HTMLElement { return document.querySelector('[data-scroll-area-viewport]') as HTMLElement; } describe('scroll-area — thumb sizing', () => { it('sets the thumb-size CSS var on the scrollbar so the thumb has length', async () => { track(mount(makeApp({ type: 'always' }), { attachTo: document.body })); await waitFrames(); const v = getScrollbar('vertical'); const h2 = getScrollbar('horizontal'); // The vars are written inline on the scrollbar element style. expect(v.style.getPropertyValue('--scroll-area-thumb-height')).toMatch(/px$/); expect(h2.style.getPropertyValue('--scroll-area-thumb-width')).toMatch(/px$/); }); }); describe('scroll-area — viewport keyboard focus', () => { it('viewport is focusable by default (tabindex=0)', async () => { track(mount(makeApp({ type: 'always' }), { attachTo: document.body })); await waitFrames(); expect(getViewport().getAttribute('tabindex')).toBe('0'); }); it('viewport tabindex is overridable', async () => { const App = defineComponent({ setup() { return () => h( ScrollAreaRoot, { type: 'always', style: { width: '100px', height: '100px' } }, { default: () => [ h(ScrollAreaViewport, { tabindex: -1, style: { width: '100%', height: '100%' } }, { default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'), }), h(ScrollAreaScrollbar, { orientation: 'vertical' }, { default: () => h(ScrollAreaThumb) }), ], }, ); }, }); track(mount(App, { attachTo: document.body })); await waitFrames(); expect(getViewport().getAttribute('tabindex')).toBe('-1'); }); }); describe('scroll-area — RTL scrollbar positioning', () => { it('LTR keeps the vertical bar on the right', async () => { track(mount(makeApp({ type: 'always', dir: 'ltr' }), { attachTo: document.body })); await waitFrames(); const v = getScrollbar('vertical'); expect(v.style.right).toBe('0px'); expect(v.style.left).toBe(''); }); it('RTL flips the vertical bar to the left', async () => { track(mount(makeApp({ type: 'always', dir: 'rtl' }), { attachTo: document.body })); await waitFrames(); const v = getScrollbar('vertical'); expect(v.style.left).toBe('0px'); expect(v.style.right).toBe(''); }); }); describe('scroll-area — thumb data-state', () => { it('reflects hasThumb when content overflows', async () => { track(mount(makeApp({ type: 'always' }), { attachTo: document.body })); await waitFrames(); const thumb = document.querySelector('[data-state]') as HTMLElement; expect(thumb).toBeTruthy(); // jsdom has no layout; with always type the scrollbar is mounted and // thumb data-state derives from hasThumb (false in jsdom => 'hidden'). expect(['visible', 'hidden']).toContain(thumb.getAttribute('data-state')); }); }); describe('scroll-area — ref forwarding', () => { it('forwards a template ref on ScrollAreaScrollbar to the DOM scrollbar element', async () => { const scrollbarRef = ref(null); track(mount(makeApp({ type: 'always' }, { scrollbarRef }), { attachTo: document.body })); await waitFrames(); const el = scrollbarRef.value?.$el ?? scrollbarRef.value; expect(el).toBeTruthy(); expect((el as HTMLElement).getAttribute?.('role')).toBe('scrollbar'); }); }); describe('scroll-area — glimpse type', () => { it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => { const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); await waitFrames(); const root = w.element as HTMLElement; root.dispatchEvent(new PointerEvent('pointerenter')); await waitFrames(); // Scope to this component's root: browser-mode suites share one document, // so a global query can also count scrollbars mounted by other suites. expect(root.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0); }); it('glimpse stays hidden when the pointer is away', async () => { const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); const root = w.element as HTMLElement; // Browser mode uses a real cursor: the area mounts at the top-left, so a // leftover pointer from a previous suite can land on it and fire a stray // `pointerenter` (revealing the glimpse). Let that settle, then assert the // deterministic "pointer not over the area" state via `pointerleave`. await waitFrames(); root.dispatchEvent(new PointerEvent('pointerleave')); await waitFrames(); expect(root.querySelectorAll('[data-state="visible"]').length).toBe(0); }); }); describe('scroll-area — corner type guard', () => { it('does not render a corner for type="scroll"', async () => { const App = defineComponent({ setup() { return () => h( ScrollAreaRoot, { type: 'scroll', style: { width: '100px', height: '100px' } }, { default: () => [ h(ScrollAreaViewport, { style: { width: '100%', height: '100%' } }, { default: () => h('div', { style: { width: '500px', height: '500px' } }, 'content'), }), h(ScrollAreaScrollbar, { orientation: 'vertical', forceMount: true }, { default: () => h(ScrollAreaThumb) }), h(ScrollAreaScrollbar, { orientation: 'horizontal', forceMount: true }, { default: () => h(ScrollAreaThumb) }), h(ScrollAreaCorner), ], }, ); }, }); const w = track(mount(App, { attachTo: document.body })); await waitFrames(); // Corner only renders when both bars are present AND type !== 'scroll'. // For type='scroll' the corner must never appear. const corners = w.findAll('div').filter(d => (d.element as HTMLElement).style.position === 'absolute' && (d.element as HTMLElement).getAttribute('role') !== 'scrollbar'); // None of the absolutely-positioned non-scrollbar divs should be the corner. // (Corner has both width/height px set and bottom:0 with no role.) const corner = corners.find(d => (d.element as HTMLElement).style.bottom === '0px' && !(d.element as HTMLElement).hasAttribute('aria-orientation')); expect(corner).toBeUndefined(); }); });