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,59 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Visually hides its content while keeping it available to assistive
* technology. The element is removed from the visual layout but stays in the
* accessibility tree (and remains focusable) so screen readers can still
* announce it. Use it for accessible labels, status text, or skip links that
* should be heard but not seen — for example a hidden heading, an icon-only
* button's name, or extra context for a control.
*/
export interface VisuallyHiddenProps extends PrimitiveProps {
/**
* How the content participates: `'focusable'` keeps it in the accessibility
* tree and focusable (visually hidden only — e.g. skip links); `'hidden'`
* additionally hides it from layout and focus.
* @default 'focusable'
*/
feature?: 'focusable' | 'hidden';
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', feature = 'focusable' } = defineProps<VisuallyHiddenProps>();
const { forwardRef } = useForwardExpose();
const style = {
position: 'absolute',
top: '-1px',
left: '-1px',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
wordWrap: 'normal',
border: '0',
} as const;
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:style="style"
:tabindex="feature === 'hidden' ? -1 : undefined"
:aria-hidden="feature === 'hidden' ? true : undefined"
:data-visually-hidden="feature"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,95 @@
<script lang="ts">
import type { VisuallyHiddenInputBubbleProps } from './VisuallyHiddenInputBubble.vue';
/**
* Bridges a custom control's value into native form submission. It serializes
* the bound `value` into one visually-hidden native `<input>` per leaf so the
* data is submitted with the owning `<form>` and participates in native
* constraint validation:
*
* - primitives (`string | number | boolean | null | undefined`) → a single
* input named `name`;
* - arrays of primitives → `name[index]`;
* - arrays of objects → `name[index][key]`;
* - plain objects → `name[key]`.
*
* A `required` field bound to an empty array still renders one input, so native
* `required` validation fires on empty multi-selects.
*/
export interface VisuallyHiddenInputProps<T = unknown> extends Omit<VisuallyHiddenInputBubbleProps<T>, 'value'> {
/** The value to serialize and submit. */
value: T;
}
</script>
<script setup lang="ts" generic="T = unknown">
import { computed } from 'vue';
import { isArray, isObject } from '@vue/shared';
import VisuallyHiddenInputBubble from './VisuallyHiddenInputBubble.vue';
const props = withDefaults(defineProps<VisuallyHiddenInputProps<T>>(), {
feature: 'hidden',
checked: undefined,
});
defineOptions({ inheritAttrs: false });
// Keep a single input for a `required` empty multi-select so the browser's
// native validation still blocks submission.
const requiresEmptyArrayInput = computed(() =>
isArray(props.value) && props.value.length === 0 && props.required);
interface SerializedLeaf {
name: string;
value: unknown;
}
const leaves = computed<SerializedLeaf[]>(() => {
const value = props.value;
const name = props.name;
// Primitive (or nullish) value → one input.
if (!isObject(value))
return [{ name, value }];
// Array value → `name[index]` for primitives, `name[index][key]` for objects.
if (isArray(value)) {
return value.flatMap((item, index) => {
if (isObject(item) && !isArray(item))
return Object.entries(item).map(([key, v]) => ({ name: `${name}[${index}][${key}]`, value: v }));
return { name: `${name}[${index}]`, value: item };
});
}
// Plain object value → `name[key]`.
return Object.entries(value).map(([key, v]) => ({ name: `${name}[${key}]`, value: v }));
});
</script>
<template>
<VisuallyHiddenInputBubble
v-if="requiresEmptyArrayInput"
:key="name"
v-bind="$attrs"
:name="name"
:value="value"
:checked="checked"
:required="required"
:disabled="disabled"
:feature="feature"
/>
<VisuallyHiddenInputBubble
v-for="leaf in leaves"
v-else
:key="leaf.name"
v-bind="$attrs"
:name="leaf.name"
:value="leaf.value"
:checked="checked"
:required="required"
:disabled="disabled"
:feature="feature"
/>
</template>
@@ -0,0 +1,115 @@
<script lang="ts">
import type { VisuallyHiddenProps } from './VisuallyHidden.vue';
/**
* A single native, visually-hidden `<input>` that mirrors a custom control's
* value into native form submission. It keeps the input out of the visual
* layout (and the accessibility tree) while staying part of the owning
* `<form>`, so the value is submitted and native constraint validation
* (`required`) still fires.
*
* When `value`/`checked` change programmatically, it writes through the native
* `HTMLInputElement` property setter and dispatches bubbling `input` and
* `change` events, so third-party form libraries and listeners observe the
* change exactly as they would for direct user input.
*/
// Module-scope cache for the native `value`/`checked` property setters.
// Resolving `Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, ...)`
// is constant for the whole page lifetime, so we resolve once (lazily, behind
// the caller's `window` guard for SSR-safety) and reuse a stable monomorphic
// setter reference for every programmatic value/checked change.
type InputSetter = (this: HTMLInputElement, v: unknown) => void;
let valueSetter: InputSetter | undefined;
let checkedSetter: InputSetter | undefined;
let nativeSettersResolved = false;
function resolveNativeSetters(): void {
if (nativeSettersResolved) return;
nativeSettersResolved = true;
const proto = globalThis.HTMLInputElement.prototype;
valueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set as InputSetter | undefined;
checkedSetter = Object.getOwnPropertyDescriptor(proto, 'checked')?.set as InputSetter | undefined;
}
export interface VisuallyHiddenInputBubbleProps<T = unknown> {
/** Name submitted with the owning form. */
name: string;
/** Value submitted with the owning form. */
value: T;
/**
* Checked state for checkbox/radio-style submission. When provided it is the
* source of truth driven through the native `checked` setter; otherwise
* `value` is driven through the native `value` setter.
*/
checked?: boolean;
/** Mirror the `required` constraint so native validation fires. */
required?: boolean;
/** Mirror the `disabled` state so the field is excluded from submission. */
disabled?: boolean;
/**
* Visual-hiding strategy passed through to `VisuallyHidden`.
* @default 'hidden'
*/
feature?: VisuallyHiddenProps['feature'];
}
</script>
<script setup lang="ts" generic="T = unknown">
import { computed, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import VisuallyHidden from './VisuallyHidden.vue';
const props = withDefaults(defineProps<VisuallyHiddenInputBubbleProps<T>>(), {
feature: 'hidden',
checked: undefined,
});
defineOptions({ inheritAttrs: false });
const { forwardRef, currentElement } = useForwardExpose();
// `checked` (when provided) drives a checkbox-style input via the native
// `checked` setter; otherwise `value` drives a text-style input via `value`.
const isCheckbox = computed(() => props.checked !== undefined);
// Single reactive source describing what the native input should reflect.
const driven = computed(() => (isCheckbox.value ? props.checked : props.value));
watch(
driven,
(next, prev) => syncNativeInput(next, prev),
{ flush: 'post' },
);
function syncNativeInput(next: unknown, prev: unknown): void {
if (next === prev) return;
const input = currentElement.value as HTMLInputElement | undefined;
if (!input || globalThis.window === undefined) return;
// Write through the native property setter so frameworks that monkey-patch
// the input's value/checked tracker (e.g. synthetic event systems) observe
// the programmatic change, then emit the events a real edit would produce.
resolveNativeSetters();
const setter = isCheckbox.value ? checkedSetter : valueSetter;
setter?.call(input, next);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
</script>
<template>
<VisuallyHidden
:ref="forwardRef"
as="input"
:type="isCheckbox ? 'checkbox' : 'text'"
:name="name"
:value="value"
:checked="checked"
:required="required"
:disabled="disabled"
:feature="feature"
v-bind="$attrs"
/>
</template>
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import VisuallyHidden from '../VisuallyHidden.vue';
describe('VisuallyHidden', () => {
it('renders a span with sr-only style by default', () => {
const w = mount(VisuallyHidden, { slots: { default: 'Screen reader only' } });
const el = w.element as HTMLElement;
expect(el.tagName).toBe('SPAN');
expect(el.style.position).toBe('absolute');
expect(el.style.width).toBe('1px');
expect(el.style.height).toBe('1px');
expect(el.style.overflow).toBe('hidden');
expect(w.text()).toBe('Screen reader only');
});
it('does not set aria-hidden by default (content is announced)', () => {
const w = mount(VisuallyHidden, { slots: { default: 'x' } });
expect(w.element.getAttribute('aria-hidden')).toBeNull();
});
it('sets aria-hidden when feature="hidden"', () => {
const w = mount(VisuallyHidden, {
props: { feature: 'hidden' },
slots: { default: 'x' },
});
expect(w.element.getAttribute('aria-hidden')).toBe('true');
});
it('exposes a data attribute describing the feature', () => {
const w = mount(VisuallyHidden, { slots: { default: 'x' } });
expect(w.element.getAttribute('data-visually-hidden')).toBe('focusable');
});
});
@@ -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);
});
});
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { VisuallyHidden } from '@robonen/primitives';
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<div class="demo-card flex w-full max-w-sm flex-col gap-5 p-5 text-fg">
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold">
Icon-only buttons
</h3>
<p class="text-sm text-fg-muted">
Each button shows only an icon, but exposes a real accessible name to screen readers.
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex size-9 items-center justify-center rounded-md border border-border bg-bg-subtle text-fg transition-colors hover:bg-bg-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="count -= 1"
>
<span
aria-hidden="true"
class="text-base leading-none"
>&minus;</span>
<VisuallyHidden>Decrease quantity</VisuallyHidden>
</button>
<span class="min-w-8 text-center text-sm font-medium tabular-nums">
{{ count }}
</span>
<button
type="button"
class="inline-flex size-9 items-center justify-center rounded-md border border-border bg-bg-subtle text-fg transition-colors hover:bg-bg-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@click="count += 1"
>
<span
aria-hidden="true"
class="text-base leading-none"
>+</span>
<VisuallyHidden>Increase quantity</VisuallyHidden>
</button>
</div>
<p class="text-sm text-fg-subtle">
Quantity is now
<span class="font-medium text-fg">{{ count }}</span>.
<VisuallyHidden
as="span"
aria-live="polite"
>
Quantity changed to {{ count }}.
</VisuallyHidden>
</p>
</div>
</template>
@@ -0,0 +1,6 @@
export { default as VisuallyHidden } from './VisuallyHidden.vue';
export type { VisuallyHiddenProps } from './VisuallyHidden.vue';
export { default as VisuallyHiddenInput } from './VisuallyHiddenInput.vue';
export type { VisuallyHiddenInputProps } from './VisuallyHiddenInput.vue';
export { default as VisuallyHiddenInputBubble } from './VisuallyHiddenInputBubble.vue';
export type { VisuallyHiddenInputBubbleProps } from './VisuallyHiddenInputBubble.vue';