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:
@@ -0,0 +1,226 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import VisuallyHiddenInput from '../VisuallyHiddenInput.vue';
|
||||
import VisuallyHiddenInputBubble from '../VisuallyHiddenInputBubble.vue';
|
||||
|
||||
function inputs(el: Element): HTMLInputElement[] {
|
||||
return Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
|
||||
}
|
||||
|
||||
describe('VisuallyHiddenInput', () => {
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
});
|
||||
|
||||
it('renders a single hidden input for a primitive value', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'color', value: 'red' },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]!.name).toBe('color');
|
||||
expect(all[0]!.value).toBe('red');
|
||||
});
|
||||
|
||||
it('hides the input from layout and the accessibility tree by default', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'color', value: 'red' },
|
||||
});
|
||||
|
||||
const input = inputs(wrapper.element.parentElement!)[0]!;
|
||||
expect(input.style.position).toBe('absolute');
|
||||
expect(input.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(input.getAttribute('tabindex')).toBe('-1');
|
||||
expect(input.getAttribute('data-visually-hidden')).toBe('hidden');
|
||||
});
|
||||
|
||||
it('serializes an array of primitives to name[index]', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: ['a', 'b', 'c'] },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual(['tags[0]', 'tags[1]', 'tags[2]']);
|
||||
expect(all.map(i => i.value)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('serializes an array of objects to name[index][key]', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'items', value: [{ id: 1, label: 'x' }, { id: 2, label: 'y' }] },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual([
|
||||
'items[0][id]',
|
||||
'items[0][label]',
|
||||
'items[1][id]',
|
||||
'items[1][label]',
|
||||
]);
|
||||
expect(all.map(i => i.value)).toEqual(['1', 'x', '2', 'y']);
|
||||
});
|
||||
|
||||
it('serializes a plain object to name[key]', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'coords', value: { x: 10, y: 20 } },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual(['coords[x]', 'coords[y]']);
|
||||
expect(all.map(i => i.value)).toEqual(['10', '20']);
|
||||
});
|
||||
|
||||
it('renders nothing for an empty, non-required array', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: [] },
|
||||
});
|
||||
|
||||
expect(inputs(wrapper.element.parentElement!)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders one required input for an empty required array (native validation fires)', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: [], required: true },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]!.name).toBe('tags');
|
||||
expect(all[0]!.required).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards required and disabled to every leaf input', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: ['a', 'b'], required: true, disabled: true },
|
||||
});
|
||||
|
||||
for (const input of inputs(wrapper.element.parentElement!)) {
|
||||
expect(input.required).toBe(true);
|
||||
expect(input.disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates the rendered inputs when the array value changes', async () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: ['a'] },
|
||||
});
|
||||
|
||||
expect(inputs(wrapper.element.parentElement!)).toHaveLength(1);
|
||||
|
||||
await wrapper.setProps({ value: ['a', 'b'] });
|
||||
await nextTick();
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual(['tags[0]', 'tags[1]']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VisuallyHiddenInputBubble', () => {
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
});
|
||||
|
||||
it('renders a hidden text input mirroring the value', () => {
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'q', value: 'hello' },
|
||||
});
|
||||
|
||||
const input = wrapper.element as HTMLInputElement;
|
||||
expect(input.tagName).toBe('INPUT');
|
||||
expect(input.type).toBe('text');
|
||||
expect(input.name).toBe('q');
|
||||
expect(input.value).toBe('hello');
|
||||
});
|
||||
|
||||
it('renders a checkbox-style input when checked is provided', () => {
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'agree', value: 'on', checked: true },
|
||||
});
|
||||
|
||||
const input = wrapper.element as HTMLInputElement;
|
||||
expect(input.type).toBe('checkbox');
|
||||
expect(input.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('dispatches bubbling input and change events when the value changes programmatically', async () => {
|
||||
const onInput = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
document.body.addEventListener('input', onInput);
|
||||
document.body.addEventListener('change', onChange);
|
||||
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'q', value: 'a' },
|
||||
});
|
||||
|
||||
onInput.mockClear();
|
||||
onChange.mockClear();
|
||||
|
||||
await wrapper.setProps({ value: 'b' });
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.element as HTMLInputElement).value).toBe('b');
|
||||
expect(onInput).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeEventListener('input', onInput);
|
||||
document.body.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
it('dispatches change events when the checked state changes programmatically', async () => {
|
||||
const onChange = vi.fn();
|
||||
document.body.addEventListener('change', onChange);
|
||||
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'agree', value: 'on', checked: false },
|
||||
});
|
||||
|
||||
onChange.mockClear();
|
||||
|
||||
await wrapper.setProps({ checked: true });
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.element as HTMLInputElement).checked).toBe(true);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
it('does not dispatch when the value is unchanged', async () => {
|
||||
const onChange = vi.fn();
|
||||
document.body.addEventListener('change', onChange);
|
||||
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'q', value: 'a' },
|
||||
});
|
||||
|
||||
onChange.mockClear();
|
||||
|
||||
await wrapper.setProps({ value: 'a' });
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeEventListener('change', onChange);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user