207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
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<VueWrapper<any>> = [];
|
|
|
|
afterEach(() => {
|
|
while (wrappers.length) wrappers.pop()!.unmount();
|
|
document.body.innerHTML = '';
|
|
document.body.removeAttribute('style');
|
|
});
|
|
|
|
function track<T extends VueWrapper<any>>(w: T): T {
|
|
wrappers.push(w);
|
|
return w;
|
|
}
|
|
|
|
function makeApp(rootProps: Record<string, unknown> = {}, 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<void>(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<any>(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();
|
|
});
|
|
});
|