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,41 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The visual fill of the progress bar, rendered inside `ProgressRoot`. It reads
* the value, max, and state from context and exposes them via `data-state`,
* `data-value`, and `data-max` (plus matching slot props) so you can size and
* style the fill — e.g. translating it by the completion percentage.
*/
export interface ProgressIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useProgressContext } from './context';
const { as = 'div' } = defineProps<ProgressIndicatorProps>();
const { forwardRef } = useForwardExpose();
const ctx = useProgressContext();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="ctx.state.value"
:data-value="ctx.value.value ?? undefined"
:data-max="ctx.max.value"
>
<slot
:value="ctx.value.value"
:max="ctx.max.value"
:state="ctx.state.value"
:progress="ctx.progress.value"
:percentage="ctx.percentage.value"
/>
</Primitive>
</template>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { ProgressState } from './context';
/**
* A bar that shows the completion progress of a task, typically a horizontal
* fill that grows from empty to full. Use it for file uploads, multi-step form
* progress, loading indicators, or any operation whose progress you can measure
* (or, with a `null` value, signal as indeterminate).
*
* The root renders the accessible `progressbar` (wiring up `aria-valuemin`,
* `aria-valuemax`, `aria-valuenow`, `aria-valuetext`, and an `aria-label`
* accessible name) and derives the current `state` — `indeterminate`,
* `loading`, or `complete` — which it provides via context and exposes on the
* `data-state` attribute. Pair it with `ProgressIndicator` for the visual fill.
*
* Both `modelValue` and `max` are two-way (`v-model` / `v-model:max`): bad
* inputs (`NaN`, negatives, out-of-range, `max <= 0`) are validated, clamped,
* and reported in development, so the rendered ARIA is always valid.
*/
export interface ProgressRootProps extends PrimitiveProps {
/** Current value. `null` denotes an indeterminate progress bar. Two-way via `v-model`. */
modelValue?: number | null;
/** Maximum value. Two-way via `v-model:max`. @default 100 */
max?: number;
/**
* Builds the `aria-valuetext` describing the current value in a human-readable
* form. Receives the resolved value (`null` when indeterminate) and max.
* @default `(v, m) => v == null ? undefined : `${Math.round((v / m) * 100)}%``
*/
getValueLabel?: (value: number | null, max: number) => string | undefined;
/**
* Accessible name for the progressbar, rendered as `aria-label`. Accepts a
* static string or a function of the resolved value and max. Provide this (or
* an external `aria-labelledby`) so screen readers announce a meaningful name.
*/
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
}
export interface ProgressRootEmits {
/** Emitted when the value changes (after validation/clamping). */
'update:modelValue': [value: number | null];
/** Emitted when the max changes (after validation). */
'update:max': [value: number];
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, ref } from 'vue';
import { provideProgressContext } from './context';
import { isFiniteNumber, resolveMax, resolveValue } from './utils';
import { useForwardExpose } from '@robonen/vue';
const {
max: maxProp,
getValueLabel = (v: number | null, m: number) => v === null ? undefined : `${Math.round((v / m) * 100)}%`,
accessibleLabel,
as = 'div',
} = defineProps<ProgressRootProps>();
defineEmits<ProgressRootEmits>();
const { forwardRef } = useForwardExpose();
const localValue = ref<number | null>(null);
const localMax = ref<number>(resolveMax(maxProp));
const max = defineModel<number>('max', {
get: external => resolveMax(external ?? localMax.value),
set: (value) => {
const next = resolveMax(value);
localMax.value = next;
return next;
},
});
const value = defineModel<number | null>({
get: external => resolveValue(external === undefined ? localValue.value : external, max.value),
set: (raw) => {
const next = resolveValue(raw, max.value);
localValue.value = next;
return next;
},
});
const state = computed<ProgressState>(() => {
const v = value.value;
if (v === null) return 'indeterminate';
if (v >= max.value) return 'complete';
return 'loading';
});
const progress = computed<number | null>(() => value.value === null ? null : value.value / max.value);
const percentage = computed<number | null>(() => progress.value === null ? null : Math.round(progress.value * 100));
const valueText = computed(() => getValueLabel(value.value, max.value));
const ariaLabel = computed(() => typeof accessibleLabel === 'function'
? accessibleLabel(value.value, max.value)
: accessibleLabel);
provideProgressContext({
value,
max,
state,
progress,
percentage,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="progressbar"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuenow="isFiniteNumber(value) ? value : undefined"
:aria-valuetext="valueText"
:aria-label="ariaLabel"
:data-state="state"
:data-value="value ?? undefined"
:data-max="max"
>
<slot
:value="value"
:max="max"
:state="state"
:progress="progress"
:percentage="percentage"
/>
</Primitive>
</template>
@@ -0,0 +1,277 @@
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { ProgressIndicator, ProgressRoot } from '../index';
function mountProgress(props: Record<string, unknown> = {}, slot?: (scope: Record<string, unknown>) => unknown) {
return mount(defineComponent({
setup: () => () => h(ProgressRoot, props, {
default: (scope: Record<string, unknown>) =>
slot ? slot(scope) : h(ProgressIndicator, { class: 'ind' }),
}),
}), { attachTo: document.body });
}
describe('Progress', () => {
it('has role="progressbar" with aria attributes', () => {
const wrapper = mountProgress({ modelValue: 40 });
expect(wrapper.attributes('role')).toBe('progressbar');
expect(wrapper.attributes('aria-valuemin')).toBe('0');
expect(wrapper.attributes('aria-valuemax')).toBe('100');
expect(wrapper.attributes('aria-valuenow')).toBe('40');
expect(wrapper.attributes('aria-valuetext')).toBe('40%');
wrapper.unmount();
});
it('is indeterminate when value is null', () => {
const wrapper = mountProgress({ modelValue: null });
expect(wrapper.attributes('data-state')).toBe('indeterminate');
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
wrapper.unmount();
});
it('is complete when value reaches max', () => {
const wrapper = mountProgress({ modelValue: 100 });
expect(wrapper.attributes('data-state')).toBe('complete');
wrapper.unmount();
});
it('custom max', () => {
const wrapper = mountProgress({ modelValue: 5, max: 10 });
expect(wrapper.attributes('aria-valuemax')).toBe('10');
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
wrapper.unmount();
});
it('indicator receives matching data-state', () => {
const wrapper = mountProgress({ modelValue: 70 });
const ind = wrapper.find('.ind');
expect(ind.attributes('data-state')).toBe('loading');
expect(ind.attributes('data-value')).toBe('70');
wrapper.unmount();
});
it('getValueLabel override', () => {
const wrapper = mountProgress({ modelValue: 3, max: 10, getValueLabel: (v: number | null, m: number) => `${v} of ${m}` });
expect(wrapper.attributes('aria-valuetext')).toBe('3 of 10');
wrapper.unmount();
});
// ---- accessible name (aria-label) ----
describe('accessible name', () => {
it('renders no aria-label by default', () => {
const wrapper = mountProgress({ modelValue: 40 });
expect(wrapper.attributes('aria-label')).toBeUndefined();
wrapper.unmount();
});
it('accepts a static string accessibleLabel as aria-label', () => {
const wrapper = mountProgress({ modelValue: 40, accessibleLabel: 'Upload progress' });
expect(wrapper.attributes('aria-label')).toBe('Upload progress');
// value text is independent
expect(wrapper.attributes('aria-valuetext')).toBe('40%');
wrapper.unmount();
});
it('accepts a function accessibleLabel that receives value/max', () => {
const wrapper = mountProgress({
modelValue: 30,
max: 60,
accessibleLabel: (v: number | null, m: number) => `${v}/${m} done`,
});
expect(wrapper.attributes('aria-label')).toBe('30/60 done');
wrapper.unmount();
});
});
// ---- input validation / clamping ----
describe('input validation', () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('clamps a value above max', () => {
const wrapper = mountProgress({ modelValue: 150, max: 100 });
expect(wrapper.attributes('aria-valuenow')).toBe('100');
expect(wrapper.attributes('data-state')).toBe('complete');
wrapper.unmount();
});
it('clamps a negative value to 0', () => {
const wrapper = mountProgress({ modelValue: -20 });
expect(wrapper.attributes('aria-valuenow')).toBe('0');
wrapper.unmount();
});
it('coerces NaN value to indeterminate and warns', () => {
const wrapper = mountProgress({ modelValue: Number.NaN });
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
expect(wrapper.attributes('data-state')).toBe('indeterminate');
expect(errorSpy).toHaveBeenCalled();
wrapper.unmount();
});
it('falls back to default max when max <= 0 and warns', () => {
const wrapper = mountProgress({ modelValue: 50, max: 0 });
expect(wrapper.attributes('aria-valuemax')).toBe('100');
// value text uses the corrected max — no Infinity%
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
expect(errorSpy).toHaveBeenCalled();
wrapper.unmount();
});
it('falls back to default max when max is NaN', () => {
const wrapper = mountProgress({ modelValue: 50, max: Number.NaN });
expect(wrapper.attributes('aria-valuemax')).toBe('100');
wrapper.unmount();
});
it('does not render NaN aria-valuenow for non-finite input', () => {
const wrapper = mountProgress({ modelValue: Number.POSITIVE_INFINITY });
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
wrapper.unmount();
});
});
// ---- controlled two-way v-model ----
describe('controlled v-model', () => {
it('updates DOM when bound value changes', async () => {
const value = ref<number | null>(20);
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: number | null) => { value.value = v; },
}, { default: () => h(ProgressIndicator, { class: 'ind' }) }),
}), { attachTo: document.body });
expect(wrapper.attributes('aria-valuenow')).toBe('20');
value.value = 80;
await nextTick();
expect(wrapper.attributes('aria-valuenow')).toBe('80');
wrapper.unmount();
});
it('clamps an out-of-range bound value on read without mutating the parent', async () => {
// One-way bind (no update listener): the bar displays a clamped value but
// never writes back to a source the parent did not opt into mutating.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const wrapper = mountProgress({ modelValue: 250, max: 100 });
expect(wrapper.attributes('aria-valuenow')).toBe('100');
expect(wrapper.attributes('data-state')).toBe('complete');
errorSpy.mockRestore();
wrapper.unmount();
});
it('declares the update:modelValue emit so v-model is a true two-way contract', async () => {
const value = ref<number | null>(40);
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, {
modelValue: value.value,
'onUpdate:modelValue': (v: number | null) => { value.value = v; },
}, { default: () => h(ProgressIndicator) }),
}), { attachTo: document.body });
expect(wrapper.attributes('aria-valuenow')).toBe('40');
// parent remains the source of truth and drives the bar
value.value = 90;
await nextTick();
expect(wrapper.attributes('aria-valuenow')).toBe('90');
wrapper.unmount();
});
});
// ---- uncontrolled mode ----
describe('uncontrolled mode', () => {
it('starts indeterminate when no modelValue is provided', () => {
const wrapper = mount(ProgressRoot, {
attachTo: document.body,
slots: { default: () => h(ProgressIndicator) },
});
expect(wrapper.attributes('data-state')).toBe('indeterminate');
expect(wrapper.attributes('aria-valuenow')).toBeUndefined();
wrapper.unmount();
});
});
// ---- two-way max ----
describe('two-way max', () => {
it('reacts to bound max changes', async () => {
const max = ref(100);
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, {
modelValue: 50,
max: max.value,
'onUpdate:max': (m: number) => { max.value = m; },
}, { default: () => h(ProgressIndicator) }),
}), { attachTo: document.body });
expect(wrapper.attributes('aria-valuetext')).toBe('50%');
max.value = 200;
await nextTick();
expect(wrapper.attributes('aria-valuemax')).toBe('200');
expect(wrapper.attributes('aria-valuetext')).toBe('25%');
wrapper.unmount();
});
});
// ---- slot scope ----
describe('slot scope', () => {
it('exposes progress and percentage on the root slot', () => {
const seen: Record<string, unknown> = {};
const wrapper = mountProgress({ modelValue: 25, max: 50 }, (scope) => {
Object.assign(seen, scope);
return h('span');
});
expect(seen.value).toBe(25);
expect(seen.max).toBe(50);
expect(seen.state).toBe('loading');
expect(seen.progress).toBe(0.5);
expect(seen.percentage).toBe(50);
wrapper.unmount();
});
it('exposes progress/percentage as null when indeterminate', () => {
const seen: Record<string, unknown> = {};
const wrapper = mountProgress({ modelValue: null }, (scope) => {
Object.assign(seen, scope);
return h('span');
});
expect(seen.progress).toBeNull();
expect(seen.percentage).toBeNull();
wrapper.unmount();
});
it('exposes progress/percentage on the indicator slot', () => {
const indSeen: Record<string, unknown> = {};
const wrapper = mount(defineComponent({
setup: () => () => h(ProgressRoot, { modelValue: 30, max: 60 }, {
default: () => h(ProgressIndicator, null, {
default: (scope: Record<string, unknown>) => {
Object.assign(indSeen, scope);
return h('span');
},
}),
}),
}), { attachTo: document.body });
expect(indSeen.progress).toBe(0.5);
expect(indSeen.percentage).toBe(50);
wrapper.unmount();
});
});
// ---- overshoot complete (robonen's preserved strength) ----
it('keeps complete state when value overshoots after clamp', () => {
const wrapper = mountProgress({ modelValue: 105, max: 100 });
// clamped to 100 -> still complete
expect(wrapper.attributes('data-state')).toBe('complete');
wrapper.unmount();
});
it('renders data-value/data-max on the root', () => {
const wrapper = mountProgress({ modelValue: 42, max: 84 });
expect(wrapper.attributes('data-value')).toBe('42');
expect(wrapper.attributes('data-max')).toBe('84');
wrapper.unmount();
});
});
@@ -0,0 +1,22 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type ProgressState = 'indeterminate' | 'loading' | 'complete';
export interface ProgressContext {
/** Resolved (validated/clamped) value; `null` when indeterminate. */
value: Ref<number | null>;
/** Resolved (validated) maximum. */
max: Ref<number>;
/** Derived progress state. */
state: Ref<ProgressState>;
/** Completion ratio in `[0, 1]`, or `null` when indeterminate. */
progress: ComputedRef<number | null>;
/** Completion percentage in `[0, 100]`, or `null` when indeterminate. */
percentage: ComputedRef<number | null>;
}
const ctx = useContextFactory<ProgressContext>('ProgressContext');
export const provideProgressContext = ctx.provide;
export const useProgressContext = ctx.inject;
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { ProgressIndicator, ProgressRoot } from '@robonen/primitives';
const value = ref(0);
let timer: ReturnType<typeof setInterval> | undefined;
function start() {
stop();
value.value = 0;
timer = setInterval(() => {
value.value = Math.min(100, value.value + Math.round(8 + Math.random() * 14));
if (value.value >= 100)
stop();
}, 600);
}
function stop() {
if (timer) {
clearInterval(timer);
timer = undefined;
}
}
onBeforeUnmount(stop);
</script>
<template>
<div class="demo-card w-full max-w-sm space-y-6 p-5 text-fg">
<!-- Determinate: a simulated upload -->
<div class="space-y-2">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Uploading build.zip</span>
<span class="font-mono text-fg-muted">{{ value }}%</span>
</div>
<ProgressRoot
v-slot="{ state }"
:model-value="value"
class="h-2 w-full overflow-hidden rounded-full bg-bg-inset"
>
<ProgressIndicator
class="h-full rounded-full transition-transform duration-500 ease-out"
:class="state === 'complete'
? 'bg-emerald-500 dark:bg-emerald-400'
: 'bg-accent'"
:style="{ transform: `translateX(-${100 - value}%)` }"
/>
</ProgressRoot>
<button
type="button"
class="rounded-md border border-border bg-bg px-3 py-1.5 text-sm text-fg transition hover:bg-bg-inset active:scale-95 cursor-pointer"
@click="start"
>
{{ value === 0 ? 'Start upload' : 'Restart' }}
</button>
</div>
<!-- Indeterminate: value is null -->
<div class="space-y-2">
<span class="text-sm font-medium">Syncing changes</span>
<ProgressRoot
:model-value="null"
class="h-2 w-full overflow-hidden rounded-full bg-bg-inset"
>
<ProgressIndicator class="h-full w-2/5 animate-pulse rounded-full bg-accent" />
</ProgressRoot>
</div>
</div>
</template>
@@ -0,0 +1,6 @@
export { default as ProgressRoot } from './ProgressRoot.vue';
export { default as ProgressIndicator } from './ProgressIndicator.vue';
export type { ProgressRootProps, ProgressRootEmits } from './ProgressRoot.vue';
export type { ProgressIndicatorProps } from './ProgressIndicator.vue';
export { provideProgressContext, useProgressContext } from './context';
export type { ProgressState, ProgressContext } from './context';
@@ -0,0 +1,54 @@
import { isNull, isNumber, isUndefined } from '@robonen/stdlib';
/** Fallback maximum used when `max` is omitted or invalid. */
export const DEFAULT_MAX = 100;
/** `null`/`undefined` mark an indeterminate progress bar. */
export function isNullish(value: unknown): value is null | undefined {
return isNull(value) || isUndefined(value);
}
/** A real, finite numeric value (filters `NaN`/`Infinity`). */
export function isFiniteNumber(value: unknown): value is number {
return isNumber(value) && Number.isFinite(value);
}
const VALUE_MESSAGE = (value: unknown, max: number): string =>
`Invalid \`modelValue\` of \`${String(value)}\` supplied to ProgressRoot. `
+ `The value must be a finite number between 0 and \`max\` (${max}), `
+ 'or `null`/`undefined` for an indeterminate bar. Defaulting to `null`.';
const MAX_MESSAGE = (max: unknown): string =>
`Invalid \`max\` of \`${String(max)}\` supplied to ProgressRoot. `
+ `Only finite numbers greater than 0 are valid. Defaulting to \`${DEFAULT_MAX}\`.`;
/**
* Resolve a usable `max`: a finite number greater than `0`, otherwise the
* default. Warns once in dev when an invalid value is supplied. Compiled out of
* production via the `__DEV__` global.
*/
export function resolveMax(max: number | undefined): number {
if (isUndefined(max)) return DEFAULT_MAX;
if (isFiniteNumber(max) && max > 0) return max;
if (__DEV__) console.error(MAX_MESSAGE(max));
return DEFAULT_MAX;
}
/**
* Resolve a usable value against an already-resolved `max`: pass through
* nullish (indeterminate) values, clamp finite numbers into `[0, max]`, and
* coerce anything else (`NaN`, non-numbers) to `null`. Warns once in dev when
* coercing an unusable value. Compiled out of production via `__DEV__`.
*/
export function resolveValue(value: number | null | undefined, max: number): number | null {
if (isNullish(value)) return null;
if (isFiniteNumber(value)) {
if (value < 0) return 0;
if (value > max) return max;
return value;
}
if (__DEV__) console.error(VALUE_MESSAGE(value, max));
return null;
}