feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,198 @@
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 () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames();
const root = document.querySelector('[dir]') as HTMLElement;
root.dispatchEvent(new PointerEvent('pointerenter'));
await waitFrames();
expect(document.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0);
});
it('glimpse stays hidden before any interaction', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames();
// No pointer enter / scroll => no visible scrollbar yet.
expect(document.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();
});
});