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,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();
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user