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,66 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that decreases the value by one `step`. Rendered as a `<button>` by
* default, kept out of the tab order (the input is the focusable spinbutton) but
* exposed to assistive tech with an `aria-label`. Holding the button auto-repeats
* the decrement, and it is disabled when the root is `disabled`/`readonly`, when
* its own `disabled` prop is set, or when the value is already at `min`.
*/
export interface NumberFieldDecrementProps extends PrimitiveProps {
/** Disable this button independently of the root. */
disabled?: boolean;
/**
* Accessible label for assistive tech. Bind via `aria-label` (Vue maps the
* kebab-case attribute to this prop).
* @default 'Decrease'
*/
ariaLabel?: string;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useNumberFieldContext } from './context';
import { usePressedHold } from './utils';
const { as = 'button', disabled = false, ariaLabel = 'Decrease' } = defineProps<NumberFieldDecrementProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useNumberFieldContext();
const isDisabled = computed(() =>
ctx.disabled.value || ctx.readonly.value || disabled || ctx.isDecrementDisabled.value);
const { isPressed, onTrigger, consumeClick } = usePressedHold({ target: currentElement, disabled: isDisabled });
onTrigger(() => ctx.decrement());
function onClick(): void {
// Pointer presses already fired via `onTrigger`; only handle clicks with no
// preceding pointer press (programmatic `.click()`, keyboard activation).
if (consumeClick() || isDisabled.value)
return;
ctx.decrement();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
:aria-label="ariaLabel"
:style="{ userSelect: isPressed ? 'none' : undefined }"
:disabled="isDisabled || undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-pressed="isPressed ? 'true' : undefined"
@click="onClick"
@contextmenu.prevent
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,66 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that increases the value by one `step`. Rendered as a `<button>` by
* default, kept out of the tab order (the input is the focusable spinbutton) but
* exposed to assistive tech with an `aria-label`. Holding the button auto-repeats
* the increment, and it is disabled when the root is `disabled`/`readonly`, when
* its own `disabled` prop is set, or when the value is already at `max`.
*/
export interface NumberFieldIncrementProps extends PrimitiveProps {
/** Disable this button independently of the root. */
disabled?: boolean;
/**
* Accessible label for assistive tech. Bind via `aria-label` (Vue maps the
* kebab-case attribute to this prop).
* @default 'Increase'
*/
ariaLabel?: string;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useNumberFieldContext } from './context';
import { usePressedHold } from './utils';
const { as = 'button', disabled = false, ariaLabel = 'Increase' } = defineProps<NumberFieldIncrementProps>();
const { forwardRef, currentElement } = useForwardExpose();
const ctx = useNumberFieldContext();
const isDisabled = computed(() =>
ctx.disabled.value || ctx.readonly.value || disabled || ctx.isIncrementDisabled.value);
const { isPressed, onTrigger, consumeClick } = usePressedHold({ target: currentElement, disabled: isDisabled });
onTrigger(() => ctx.increment());
function onClick(): void {
// Pointer presses already fired via `onTrigger`; only handle clicks with no
// preceding pointer press (programmatic `.click()`, keyboard activation).
if (consumeClick() || isDisabled.value)
return;
ctx.increment();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
:aria-label="ariaLabel"
:style="{ userSelect: isPressed ? 'none' : undefined }"
:disabled="isDisabled || undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-pressed="isPressed ? 'true' : undefined"
@click="onClick"
@contextmenu.prevent
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,154 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The text field that displays and edits the value, rendered as a native
* `<input role="spinbutton">` wired to the root context. It parses typed input,
* mirrors the current value via `aria-valuenow`/`aria-valuemin`/`aria-valuemax`,
* and handles Arrow/Page/Home/End keys to step, jump, or clamp to the bounds.
* Mouse-wheel scrolling steps the value while focused, keystrokes that would
* produce an invalid number are rejected, and the value is re-clamped, snapped,
* and reformatted on blur or Enter.
*/
export interface NumberFieldInputProps extends PrimitiveProps {
placeholder?: string;
name?: string;
required?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, onMounted, ref, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { getActiveElement } from '@robonen/platform/browsers';
import { useNumberFieldContext } from './context';
const { as = 'input', placeholder, name, required } = defineProps<NumberFieldInputProps>();
const ctx = useNumberFieldContext();
const { forwardRef, currentElement } = useForwardExpose();
// Local mirror of the displayed text so in-progress edits (e.g. a trailing
// decimal separator) survive until the value is committed/reformatted.
const inputValue = ref(ctx.textValue.value);
watch(() => ctx.textValue.value, (v) => {
inputValue.value = v;
});
const valueNow = computed(() => ctx.value.value ?? undefined);
onMounted(() => {
ctx.onInputElement(currentElement.value as HTMLInputElement | undefined);
});
function onInput(event: Event): void {
const target = event.target as HTMLInputElement;
inputValue.value = target.value;
// Live update: empty clears to null, unparseable also clears to null (the
// value is re-clamped/snapped/reformatted only on commit via `applyInputValue`).
const parsed = ctx.parseInput(target.value);
ctx.setValue(parsed);
}
function onBeforeInput(event: InputEvent): void {
const target = event.target as HTMLInputElement;
const next
= target.value.slice(0, target.selectionStart ?? undefined)
+ (event.data ?? '')
+ target.value.slice(target.selectionEnd ?? undefined);
if (!ctx.validate(next))
event.preventDefault();
}
function commit(event: Event): void {
ctx.applyInputValue((event.target as HTMLInputElement).value);
}
function onWheel(event: WheelEvent): void {
if (ctx.disableWheelChange.value || ctx.disabled.value || ctx.readonly.value)
return;
if (event.target !== getActiveElement())
return;
// Trackpads emit simultaneous X/Y; ignore mostly-horizontal scrolls.
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX))
return;
event.preventDefault();
const goingDown = event.deltaY > 0;
const decrease = goingDown !== ctx.invertWheelChange.value;
if (decrease)
ctx.decrement();
else
ctx.increment();
}
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value || ctx.readonly.value) return;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
ctx.increment();
break;
case 'ArrowDown':
event.preventDefault();
ctx.decrement();
break;
case 'PageUp':
event.preventDefault();
ctx.increment(ctx.step.value * 10);
break;
case 'PageDown':
event.preventDefault();
ctx.decrement(ctx.step.value * 10);
break;
case 'Home':
if (ctx.min.value !== undefined) {
event.preventDefault();
ctx.setValue(ctx.min.value);
}
break;
case 'End':
if (ctx.max.value !== undefined) {
event.preventDefault();
ctx.setValue(ctx.max.value);
}
break;
case 'Enter':
ctx.applyInputValue((event.target as HTMLInputElement).value);
break;
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="ctx.inputId"
role="spinbutton"
type="text"
tabindex="0"
:inputmode="ctx.inputMode.value"
autocomplete="off"
autocorrect="off"
spellcheck="false"
aria-roledescription="Number field"
:aria-valuemin="ctx.min.value"
:aria-valuemax="ctx.max.value"
:aria-valuenow="valueNow"
:aria-disabled="ctx.disabled.value || undefined"
:aria-readonly="ctx.readonly.value || undefined"
:disabled="ctx.disabled.value || undefined"
:readonly="ctx.readonly.value || undefined"
:placeholder="placeholder"
:name="name"
:required="required || undefined"
:value="inputValue"
@beforeinput="onBeforeInput"
@input="onInput"
@keydown="onKeyDown"
@wheel="onWheel"
@change="commit"
@blur="commit"
/>
</template>
@@ -0,0 +1,245 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A numeric input with stepper controls, keyboard increment/decrement, and
* optional clamping. The interactive root: it owns the value (controlled via
* `v-model` / `update:modelValue` or uncontrolled via `defaultValue`), clamps
* to `min`/`max`, snaps to `step`, formats with the active locale, and provides
* context to `NumberFieldInput`, `NumberFieldIncrement`, and
* `NumberFieldDecrement`. Use it whenever you need a styled number entry with
* spinner buttons and arrow-key support.
*/
export interface NumberFieldRootProps extends PrimitiveProps {
defaultValue?: number | null;
min?: number;
max?: number;
step?: number;
/** When `false`, values are clamped but not snapped to the nearest step. */
stepSnapping?: boolean;
disabled?: boolean;
readonly?: boolean;
/** Native input name; submits the value with the surrounding `<form>`. */
name?: string;
/** Mark the field required so native form validation fires on empty submit. */
required?: boolean;
/** `Intl.NumberFormat` options controlling display and the allowed characters. */
formatOptions?: Intl.NumberFormatOptions;
/** Locale override for formatting/parsing; falls back to the app `ConfigProvider`. */
locale?: string;
/** When `false`, wheel scrolling over the input does not change the value. */
disableWheelChange?: boolean;
/** Invert the direction of wheel-driven stepping. */
invertWheelChange?: boolean;
/** When `true` (default), stepper buttons return focus to the input. */
focusOnChange?: boolean;
}
export interface NumberFieldRootEmits {
valueChange: [value: number | null];
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { provideNumberFieldContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { clamp } from '@robonen/stdlib';
import { useId, useLocale } from '../../utilities/config-provider';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
import { createNumberFormat, handleDecimalOperation, snapValueToStep } from './utils';
const {
step = 1,
stepSnapping = true,
disabled = false,
readonly = false,
focusOnChange = true,
disableWheelChange = false,
invertWheelChange = false,
min,
max,
name,
required,
formatOptions,
locale: localeProp,
defaultValue,
as = 'div',
} = defineProps<NumberFieldRootProps>();
const emit = defineEmits<NumberFieldRootEmits>();
// `defineModel` drives both controlled (`v-model`) and uncontrolled modes; in
// uncontrolled mode `model.value` is `undefined` until first write, so the
// internal `localValue` below seeds from `defaultValue` and stays the live
// source of truth (synchronous multi-step updates can't wait on a prop re-flow).
const model = defineModel<number | null>();
const localValue = ref<number | null>(
model.value !== undefined ? model.value : (defaultValue ?? null),
);
watch(model, (v) => {
if (v === undefined) return;
if (v === localValue.value) return;
localValue.value = v;
});
const locale = useLocale(() => localeProp);
const numberFormat = createNumberFormat(locale, () => formatOptions);
const inputEl = shallowRef<HTMLInputElement>();
function clampInput(v: number): number {
if (stepSnapping && Number.isFinite(step))
return snapValueToStep(v, min, max, step);
return clamp(v, min ?? -Infinity, max ?? Infinity);
}
function setValue(v: number | null): void {
if (disabled || readonly) return;
const next = v === null ? null : clampInput(v);
if (next === localValue.value) return;
localValue.value = next;
// `defineModel` emits `update:modelValue` on write — no manual emit needed.
model.value = next;
emit('valueChange', next);
}
function step_(delta: number, sign: '+' | '-'): void {
const base = localValue.value ?? min ?? 0;
setValue(handleDecimalOperation(sign, base, delta));
if (focusOnChange)
inputEl.value?.focus();
}
function increment(delta = step): void {
step_(delta, '+');
}
function decrement(delta = step): void {
step_(delta, '-');
}
const textValue = computed(() => {
if (localValue.value === null)
return '';
// Only run the locale formatter when format options are present, so the
// plain-number contract (and existing `String(value)` display) is preserved.
return formatOptions ? numberFormat.format(localValue.value) : String(localValue.value);
});
const inputMode = computed<'numeric' | 'decimal'>(() => {
// Default `Intl.NumberFormat` reports 3 fraction digits, so only trust the
// formatter when the consumer actually passed `formatOptions`; otherwise the
// soft-keyboard hint is driven purely by whether the step is fractional.
const fractionDigits = formatOptions ? (numberFormat.resolved.value.maximumFractionDigits ?? 0) : 0;
const allowsFraction = fractionDigits > 0 || !Number.isInteger(step);
return allowsFraction ? 'decimal' : 'numeric';
});
function parseRaw(raw: string): number {
return formatOptions ? numberFormat.parse(raw) : Number(raw.trim());
}
function parseInput(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === '')
return null;
const parsed = parseRaw(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function applyInputValue(raw: string): void {
const trimmed = raw.trim();
if (trimmed === '') {
setValue(null);
return;
}
const parsed = parseRaw(trimmed);
if (Number.isNaN(parsed) || !Number.isFinite(parsed))
return;
setValue(parsed);
}
function validate(raw: string): boolean {
if (!formatOptions)
return true;
return numberFormat.isValidPartial(raw);
}
const isIncrementDisabled = computed(() => {
if (localValue.value === null || max === undefined)
return false;
return handleDecimalOperation('+', localValue.value, step) > max && localValue.value >= max;
});
const isDecrementDisabled = computed(() => {
if (localValue.value === null || min === undefined)
return false;
return handleDecimalOperation('-', localValue.value, step) < min && localValue.value <= min;
});
const inputId = useId(undefined, 'number-field-input').value;
// `defineExpose` is consumed and merged by `useForwardExpose` — it must run
// first so the imperative API is forwarded alongside the element ref (and so
// `expose()` is only called once).
defineExpose({ value: localValue, increment, decrement, setValue });
const { forwardRef, currentElement } = useForwardExpose();
const isFormControl = computed(() => {
const el = currentElement.value;
return !!el && !!el.closest('form');
});
provideNumberFieldContext({
value: localValue,
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
min: toRef(() => min),
max: toRef(() => max),
step: toRef(() => step),
disabled: toRef(() => disabled),
readonly: toRef(() => readonly),
increment,
decrement,
setValue,
inputId,
textValue,
inputMode,
parseInput,
applyInputValue,
validate,
isIncrementDisabled,
isDecrementDisabled,
disableWheelChange: toRef(() => disableWheelChange),
invertWheelChange: toRef(() => invertWheelChange),
focusOnChange: toRef(() => focusOnChange),
inputEl,
onInputElement: (el) => { inputEl.value = el; },
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="group"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
>
<slot
:value="localValue"
:text-value="textValue"
:increment="increment"
:decrement="decrement"
/>
<VisuallyHiddenInput
v-if="isFormControl && name"
:name="name"
:value="localValue"
:required="required"
:disabled="disabled"
/>
</Primitive>
</template>
@@ -0,0 +1,381 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import type { Component } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldRoot,
} from '../index';
import {
createNumberFormat,
handleDecimalOperation,
roundToStepPrecision,
snapValueToStep,
} from '../utils';
let active: { unmount: () => void } | undefined;
function mountField(props: Record<string, unknown> = {}, opts: { inForm?: boolean } = {}) {
const model = ref<number | null | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => {
const field = h(NumberFieldRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: number | null) => { model.value = v; },
...props,
}, {
default: () => [
h(NumberFieldInput as Component, { id: 'inp' }),
h(NumberFieldIncrement, { id: 'inc' }, { default: () => '+' }),
h(NumberFieldDecrement, { id: 'dec' }, { default: () => '' }),
],
});
return opts.inForm ? h('form', { id: 'form' }, [field]) : field;
},
});
const wrapper = mount(Harness, { attachTo: document.body });
active = wrapper;
return { wrapper, model };
}
function press(el: Element, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
function pointerDown(el: Element): void {
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }));
}
function pointerUp(): void {
globalThis.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, button: 0 }));
}
afterEach(() => {
active?.unmount();
active = undefined;
});
describe('NumberField utils', () => {
it('handleDecimalOperation avoids float drift', () => {
expect(handleDecimalOperation('+', 0.1, 0.2)).toBe(0.3);
expect(handleDecimalOperation('-', 0.3, 0.1)).toBe(0.2);
expect(handleDecimalOperation('+', 1, 2)).toBe(3);
});
it('roundToStepPrecision rounds to step precision', () => {
expect(roundToStepPrecision(0.30000000000000004, 0.1)).toBe(0.3);
expect(roundToStepPrecision(5, 1)).toBe(5);
});
it('snapValueToStep snaps to grid within bounds', () => {
expect(snapValueToStep(7, 0, 10, 5)).toBe(5);
expect(snapValueToStep(8, 0, 10, 5)).toBe(10);
expect(snapValueToStep(2.3, 0, 10, 0.5)).toBe(2.5);
expect(snapValueToStep(-1, 0, 10, 1)).toBe(0);
});
it('createNumberFormat formats and parses with locale separators', () => {
const fmt = createNumberFormat(() => 'en-US', () => ({ minimumFractionDigits: 2 }));
expect(fmt.format(1234.5)).toBe('1,234.50');
expect(fmt.parse('1,234.50')).toBe(1234.5);
expect(fmt.parse('abc')).toBeNaN();
expect(fmt.isValidPartial('1,2')).toBe(true);
expect(fmt.isValidPartial('xyz')).toBe(false);
});
});
describe('NumberField ARIA / structure', () => {
it('root has role=group', () => {
const { wrapper } = mountField({ defaultValue: 1 });
expect(wrapper.find('[role="group"]').exists()).toBe(true);
});
it('input has a11y attributes', () => {
mountField({ min: 0, max: 10, defaultValue: 5 });
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.getAttribute('role')).toBe('spinbutton');
expect(input.getAttribute('tabindex')).toBe('0');
expect(input.getAttribute('autocorrect')).toBe('off');
expect(input.getAttribute('spellcheck')).toBe('false');
expect(input.getAttribute('aria-roledescription')).toBe('Number field');
expect(input.getAttribute('inputmode')).toBe('numeric');
});
it('inputmode becomes decimal with fractional step', () => {
mountField({ step: 0.5, defaultValue: 1 });
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.getAttribute('inputmode')).toBe('decimal');
});
it('stepper buttons are exposed to AT with aria-label (not aria-hidden)', () => {
mountField({ defaultValue: 1 });
const inc = document.querySelector<HTMLButtonElement>('#inc')!;
const dec = document.querySelector<HTMLButtonElement>('#dec')!;
expect(inc.getAttribute('aria-hidden')).toBeNull();
expect(inc.getAttribute('aria-label')).toBe('Increase');
expect(dec.getAttribute('aria-label')).toBe('Decrease');
expect(inc.getAttribute('tabindex')).toBe('-1');
});
it('custom aria-label on buttons is forwarded', () => {
const model = ref<number | null>(1);
const wrapper = mount(defineComponent({
setup: () => () => h(NumberFieldRoot, { modelValue: model.value }, {
default: () => [
h(NumberFieldIncrement, { id: 'inc', 'aria-label': 'More' }),
h(NumberFieldDecrement, { id: 'dec', 'aria-label': 'Less' }),
],
}),
}), { attachTo: document.body });
active = wrapper;
expect(document.querySelector('#inc')!.getAttribute('aria-label')).toBe('More');
expect(document.querySelector('#dec')!.getAttribute('aria-label')).toBe('Less');
});
});
describe('NumberField decimal-safe stepping & snapping', () => {
it('steps fractional values without float drift', async () => {
// `stepSnapping: false` isolates the decimal-safe arithmetic from grid snapping.
const { model } = mountField({ defaultValue: 0.1, step: 0.2, stepSnapping: false });
await nextTick();
(document.querySelector<HTMLButtonElement>('#inc')!).click();
await nextTick();
expect(model.value).toBe(0.3);
});
it('snaps off-grid value to step grid on commit', async () => {
const { model } = mountField({ min: 0, max: 10, step: 5, defaultValue: 0 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.value = '7';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBe(5);
});
it('stepSnapping=false clamps without snapping', async () => {
const { model } = mountField({ min: 0, max: 10, step: 5, stepSnapping: false, defaultValue: 0 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.value = '7';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBe(7);
});
});
describe('NumberField at-boundary disabled', () => {
it('disables increment at max and decrement at min', async () => {
const { model } = mountField({ min: 0, max: 5, step: 1, defaultValue: 5 });
await nextTick();
const inc = document.querySelector<HTMLButtonElement>('#inc')!;
expect(inc.hasAttribute('data-disabled')).toBe(true);
expect(inc.disabled).toBe(true);
// Move to min
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'Home');
await nextTick();
expect(model.value).toBe(0);
const dec = document.querySelector<HTMLButtonElement>('#dec')!;
expect(dec.hasAttribute('data-disabled')).toBe(true);
expect(dec.disabled).toBe(true);
});
it('per-button disabled prop disables independently', async () => {
const model = ref<number | null>(2);
const wrapper = mount(defineComponent({
setup: () => () => h(NumberFieldRoot, { modelValue: model.value }, {
default: () => [
h(NumberFieldIncrement, { id: 'inc', disabled: true }),
h(NumberFieldDecrement, { id: 'dec' }),
],
}),
}), { attachTo: document.body });
active = wrapper;
await nextTick();
const inc = document.querySelector<HTMLButtonElement>('#inc')!;
expect(inc.disabled).toBe(true);
inc.click();
await nextTick();
expect(model.value).toBe(2);
});
});
describe('NumberField wheel', () => {
it('increments on wheel up while focused, decrements on wheel down', async () => {
const { model } = mountField({ defaultValue: 5, step: 1 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.focus();
input.dispatchEvent(new WheelEvent('wheel', { deltaY: -10, deltaX: 0, bubbles: true, cancelable: true }));
await nextTick();
expect(model.value).toBe(6);
input.dispatchEvent(new WheelEvent('wheel', { deltaY: 10, deltaX: 0, bubbles: true, cancelable: true }));
await nextTick();
expect(model.value).toBe(5);
});
it('disableWheelChange ignores wheel', async () => {
const { model } = mountField({ defaultValue: 5, disableWheelChange: true });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.focus();
input.dispatchEvent(new WheelEvent('wheel', { deltaY: -10, deltaX: 0, bubbles: true, cancelable: true }));
await nextTick();
// No value write occurred, so the uncontrolled model ref stays untouched.
expect(model.value).toBeUndefined();
expect(input.value).toBe('5');
});
it('invertWheelChange reverses direction', async () => {
const { model } = mountField({ defaultValue: 5, invertWheelChange: true });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.focus();
input.dispatchEvent(new WheelEvent('wheel', { deltaY: -10, deltaX: 0, bubbles: true, cancelable: true }));
await nextTick();
expect(model.value).toBe(4);
});
it('ignores mostly-horizontal wheel (trackpad heuristic)', async () => {
const { model } = mountField({ defaultValue: 5 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.focus();
input.dispatchEvent(new WheelEvent('wheel', { deltaY: 2, deltaX: 20, bubbles: true, cancelable: true }));
await nextTick();
// No value write occurred, so the uncontrolled model ref stays untouched.
expect(model.value).toBeUndefined();
expect(input.value).toBe('5');
});
});
describe('NumberField press-and-hold', () => {
it('pointerdown triggers a step and sets data-pressed', async () => {
const { model } = mountField({ defaultValue: 0, step: 1 });
await nextTick();
const inc = document.querySelector<HTMLButtonElement>('#inc')!;
pointerDown(inc);
await nextTick();
expect(model.value).toBe(1);
expect(inc.getAttribute('data-pressed')).toBe('true');
pointerUp();
await nextTick();
expect(inc.getAttribute('data-pressed')).toBeNull();
});
it('synthetic click after a pointer press does not double-step', async () => {
const { model } = mountField({ defaultValue: 0, step: 1 });
await nextTick();
const inc = document.querySelector<HTMLButtonElement>('#inc')!;
pointerDown(inc);
pointerUp();
inc.click();
await nextTick();
expect(model.value).toBe(1);
});
});
describe('NumberField commit on blur / Enter', () => {
it('reclamps and snaps on blur', async () => {
const { model } = mountField({ min: 0, max: 10, step: 1, defaultValue: 5 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.value = '99';
input.dispatchEvent(new Event('blur', { bubbles: true }));
await nextTick();
expect(model.value).toBe(10);
});
it('Enter commits the value', async () => {
const { model } = mountField({ min: 0, max: 10, step: 1, defaultValue: 5 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.value = '99';
press(input, 'Enter');
await nextTick();
expect(model.value).toBe(10);
});
});
describe('NumberField beforeinput validation (formatOptions)', () => {
it('rejects invalid characters when formatOptions set', async () => {
mountField({ formatOptions: { maximumFractionDigits: 2 }, defaultValue: 1 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
const evt = new InputEvent('beforeinput', { data: 'a', bubbles: true, cancelable: true });
input.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
const ok = new InputEvent('beforeinput', { data: '5', bubbles: true, cancelable: true });
input.dispatchEvent(ok);
expect(ok.defaultPrevented).toBe(false);
});
});
describe('NumberField formatting', () => {
it('displays formatted text via textValue when formatOptions set', async () => {
const { wrapper } = mountField({
formatOptions: { style: 'currency', currency: 'USD' },
defaultValue: 1234.5,
});
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.value).toContain('1,234');
expect(input.value).toContain('$');
wrapper.unmount();
active = undefined;
});
it('plain number display without formatOptions', async () => {
mountField({ defaultValue: 42 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.value).toBe('42');
});
});
describe('NumberField form integration', () => {
it('renders a hidden input inside a form', async () => {
mountField({ name: 'qty', defaultValue: 3 }, { inForm: true });
await nextTick();
const hidden = document.querySelector<HTMLInputElement>('form input[name="qty"]');
expect(hidden).not.toBeNull();
expect(hidden!.value).toBe('3');
});
it('does not render a hidden input outside a form', async () => {
mountField({ name: 'qty', defaultValue: 3 });
await nextTick();
const hidden = document.querySelector<HTMLInputElement>('input[name="qty"]');
expect(hidden).toBeNull();
});
});
describe('NumberField polymorphism', () => {
it('Input renders the provided as element', async () => {
const model = ref<number | null>(1);
const wrapper = mount(defineComponent({
setup: () => () => h(NumberFieldRoot, { modelValue: model.value }, {
default: () => [h(NumberFieldInput as Component, { id: 'inp', as: 'input' })],
}),
}), { attachTo: document.body });
active = wrapper;
await nextTick();
expect(document.querySelector('#inp')!.tagName).toBe('INPUT');
});
});
describe('NumberField readonly', () => {
it('readonly blocks stepping but exposes value', async () => {
const { model } = mountField({ readonly: true, defaultValue: 5 });
await nextTick();
(document.querySelector<HTMLButtonElement>('#inc')!).click();
await nextTick();
expect(model.value).toBeUndefined();
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.getAttribute('aria-readonly')).toBe('true');
});
});
@@ -0,0 +1,130 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import type { Component } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldRoot,
} from '../index';
function mountField(props: Record<string, unknown> = {}) {
const model = ref<number | null | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(NumberFieldRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: number | null) => { model.value = v; },
...props,
}, {
default: () => [
h(NumberFieldInput as Component, { id: 'inp' }),
h(NumberFieldIncrement, { id: 'inc' }, { default: () => '+' }),
h(NumberFieldDecrement, { id: 'dec' }, { default: () => '' }),
],
}),
});
return { wrapper: mount(Harness, { attachTo: document.body }), model };
}
function press(el: Element, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('NumberField', () => {
it('input has role=spinbutton with ARIA attrs', () => {
const { wrapper } = mountField({ min: 0, max: 10, defaultValue: 5 });
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.getAttribute('role')).toBe('spinbutton');
expect(input.getAttribute('aria-valuemin')).toBe('0');
expect(input.getAttribute('aria-valuemax')).toBe('10');
expect(input.getAttribute('aria-valuenow')).toBe('5');
expect(input.value).toBe('5');
wrapper.unmount();
});
it('increment/decrement buttons change value', async () => {
const { wrapper, model } = mountField({ defaultValue: 0, step: 2 });
await nextTick();
(document.querySelector<HTMLButtonElement>('#inc')!).click();
await nextTick();
expect(model.value).toBe(2);
(document.querySelector<HTMLButtonElement>('#dec')!).click();
await nextTick();
expect(model.value).toBe(0);
wrapper.unmount();
});
it('ArrowUp / ArrowDown step, clamped by min/max', async () => {
const { wrapper, model } = mountField({ min: 0, max: 3, defaultValue: 2 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'ArrowUp');
await nextTick();
expect(model.value).toBe(3);
press(input, 'ArrowUp');
await nextTick();
expect(model.value).toBe(3);
press(input, 'ArrowDown');
press(input, 'ArrowDown');
press(input, 'ArrowDown');
press(input, 'ArrowDown');
await nextTick();
expect(model.value).toBe(0);
wrapper.unmount();
});
it('PageUp/PageDown step by 10×', async () => {
const { wrapper, model } = mountField({ defaultValue: 10, step: 1 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'PageUp');
await nextTick();
expect(model.value).toBe(20);
press(input, 'PageDown');
await nextTick();
expect(model.value).toBe(10);
wrapper.unmount();
});
it('Home/End jump to min/max when defined', async () => {
const { wrapper, model } = mountField({ min: 0, max: 100, defaultValue: 50 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'End');
await nextTick();
expect(model.value).toBe(100);
press(input, 'Home');
await nextTick();
expect(model.value).toBe(0);
wrapper.unmount();
});
it('typing updates value; invalid = null', async () => {
const { wrapper, model } = mountField({ defaultValue: 0 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.value = '42';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBe(42);
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBeNull();
input.value = 'abc';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBeNull();
wrapper.unmount();
});
it('disabled blocks changes', async () => {
const { wrapper, model } = mountField({ disabled: true, defaultValue: 5 });
await nextTick();
(document.querySelector<HTMLButtonElement>('#inc')!).click();
await nextTick();
expect(model.value).toBeUndefined();
wrapper.unmount();
});
});
@@ -0,0 +1,43 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface NumberFieldContext {
value: Ref<number | null>;
min: Ref<number | undefined>;
max: Ref<number | undefined>;
step: Ref<number>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
increment: (delta?: number) => void;
decrement: (delta?: number) => void;
setValue: (v: number | null) => void;
inputId: string;
/** Text shown in the field, formatted with the active locale/format options. */
textValue: ComputedRef<string>;
/** Suggested soft-keyboard mode derived from whether fractional input is allowed. */
inputMode: ComputedRef<'numeric' | 'decimal'>;
/** Live parse of a raw input string to a value (empty/unparseable → `null`). */
parseInput: (raw: string) => number | null;
/** Re-parse, snap, clamp, and reformat the raw input string. Used on commit. */
applyInputValue: (raw: string) => void;
/** `true` when a prospective raw value is a valid partial number for the locale. */
validate: (raw: string) => boolean;
/** `true` when the next increment would exceed `max`. */
isIncrementDisabled: ComputedRef<boolean>;
/** `true` when the next decrement would drop below `min`. */
isDecrementDisabled: ComputedRef<boolean>;
/** Suppress wheel-driven stepping. */
disableWheelChange: Ref<boolean>;
/** Invert the direction of wheel-driven stepping. */
invertWheelChange: Ref<boolean>;
/** Return focus to the input after a stepper button changes the value. */
focusOnChange: Ref<boolean>;
/** Track the live input element so wheel / focus-on-change can target it. */
inputEl: Ref<HTMLInputElement | undefined>;
onInputElement: (el: HTMLInputElement | undefined) => void;
}
const ctx = useContextFactory<NumberFieldContext>('NumberFieldContext');
export const provideNumberFieldContext = ctx.provide;
export const useNumberFieldContext = ctx.inject;
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot } from '@robonen/primitives';
import { computed, ref } from 'vue';
const UNIT_PRICE = 12;
const MAX = 10;
const quantity = ref<number | null>(2);
const total = computed(() => (quantity.value ?? 0) * UNIT_PRICE);
const atMax = computed(() => quantity.value !== null && quantity.value >= MAX);
</script>
<template>
<div class="flex flex-col gap-5 p-6 max-w-xs bg-bg text-fg border border-border rounded-xl">
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold">Espresso blend</span>
<span class="text-xs text-fg-subtle">${{ UNIT_PRICE }}.00 per bag</span>
</div>
<label class="flex flex-col gap-2">
<span class="text-xs font-medium text-fg-muted">Quantity</span>
<NumberFieldRoot
v-model="quantity"
:min="1"
:max="MAX"
:step="1"
class="inline-flex items-center w-fit rounded-lg border border-border bg-bg-inset overflow-hidden focus-within:ring-2 focus-within:ring-ring data-[disabled]:opacity-50"
>
<NumberFieldDecrement
class="grid place-items-center w-9 h-9 text-fg-muted hover:bg-bg-subtle hover:text-fg transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 7h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</NumberFieldDecrement>
<NumberFieldInput
name="quantity"
placeholder="0"
class="w-12 h-9 text-center text-sm font-medium bg-transparent text-fg outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
<NumberFieldIncrement
class="grid place-items-center w-9 h-9 text-fg-muted hover:bg-bg-subtle hover:text-fg transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</NumberFieldIncrement>
</NumberFieldRoot>
</label>
<div class="flex items-center justify-between pt-3 border-t border-border">
<span class="text-sm text-fg-muted">Total</span>
<span class="text-sm font-semibold tabular-nums">${{ total }}.00</span>
</div>
<p
class="text-xs"
:class="atMax ? 'text-red-600 dark:text-red-400' : 'text-fg-subtle'"
>
{{ atMax ? `Maximum of ${MAX} bags per order` : 'Use the arrow keys or buttons to adjust' }}
</p>
</div>
</template>
@@ -0,0 +1,13 @@
export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue';
export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue';
export { default as NumberFieldInput } from './NumberFieldInput.vue';
export { default as NumberFieldRoot } from './NumberFieldRoot.vue';
export type { NumberFieldDecrementProps } from './NumberFieldDecrement.vue';
export type { NumberFieldIncrementProps } from './NumberFieldIncrement.vue';
export type { NumberFieldInputProps } from './NumberFieldInput.vue';
export type { NumberFieldRootEmits, NumberFieldRootProps } from './NumberFieldRoot.vue';
export {
provideNumberFieldContext,
useNumberFieldContext,
type NumberFieldContext,
} from './context';
@@ -0,0 +1,230 @@
import type { MaybeRefOrGetter, Ref } from 'vue';
import { computed, onScopeDispose, ref, toValue } from 'vue';
import { createEventHook, useEventListener } from '@robonen/vue';
/**
* Decimal-safe add/subtract. Floating point makes `0.1 + 0.2 === 0.30000000000000004`,
* which is wrong for a stepper. We scale both operands to integers by the larger
* of their decimal lengths, operate, then scale back.
*/
export function handleDecimalOperation(operator: '+' | '-', a: number, b: number): number {
let result = operator === '+' ? a + b : a - b;
if (a % 1 !== 0 || b % 1 !== 0) {
const aDecimals = a.toString().split('.')[1]?.length ?? 0;
const bDecimals = b.toString().split('.')[1]?.length ?? 0;
const multiplier = 10 ** Math.max(aDecimals, bDecimals);
const aInt = Math.round(a * multiplier);
const bInt = Math.round(b * multiplier);
result = (operator === '+' ? aInt + bInt : aInt - bInt) / multiplier;
}
return result;
}
/**
* Round a value to the decimal precision implied by `step` (e.g. step `0.5` →
* one decimal place), correcting floating-point drift after snapping.
*/
export function roundToStepPrecision(value: number, step: number): number {
const stepString = step.toString();
const pointIndex = stepString.indexOf('.');
const precision = pointIndex >= 0 ? stepString.length - pointIndex - 1 : 0;
if (precision > 0) {
const pow = 10 ** precision;
return Math.round(value * pow) / pow;
}
return value;
}
/**
* Snap `value` to the nearest multiple of `step` within `[min, max]`. Mirrors the
* spinbutton semantics: when the value lands beyond a bound the snapped result is
* pulled back to the last reachable on-grid value inside the range.
*/
export function snapValueToStep(value: number, min: number | undefined, max: number | undefined, step: number): number {
const lo = min ?? Number.NaN;
const hi = max ?? Number.NaN;
const remainder = (value - (Number.isNaN(lo) ? 0 : lo)) % step;
let snapped = roundToStepPrecision(
Math.abs(remainder) * 2 >= step
? value + Math.sign(remainder) * (step - Math.abs(remainder))
: value - remainder,
step,
);
if (!Number.isNaN(lo)) {
if (snapped < lo)
snapped = lo;
else if (!Number.isNaN(hi) && snapped > hi)
snapped = lo + Math.floor(roundToStepPrecision((hi - lo) / step, step)) * step;
}
else if (!Number.isNaN(hi) && snapped > hi) {
snapped = Math.floor(roundToStepPrecision(hi / step, step)) * step;
}
return roundToStepPrecision(snapped, step);
}
export interface UsePressedHoldOptions {
/** Element the press starts on (pointer must go down on it). */
target: Ref<HTMLElement | undefined>;
/** When `true`, presses are ignored and any in-flight repeat stops. */
disabled: MaybeRefOrGetter<boolean>;
}
export interface UsePressedHoldReturn {
/** `true` while the pointer is held down on the target. */
isPressed: Ref<boolean>;
/** Register a callback fired once on press and then repeatedly while held. */
onTrigger: (fn: () => void) => void;
/**
* `true` for one `click` immediately after a real pointer press already fired
* the trigger. Lets a `@click` fallback (for programmatic / keyboard-activated
* clicks that emit no `pointerdown`) avoid double-firing.
*/
consumeClick: () => boolean;
}
/**
* Press-and-hold auto-repeat for stepper buttons: fires immediately on
* pointer-down, then repeats every 60ms after an initial 400ms delay until the
* pointer is released, cancelled, or leaves. Built on the toolkit's
* `useEventListener` (auto-cleaned on scope dispose) and `createEventHook`.
*/
export function usePressedHold(options: UsePressedHoldOptions): UsePressedHoldReturn {
const { target, disabled } = options;
const isPressed = ref(false);
const triggerHook = createEventHook<void>();
let timer: ReturnType<typeof setTimeout> | undefined;
// Set when a pointer press fired the trigger; the synthetic `click` that the
// browser dispatches afterwards must be swallowed by the `@click` fallback.
let pressDidTrigger = false;
function clearTimer(): void {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
function repeat(delay: number): void {
clearTimer();
if (toValue(disabled))
return;
triggerHook.trigger();
timer = setTimeout(() => repeat(60), delay);
}
function onPressStart(event: PointerEvent): void {
// Only the primary (left) button, and ignore re-entrant presses.
if (event.button !== 0 || isPressed.value || toValue(disabled))
return;
event.preventDefault();
isPressed.value = true;
pressDidTrigger = true;
repeat(400);
}
function onPressEnd(): void {
isPressed.value = false;
clearTimer();
}
function consumeClick(): boolean {
if (pressDidTrigger) {
pressDidTrigger = false;
return true;
}
return false;
}
useEventListener(target, 'pointerdown', onPressStart);
// Release/cancel are watched on `window` so lifting the pointer outside the
// button still ends the hold; leaving the button also ends it.
useEventListener(globalThis.window, 'pointerup', onPressEnd);
useEventListener(globalThis.window, 'pointercancel', onPressEnd);
useEventListener(target, 'pointerleave', onPressEnd);
// Clear any in-flight repeat when the owning component unmounts.
onScopeDispose(clearTimer);
return {
isPressed,
onTrigger: triggerHook.on,
consumeClick,
};
}
/**
* Lightweight, locale-aware number formatting/parsing on top of native
* `Intl.NumberFormat` (no external i18n dependency). `format` renders the value
* with grouping/currency/percent per `options`; `parse` strips locale-specific
* group and decimal separators (and currency/percent symbols) back to a plain
* `number`, returning `NaN` for unparseable input.
*/
export function createNumberFormat(locale: MaybeRefOrGetter<string>, options: MaybeRefOrGetter<Intl.NumberFormatOptions | undefined>) {
const formatter = computed(() => new Intl.NumberFormat(toValue(locale), toValue(options)));
const separators = computed(() => {
const parts = formatter.value.formatToParts(12345.6);
const group = parts.find(p => p.type === 'group')?.value ?? ',';
const decimal = parts.find(p => p.type === 'decimal')?.value ?? '.';
return { group, decimal };
});
const resolved = computed(() => formatter.value.resolvedOptions());
// The partial-validation pattern depends solely on the locale decimal
// separator, so rebuild it only when that separator changes rather than on
// every keystroke (isValidPartial runs from @beforeinput per character).
const partialRe = computed(
() => new RegExp(`^[+-]?[\\d${escapeRegExp(separators.value.decimal)}.,\\s\\u00A0%$€£¥]*$`),
);
function format(value: number): string {
return formatter.value.format(value);
}
function parse(raw: string): number {
const trimmed = raw.trim();
if (trimmed === '')
return Number.NaN;
const { group, decimal } = separators.value;
// Strip everything except digits, sign, and the locale decimal separator,
// then normalise the decimal separator to `.` for `Number`.
const cleaned = trimmed
.split(group).join('')
.split(decimal).join('.')
.replaceAll(/[^\d.\-+e]/gi, '');
// No digits at all → not a number (`Number('')` would coerce to 0).
if (!/\d/.test(cleaned))
return Number.NaN;
const n = Number(cleaned);
return Number.isFinite(n) ? n : Number.NaN;
}
function isValidPartial(raw: string): boolean {
if (raw === '' || raw === '-' || raw === '+')
return true;
return partialRe.value.test(raw);
}
return { format, parse, isValidPartial, resolved };
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}