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.
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from '../index';
|
||||
import type { SelectItemSelectEvent } from '../index';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mounted: Array<{ unmount: () => void }> = [];
|
||||
function track<T extends { unmount: () => void }>(w: T): T {
|
||||
mounted.push(w);
|
||||
return w;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length) mounted.pop()!.unmount();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
interface Opt { value: unknown; label: string; disabled?: boolean }
|
||||
|
||||
const valueRef = ref<unknown>(undefined);
|
||||
|
||||
function createSelect(
|
||||
rootProps: Record<string, unknown> = {},
|
||||
options: Opt[] = [
|
||||
{ value: 'apple', label: 'Apple' },
|
||||
{ value: 'banana', label: 'Banana' },
|
||||
{ value: 'cherry', label: 'Cherry' },
|
||||
],
|
||||
itemHandlers: Record<string, unknown> = {},
|
||||
) {
|
||||
const value = ref(rootProps.modelValue ?? rootProps.defaultValue);
|
||||
valueRef.value = value.value;
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(
|
||||
SelectRoot,
|
||||
{
|
||||
modelValue: value.value as never,
|
||||
'onUpdate:modelValue': (v: unknown) => {
|
||||
value.value = v as never;
|
||||
valueRef.value = v;
|
||||
},
|
||||
...rootProps,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(SelectTrigger, null, {
|
||||
default: () => h(SelectValue, { placeholder: 'Pick one' }),
|
||||
}),
|
||||
h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, null, {
|
||||
default: () => h(SelectViewport, null, {
|
||||
default: () => options.map(opt =>
|
||||
h(SelectItem, { key: String(opt.value), value: opt.value as never, disabled: opt.disabled, ...itemHandlers }, {
|
||||
default: () => [
|
||||
h(SelectItemText, null, { default: () => opt.label }),
|
||||
h(SelectItemIndicator, null, { default: () => '✓' }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
return track(w);
|
||||
}
|
||||
|
||||
function press(el: Element, key: string, init: KeyboardEventInit = {}) {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
|
||||
}
|
||||
|
||||
function getTrigger(): HTMLElement {
|
||||
return document.querySelector('[role="combobox"]') as HTMLElement;
|
||||
}
|
||||
|
||||
function getContent(): HTMLElement | null {
|
||||
return document.querySelector('[role="listbox"]');
|
||||
}
|
||||
|
||||
function getItems(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||
}
|
||||
|
||||
async function openByClick(trigger: HTMLElement) {
|
||||
trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerType: 'mouse' }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe('Select — ARIA skeleton', () => {
|
||||
it('renders trigger as role=combobox with aria-controls and aria-expanded', () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger).toBeTruthy();
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(trigger.getAttribute('aria-controls')).toBeTruthy();
|
||||
expect(trigger.getAttribute('aria-autocomplete')).toBe('none');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('shows placeholder + data-placeholder when empty', () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.getAttribute('data-placeholder')).toBe('');
|
||||
expect(trigger.textContent).toContain('Pick one');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('content carries role=listbox and matches trigger aria-controls when open', async () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
const content = getContent();
|
||||
expect(content).toBeTruthy();
|
||||
expect(content!.id).toBe(trigger.getAttribute('aria-controls'));
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('items expose role=option, aria-labelledby, aria-selected, data-state', async () => {
|
||||
const w = createSelect({ defaultValue: 'banana' });
|
||||
await openByClick(getTrigger());
|
||||
const items = getItems();
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]!.getAttribute('aria-labelledby')).toBeTruthy();
|
||||
const banana = items.find(i => i.textContent?.includes('Banana'))!;
|
||||
expect(banana.getAttribute('aria-selected')).toBe('true');
|
||||
expect(banana.getAttribute('data-state')).toBe('checked');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — initial label without opening', () => {
|
||||
it('SelectValue shows the selected label before the dropdown is ever opened', async () => {
|
||||
const w = createSelect({ defaultValue: 'cherry' });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const trigger = getTrigger();
|
||||
expect(trigger.textContent).toContain('Cherry');
|
||||
expect(trigger.getAttribute('data-placeholder')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — selection (controlled + uncontrolled)', () => {
|
||||
it('clicking an item selects its value and closes (single)', async () => {
|
||||
createSelect();
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
const banana = getItems().find(i => i.textContent?.includes('Banana'))!;
|
||||
banana.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(valueRef.value).toBe('banana');
|
||||
expect(getContent()).toBeNull();
|
||||
expect(getTrigger().textContent).toContain('Banana');
|
||||
});
|
||||
|
||||
it('uncontrolled defaultValue drives initial selection', async () => {
|
||||
const w = createSelect({ defaultValue: 'apple', modelValue: undefined });
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).toContain('Apple');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — keyboard', () => {
|
||||
it('opens on ArrowDown / Enter / Space', async () => {
|
||||
const w = createSelect();
|
||||
const trigger = getTrigger();
|
||||
press(trigger, 'ArrowDown');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getContent()).toBeTruthy();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('Enter on an item selects it', async () => {
|
||||
const w = createSelect();
|
||||
await openByClick(getTrigger());
|
||||
const cherry = getItems().find(i => i.textContent?.includes('Cherry'))!;
|
||||
press(cherry, 'Enter');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).toContain('Cherry');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — disabled', () => {
|
||||
it('root disabled prevents opening', async () => {
|
||||
const w = createSelect({ disabled: true });
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
expect(getContent()).toBeNull();
|
||||
expect(trigger.getAttribute('data-disabled')).toBe('');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('disabled item does not select on click', async () => {
|
||||
const w = createSelect({}, [
|
||||
{ value: 'a', label: 'A' },
|
||||
{ value: 'b', label: 'B', disabled: true },
|
||||
]);
|
||||
await openByClick(getTrigger());
|
||||
const b = getItems().find(i => i.textContent?.includes('B'))!;
|
||||
expect(b.getAttribute('data-disabled')).toBe('');
|
||||
b.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).not.toContain('B');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — multiple', () => {
|
||||
it('toggles array membership and stays open', async () => {
|
||||
createSelect({ multiple: true, modelValue: undefined, defaultValue: [] as string[] });
|
||||
const trigger = getTrigger();
|
||||
await openByClick(trigger);
|
||||
getItems().find(i => i.textContent?.includes('Apple'))!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(getContent()).toBeTruthy(); // stays open
|
||||
getItems().find(i => i.textContent?.includes('Cherry'))!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(valueRef.value).toEqual(['apple', 'cherry']);
|
||||
});
|
||||
|
||||
it('reselecting a value removes it from the array', async () => {
|
||||
createSelect({ multiple: true, modelValue: undefined, defaultValue: ['apple', 'banana'] as string[] });
|
||||
await openByClick(getTrigger());
|
||||
getItems().find(i => i.textContent?.includes('Apple'))!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
expect(valueRef.value).toEqual(['banana']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — object values with `by`', () => {
|
||||
it('compares object values by key', async () => {
|
||||
const objs: Opt[] = [
|
||||
{ value: { id: 1 }, label: 'One' },
|
||||
{ value: { id: 2 }, label: 'Two' },
|
||||
];
|
||||
const w = createSelect({ by: 'id', defaultValue: { id: 2 } }, objs);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getTrigger().textContent).toContain('Two');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — select event is cancelable', () => {
|
||||
it('preventDefault on the select event blocks the value change', async () => {
|
||||
const onSelect = vi.fn((e: SelectItemSelectEvent) => e.preventDefault());
|
||||
const w = createSelect({}, undefined, { onSelect });
|
||||
await openByClick(getTrigger());
|
||||
const apple = getItems().find(i => i.textContent?.includes('Apple'))!;
|
||||
apple.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(getTrigger().textContent).not.toContain('Apple');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — empty-string value guard', () => {
|
||||
it('throws if an item has an empty-string value', async () => {
|
||||
const errors: unknown[] = [];
|
||||
const original = globalThis.onerror;
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
errors.push(e.reason);
|
||||
e.preventDefault();
|
||||
};
|
||||
globalThis.addEventListener('unhandledrejection', onRejection);
|
||||
globalThis.onerror = (_m, _s, _l, _c, err) => {
|
||||
errors.push(err);
|
||||
return true;
|
||||
};
|
||||
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
errorCaptured(err) {
|
||||
errors.push(err);
|
||||
return false;
|
||||
},
|
||||
setup() {
|
||||
return () => h(SelectRoot, { defaultOpen: true }, {
|
||||
default: () => h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, null, {
|
||||
default: () => h(SelectViewport, null, {
|
||||
default: () => h(SelectItem, { value: '' }, {
|
||||
default: () => h(SelectItemText, null, { default: () => 'Empty' }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
track(w);
|
||||
await flush();
|
||||
globalThis.removeEventListener('unhandledrejection', onRejection);
|
||||
globalThis.onerror = original;
|
||||
|
||||
expect(errors.some(e => (e as Error)?.message?.includes('empty string'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — typeahead on closed trigger', () => {
|
||||
it('typing selects a matching option without opening', async () => {
|
||||
const w = createSelect();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const trigger = getTrigger();
|
||||
press(trigger, 'c');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(getContent()).toBeNull();
|
||||
expect(getTrigger().textContent).toContain('Cherry');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select — native form submission', () => {
|
||||
it('renders a hidden native select inside a form with options', async () => {
|
||||
const w = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const value = ref('banana');
|
||||
return () => h('form', null, [
|
||||
h(
|
||||
SelectRoot,
|
||||
{ name: 'fruit', modelValue: value.value, 'onUpdate:modelValue': (v: never) => (value.value = v) },
|
||||
{
|
||||
default: () => [
|
||||
h(SelectTrigger, null, { default: () => h(SelectValue) }),
|
||||
h(SelectPortal, null, {
|
||||
default: () => h(SelectContent, null, {
|
||||
default: () => h(SelectViewport, null, {
|
||||
default: () => [
|
||||
h(SelectItem, { value: 'apple' }, { default: () => h(SelectItemText, null, { default: () => 'Apple' }) }),
|
||||
h(SelectItem, { value: 'banana' }, { default: () => h(SelectItemText, null, { default: () => 'Banana' }) }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const nativeSelect = document.querySelector('form select[name="fruit"]') as HTMLSelectElement | null;
|
||||
expect(nativeSelect).toBeTruthy();
|
||||
expect(nativeSelect!.querySelectorAll('option').length).toBeGreaterThanOrEqual(2);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.7 KiB |