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,151 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
import type { Orientation } from '../../utilities/roving-focus';
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue } from './context';
/**
* Coordinates a set of related checkboxes behind a single array model. It owns
* the list of selected `value`s (`v-model` or uncontrolled `defaultValue`),
* applies a group-level `disabled`, optionally wires arrow-key roving focus
* across the children, and — when `name` is set inside a `<form>` — submits the
* selection through hidden inputs. Each nested `CheckboxRoot` derives its
* checked state from membership in this model and toggling adds/removes its
* `value`. Reach for it whenever several checkboxes share one logical answer
* (a multi-select question, a filter set, a permissions matrix).
*/
export interface CheckboxGroupRootProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** Uncontrolled initial selection. */
defaultValue?: T[];
/** Controlled selection. Bind with `v-model`. */
modelValue?: T[];
/** Disable every checkbox in the group. */
disabled?: boolean;
/** Mark the submitted group input as required. */
required?: boolean;
/** Hidden input name; serializes the selection for form submission. */
name?: string;
/**
* Enable arrow-key roving focus across the checkboxes.
* @default true
*/
rovingFocus?: boolean;
/** Navigation orientation when `rovingFocus` is on. */
orientation?: Orientation;
/** Writing direction (RTL-aware navigation). Falls back to config `dir`. */
dir?: Direction;
/**
* Wrap focus around the ends.
* @default false
*/
loop?: boolean;
}
export interface CheckboxGroupRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T[]];
valueChange: [value: T[]];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { Ref } from 'vue';
import { computed, ref, toRef, watch } from 'vue';
import { isEqual } from '@robonen/stdlib';
import { Primitive } from '../../internal/primitive';
import { RovingFocusGroup } from '../../utilities/roving-focus';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
import { useForwardExpose } from '@robonen/vue';
import { provideCheckboxGroupContext } from './context';
const {
defaultValue,
disabled = false,
required = false,
name,
rovingFocus = true,
orientation,
dir,
loop = false,
as = 'div',
} = defineProps<CheckboxGroupRootProps<T>>();
const emit = defineEmits<CheckboxGroupRootEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose();
// `modelValue` is an array replaced wholesale on every toggle, so `shallowRef`
// avoids deep-tracking each member.
const localValue = ref<T[]>(defaultValue ?? []) as Ref<T[]>;
const model = defineModel<T[] | undefined>({
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = (v ?? []) as T[];
return v;
},
});
const currentValue = computed<T[]>(() => model.value ?? localValue.value);
function isChecked(value: AcceptableValue): boolean {
for (const v of currentValue.value) {
if (isEqual(v, value)) return true;
}
return false;
}
function toggle(value: AcceptableValue): void {
if (disabled) return;
const next = [...currentValue.value];
const index = next.findIndex(v => isEqual(v, value));
if (index === -1) next.push(value as T);
else next.splice(index, 1);
model.value = next;
emit('valueChange', next);
}
const rovingFocusProps = computed(() =>
rovingFocus ? { loop, dir, orientation } : {});
// Only submit through the form when inside one; SSR renders so the field
// submits without JS.
const isFormControl = computed<boolean>(() => {
if (globalThis.document === undefined) return true;
const el = currentElement.value;
return !!el && !!el.closest('form');
});
watch(model, (v) => {
if (v !== undefined && v !== localValue.value) localValue.value = v;
});
provideCheckboxGroupContext({
modelValue: currentValue as Ref<AcceptableValue[]>,
disabled: toRef(() => disabled),
rovingFocus: toRef(() => rovingFocus),
toggle,
isChecked,
});
</script>
<template>
<component
:is="rovingFocus ? RovingFocusGroup : Primitive"
:ref="forwardRef"
:as="as"
role="group"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
v-bind="rovingFocusProps"
>
<slot :model-value="currentValue" />
<VisuallyHiddenInput
v-if="isFormControl && name"
:name="name"
:value="currentValue"
:required="required"
:disabled="disabled"
/>
</component>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Renders its content only when the parent `CheckboxRoot` is checked or
* indeterminate, mirroring that state via `data-state`. Place the check/dash
* icon inside it; use `forceMount` to keep it mounted for CSS exit animations.
*/
export interface CheckboxIndicatorProps extends PrimitiveProps {
/** Keep mounted even when unchecked (for CSS exit animations). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { Presence } from '../../utilities/presence';
import { useCheckboxContext } from './context';
import { getState, isIndeterminate } from './utils';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', forceMount = false } = defineProps<CheckboxIndicatorProps>();
const ctx = useCheckboxContext();
defineOptions({ inheritAttrs: false });
const { forwardRef } = useForwardExpose();
</script>
<template>
<Presence
:present="forceMount || isIndeterminate(ctx.checked.value) || ctx.checked.value === true"
>
<Primitive
:ref="forwardRef"
:as="as"
v-bind="$attrs"
:data-state="getState(ctx.checked.value)"
:data-disabled="ctx.disabled.value ? '' : undefined"
style="pointer-events: none;"
>
<slot :checked="ctx.checked.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,188 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue, CheckedState } from './context';
/**
* A toggleable control with checked, unchecked, and `'indeterminate'` states,
* built on a native `<button role="checkbox">`. The interactive root: it owns
* the checked state (controlled via `v-model:checked` or uncontrolled via
* `defaultChecked`), handles toggling, exposes a hidden form input when `name`
* is set, and provides context to `CheckboxIndicator`. Use it whenever you need
* a styled checkbox that integrates with forms or supports a mixed/partial state.
*
* The checked value is generic: with the default `trueValue`/`falseValue`
* (`true`/`false`) it behaves as a boolean checkbox, but those props let the
* model carry arbitrary values (`'yes'`/`'no'`, objects, …) compared by deep
* equality. Nesting the root inside a `CheckboxGroupRoot` switches it to group
* mode: its checked state derives from membership in the group's array model
* and toggling adds/removes its `value`.
*/
export interface CheckboxRootProps<T = boolean> extends PrimitiveProps {
/** Uncontrolled initial checked state. */
defaultChecked?: T | 'indeterminate';
/** Disable interaction. */
disabled?: boolean;
/** Mark associated hidden input as required. */
required?: boolean;
/** Hidden input name attribute. */
name?: string;
/**
* Value submitted with the form (hidden input) and used for membership when
* inside a `CheckboxGroupRoot`.
* @default 'on'
*/
value?: AcceptableValue;
/** Id of the root element; anchors `<label for>` and aria-label derivation. */
id?: string;
/**
* Value the model holds when checked.
* @default true
*/
trueValue?: T;
/**
* Value the model holds when unchecked.
* @default false
*/
falseValue?: T;
}
export interface CheckboxRootEmits<T = boolean> {
checkedChange: [value: T | 'indeterminate'];
}
</script>
<script setup lang="ts" generic="T = boolean">
import type { Ref } from 'vue';
import { Primitive } from '../../internal/primitive';
import { computed, ref } from 'vue';
import { isEqual } from '@robonen/stdlib';
import { provideCheckboxContext, useCheckboxGroupContext } from './context';
import { getState, isIndeterminate } from './utils';
import { useForwardExpose } from '@robonen/vue';
import { RovingFocusItem } from '../../utilities/roving-focus';
import { VisuallyHiddenInputBubble } from '../../utilities/visually-hidden';
defineOptions({ inheritAttrs: false });
const {
disabled: disabledProp = false,
required = false,
value = 'on',
defaultChecked,
name,
id,
trueValue = true as unknown as T,
falseValue = false as unknown as T,
as = 'button',
} = defineProps<CheckboxRootProps<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const emit = defineEmits<CheckboxRootEmits<T>>();
// Group mode: when an ancestor `CheckboxGroupRoot` is present the checked state
// is derived from membership in the group's array and toggling mutates it.
const group = useCheckboxGroupContext(null);
const localChecked = ref<T | 'indeterminate'>(defaultChecked ?? (falseValue as T)) as Ref<T | 'indeterminate'>;
// `defineModel` handles both controlled (parent `v-model:checked`) and
// uncontrolled modes; `localChecked` backs the uncontrolled state seeded from
// `defaultChecked`. `checkedChange` is a separate public emit, so it stays.
const checked = defineModel<T | 'indeterminate' | undefined>('checked', {
default: undefined,
get: v => v ?? localChecked.value,
set: (v) => {
localChecked.value = v as T | 'indeterminate';
return v;
},
});
const disabled = computed<boolean>(() => (group?.disabled.value ?? false) || disabledProp);
// Canonical `CheckedState` for ARIA / `data-state` / the indicator. In group
// mode it is pure membership; standalone it compares the model to `trueValue`.
const checkedState = computed<CheckedState>(() => {
if (group) return group.isChecked(value);
const v = checked.value;
if (isIndeterminate(v)) return 'indeterminate';
return isEqual(v, trueValue);
});
function setChecked(v: T | 'indeterminate'): void {
checked.value = v;
emit('checkedChange', v);
}
function toggle(): void {
if (disabled.value) return;
if (group) {
group.toggle(value);
return;
}
// From indeterminate or unchecked → trueValue; from checked → falseValue.
const next = checkedState.value === true ? falseValue : trueValue;
setChecked(next);
}
function onKeyDown(event: KeyboardEvent): void {
// Per WAI-ARIA a checkbox does not activate on Enter; block the implicit
// form submit too.
if (event.key === 'Enter') event.preventDefault();
// <button> handles Space natively; synthesize toggle only for non-button hosts.
if (as !== 'button' && event.key === ' ') {
event.preventDefault();
toggle();
}
}
// Derive an accessible name from an associated `<label for=id>` when no explicit
// `aria-label` is supplied. Guarded for SSR (no `document`).
const ariaLabel = computed<string | undefined>(() => {
if (!id || !currentElement.value || globalThis.document === undefined) return undefined;
const label = globalThis.document.querySelector(`[for="${id}"]`) as HTMLElement | null;
return label?.innerText || undefined;
});
// A standalone checkbox renders a hidden form input whenever `name` is set; a
// grouped checkbox never does (the group owns the submitted value).
const hasHiddenInput = computed<boolean>(() => !group && !!name);
provideCheckboxContext({
checked: checkedState,
disabled,
});
</script>
<template>
<component
:is="group?.rovingFocus.value ? RovingFocusItem : Primitive"
:ref="forwardRef"
v-bind="$attrs"
:id="id"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:tabindex="(as === 'button' || group?.rovingFocus.value) ? undefined : (disabled ? -1 : 0)"
:focusable="group?.rovingFocus.value ? !disabled : undefined"
role="checkbox"
:aria-checked="isIndeterminate(checkedState) ? 'mixed' : checkedState"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:aria-label="($attrs['aria-label'] as string) || ariaLabel"
:data-state="getState(checkedState)"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled || undefined"
@click="toggle"
@keydown="onKeyDown"
>
<slot :checked="checkedState" :model-value="checked" :state="checkedState" />
<VisuallyHiddenInputBubble
v-if="hasHiddenInput"
:name="name!"
:value="value"
:checked="checkedState === true"
:required="required"
:disabled="disabled"
/>
</component>
</template>
@@ -0,0 +1,109 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { CheckboxIndicator, CheckboxRoot } from '../index';
function mountCheckbox(props: Record<string, unknown> = {}) {
return mount(CheckboxRoot, {
attachTo: document.body,
props,
slots: {
default: () => h(CheckboxIndicator, null, { default: () => '✓' }),
},
});
}
describe('Checkbox', () => {
it('renders role="checkbox" with aria-checked="false" initially', () => {
const w = mountCheckbox();
const el = w.element;
expect(el.getAttribute('role')).toBe('checkbox');
expect(el.getAttribute('aria-checked')).toBe('false');
expect(el.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('toggles on click', async () => {
const w = mountCheckbox();
const el = w.element as HTMLElement;
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('true');
expect(el.getAttribute('data-state')).toBe('checked');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('honours defaultChecked', () => {
const w = mountCheckbox({ defaultChecked: true });
expect(w.element.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('supports indeterminate state with aria-checked="mixed"', async () => {
const checked = ref<boolean | 'indeterminate'>('indeterminate');
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: checked.value,
'onUpdate:checked': (v: boolean | 'indeterminate' | undefined) => { checked.value = v!; },
}, { default: () => h(CheckboxIndicator) }),
});
const w = mount(Harness, { attachTo: document.body });
expect(w.element.getAttribute('aria-checked')).toBe('mixed');
(w.element as HTMLElement).click();
await nextTick();
// Click from indeterminate → true
expect(checked.value).toBe(true);
w.unmount();
});
it('disabled: no toggle on click, aria-disabled set', async () => {
const w = mountCheckbox({ disabled: true });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-disabled')).toBe('true');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('emits checkedChange', async () => {
const w = mountCheckbox();
(w.element as HTMLElement).click();
await nextTick();
expect(w.emitted('checkedChange')).toEqual([[true]]);
w.unmount();
});
it('renders hidden input when name is set', async () => {
const w = mountCheckbox({ name: 'agree', value: 'yes', defaultChecked: true });
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.value).toBe('yes');
expect(input.checked).toBe(true);
w.unmount();
});
it('CheckboxIndicator only renders when checked (or forceMount)', async () => {
const w = mountCheckbox();
expect(w.element.querySelector('span')).toBeNull();
(w.element as HTMLElement).click();
await nextTick();
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
it('CheckboxIndicator forceMount stays mounted when unchecked', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, { forceMount: true }, { default: () => '✓' }),
},
});
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
});
@@ -0,0 +1,328 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { CheckboxGroupRoot, CheckboxIndicator, CheckboxRoot } from '../index';
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('CheckboxRoot — generic value (trueValue/falseValue)', () => {
it('models arbitrary string values via trueValue/falseValue', async () => {
const model = ref<string>('no');
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: model.value,
trueValue: 'yes',
falseValue: 'no',
'onUpdate:checked': (v: unknown) => { model.value = v as string; },
}),
});
const w = mount(Harness, { attachTo: document.body });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-checked')).toBe('false');
expect(el.getAttribute('data-state')).toBe('unchecked');
el.click();
await nextTick();
expect(model.value).toBe('yes');
expect(el.getAttribute('aria-checked')).toBe('true');
el.click();
await nextTick();
expect(model.value).toBe('no');
w.unmount();
});
it('compares object values by deep equality', async () => {
const trueVal = { id: 1 };
const model = ref<unknown>({ id: 0 });
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: model.value,
trueValue: trueVal,
falseValue: { id: 0 },
'onUpdate:checked': (v: unknown) => { model.value = v; },
}),
});
const w = mount(Harness, { attachTo: document.body });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-checked')).toBe('false');
el.click();
await nextTick();
// Deeply equal to trueValue → checked.
expect(el.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('uncontrolled defaultChecked seeds the generic model', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { defaultChecked: 'yes', trueValue: 'yes', falseValue: 'no' },
});
expect(w.element.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
});
describe('CheckboxRoot — slot contract', () => {
it('exposes checked, modelValue and state to the default slot', async () => {
let captured: Record<string, unknown> = {};
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { defaultChecked: true },
slots: {
default: (scope: Record<string, unknown>) => {
captured = scope;
return '';
},
},
});
await nextTick();
expect(captured.checked).toBe(true);
expect(captured.state).toBe(true);
expect('modelValue' in captured).toBe(true);
w.unmount();
});
});
describe('CheckboxRoot — aria-label from associated label', () => {
it('derives aria-label from a <label for> when id is set', async () => {
const label = document.createElement('label');
label.setAttribute('for', 'cb-1');
label.textContent = 'Accept terms';
document.body.appendChild(label);
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { id: 'cb-1' },
});
await nextTick();
expect(w.element.getAttribute('aria-label')).toBe('Accept terms');
w.unmount();
label.remove();
});
it('an explicit aria-label wins over the derived one', async () => {
const label = document.createElement('label');
label.setAttribute('for', 'cb-2');
label.textContent = 'Derived';
document.body.appendChild(label);
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { id: 'cb-2' },
attrs: { 'aria-label': 'Explicit' },
});
await nextTick();
expect(w.element.getAttribute('aria-label')).toBe('Explicit');
w.unmount();
label.remove();
});
});
describe('CheckboxRoot — hidden form input', () => {
it('renders a native hidden checkbox input mirroring state', async () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { name: 'agree', value: 'on', defaultChecked: true },
});
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.checked).toBe(true);
w.unmount();
});
it('does not render a hidden input without a name', () => {
const w = mount(CheckboxRoot, { attachTo: document.body });
expect(w.element.querySelector('input[type="checkbox"]')).toBeNull();
w.unmount();
});
});
describe('CheckboxRoot — keyboard', () => {
it('Enter does not toggle (WAI-ARIA) and is prevented', async () => {
const w = mount(CheckboxRoot, { attachTo: document.body });
const el = w.element as HTMLElement;
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
el.dispatchEvent(ev);
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
expect(ev.defaultPrevented).toBe(true);
w.unmount();
});
it('Space toggles on a non-button host', async () => {
const w = mount(CheckboxRoot, { attachTo: document.body, props: { as: 'div' } });
const el = w.element as HTMLElement;
expect(el.getAttribute('tabindex')).toBe('0');
press(el, ' ');
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
});
describe('CheckboxIndicator — Presence', () => {
it('forceMount keeps it mounted with data-state unchecked', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, { forceMount: true }, { default: () => '✓' }),
},
});
const span = w.element.querySelector('span') as HTMLElement;
expect(span).toBeTruthy();
expect(span.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('mounts on check and unmounts on uncheck (no animation)', async () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, null, { default: () => '✓' }),
},
});
const el = w.element as HTMLElement;
expect(el.querySelector('span')).toBeNull();
el.click();
await nextTick();
expect(el.querySelector('span')).toBeTruthy();
el.click();
await nextTick();
expect(el.querySelector('span')).toBeNull();
w.unmount();
});
});
describe('CheckboxGroupRoot', () => {
function mountGroup(props: Record<string, unknown> = {}, values = ['a', 'b', 'c']) {
const Harness = defineComponent({
props: { groupProps: { type: Object, default: () => ({}) } },
setup: p => () => h('div', [
h(CheckboxGroupRoot, p.groupProps, {
default: () => values.map(v => h(CheckboxRoot, { key: v, value: v })),
}),
]),
});
return mount(Harness, { attachTo: document.body, props: { groupProps: props } });
}
it('renders role="group" and checks members present in the model', async () => {
const w = mountGroup({ defaultValue: ['b'] });
const group = w.element.querySelector('[role="group"]') as HTMLElement;
expect(group).toBeTruthy();
const boxes = w.element.querySelectorAll('[role="checkbox"]');
expect(boxes.length).toBe(3);
expect(boxes[0]!.getAttribute('aria-checked')).toBe('false');
expect(boxes[1]!.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('toggling a member adds/removes its value in the group model', async () => {
const model = ref<string[]>([]);
const Harness = defineComponent({
setup: () => () => h(CheckboxGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string[]) => { model.value = v; },
}, {
default: () => ['a', 'b'].map(v => h(CheckboxRoot, { key: v, value: v })),
}),
});
const w = mount(Harness, { attachTo: document.body });
const boxes = w.element.querySelectorAll('[role="checkbox"]');
(boxes[0] as HTMLElement).click();
await nextTick();
expect(model.value).toEqual(['a']);
(boxes[1] as HTMLElement).click();
await nextTick();
expect(model.value).toEqual(['a', 'b']);
(boxes[0] as HTMLElement).click();
await nextTick();
expect(model.value).toEqual(['b']);
w.unmount();
});
it('emits valueChange alongside update:modelValue', async () => {
const Harness = defineComponent({
emits: ['valueChange'],
setup: (_, { emit }) => () => h(CheckboxGroupRoot, {
onValueChange: (v: string[]) => emit('valueChange', v),
}, {
default: () => [h(CheckboxRoot, { value: 'x' })],
}),
});
const w = mount(Harness, { attachTo: document.body });
(w.element.querySelector('[role="checkbox"]') as HTMLElement).click();
await nextTick();
expect(w.emitted('valueChange')).toEqual([[['x']]]);
w.unmount();
});
it('group-level disabled blocks toggling and reflects on members', async () => {
const w = mountGroup({ disabled: true, defaultValue: [] });
const box = w.element.querySelector('[role="checkbox"]') as HTMLElement;
expect(box.getAttribute('aria-disabled')).toBe('true');
box.click();
await nextTick();
expect(box.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('renders hidden inputs for the selection inside a form', async () => {
const Harness = defineComponent({
setup: () => () => h('form', [
h(CheckboxGroupRoot, { name: 'fruits', defaultValue: ['a', 'c'] }, {
default: () => ['a', 'b', 'c'].map(v => h(CheckboxRoot, { key: v, value: v })),
}),
]),
});
const w = mount(Harness, { attachTo: document.body });
await nextTick();
const inputs = Array.from(w.element.querySelectorAll('input[name^="fruits"]')) as HTMLInputElement[];
expect(inputs.length).toBe(2);
expect(inputs.map(i => i.value)).toEqual(['a', 'c']);
w.unmount();
});
it('grouped members do not render their own hidden form input', async () => {
const Harness = defineComponent({
setup: () => () => h('form', [
h(CheckboxGroupRoot, { defaultValue: ['a'] }, {
default: () => [h(CheckboxRoot, { value: 'a', name: 'should-be-ignored' })],
}),
]),
});
const w = mount(Harness, { attachTo: document.body });
await nextTick();
expect(w.element.querySelector('input[name="should-be-ignored"]')).toBeNull();
w.unmount();
});
it('roving focus moves focus across members with arrow keys', async () => {
const w = mountGroup({ rovingFocus: true, orientation: 'horizontal' });
const boxes = Array.from(w.element.querySelectorAll('[role="checkbox"]')) as HTMLElement[];
boxes[0]!.focus();
await nextTick();
press(boxes[0]!, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(boxes[1]);
w.unmount();
});
it('without rovingFocus, no RovingFocusGroup container is rendered', async () => {
const w = mountGroup({ rovingFocus: false });
// Members are still functional checkboxes.
const boxes = w.element.querySelectorAll('[role="checkbox"]');
expect(boxes.length).toBe(3);
(boxes[0] as HTMLElement).click();
await nextTick();
expect(boxes[0]!.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
});
@@ -0,0 +1,39 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type CheckedState = boolean | 'indeterminate';
/**
* Values a checkbox can carry through a group model or a hidden form input. A
* plain boolean checkbox uses `boolean`, but `trueValue`/`falseValue` and group
* membership accept arbitrary primitives or plain objects.
*/
export type AcceptableValue = string | number | boolean | Record<string, unknown> | null;
export interface CheckboxContext {
checked: Ref<CheckedState>;
disabled: Ref<boolean>;
}
const ctx = useContextFactory<CheckboxContext>('CheckboxContext');
export const provideCheckboxContext = ctx.provide;
export const useCheckboxContext = ctx.inject;
/**
* Context published by `CheckboxGroupRoot`. A `CheckboxRoot` injects it with a
* `null` fallback; when present it switches to group mode — its checked state
* comes from membership in `modelValue` and toggling pushes/splices its `value`.
*/
export interface CheckboxGroupContext {
modelValue: Ref<AcceptableValue[]>;
disabled: Ref<boolean>;
rovingFocus: Ref<boolean>;
toggle: (value: AcceptableValue) => void;
isChecked: (value: AcceptableValue) => boolean;
}
const groupCtx = useContextFactory<CheckboxGroupContext>('CheckboxGroupContext');
export const provideCheckboxGroupContext = groupCtx.provide;
export const useCheckboxGroupContext = groupCtx.inject;
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { CheckedState } from '@robonen/primitives';
import { CheckboxIndicator, CheckboxRoot } from '@robonen/primitives';
import { computed, ref } from 'vue';
const ingredients = [
{ id: 'cheese', label: 'Extra cheese' },
{ id: 'mushrooms', label: 'Mushrooms' },
{ id: 'olives', label: 'Olives' },
];
const selected = ref<Record<string, boolean>>({
cheese: true,
mushrooms: false,
olives: false,
});
const checkedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
// Parent reflects the children: checked when all, unchecked when none, else indeterminate.
const allChecked = computed<CheckedState>(() => {
if (checkedCount.value === 0) return false;
if (checkedCount.value === ingredients.length) return true;
return 'indeterminate';
});
function toggleAll(next: CheckedState) {
const value = next === true;
for (const item of ingredients) selected.value[item.id] = value;
}
const acceptedTerms = ref(false);
</script>
<template>
<div class="flex flex-col gap-6 p-6 max-w-sm bg-bg text-fg border border-border rounded-xl">
<fieldset class="flex flex-col gap-3 m-0 p-0 border-0">
<legend class="text-sm font-semibold text-fg">
Toppings
</legend>
<label class="flex items-center gap-3 cursor-pointer select-none">
<CheckboxRoot
:checked="allChecked"
class="grid place-items-center w-5 h-5 rounded-md border border-border bg-bg-inset outline-none transition-colors data-[state=checked]:bg-accent data-[state=indeterminate]:bg-accent data-[state=checked]:border-accent data-[state=indeterminate]:border-accent focus-visible:ring-2 focus-visible:ring-ring"
@checked-change="toggleAll"
>
<CheckboxIndicator v-slot="{ checked }" class="text-accent-fg">
<svg v-if="checked === 'indeterminate'" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6h7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm font-medium">Select all</span>
<span class="ml-auto text-xs text-fg-subtle">{{ checkedCount }}/{{ ingredients.length }}</span>
</label>
<div class="flex flex-col gap-2 pl-2 border-l border-border">
<label v-for="item in ingredients" :key="item.id" class="flex items-center gap-3 cursor-pointer select-none">
<CheckboxRoot
v-model:checked="selected[item.id]"
class="grid place-items-center w-5 h-5 rounded-md border border-border bg-bg-inset outline-none transition-colors data-[state=checked]:bg-accent data-[state=checked]:border-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<CheckboxIndicator class="text-accent-fg">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm text-fg">{{ item.label }}</span>
</label>
</div>
</fieldset>
<label class="flex items-start gap-3 cursor-pointer select-none">
<CheckboxRoot
v-model:checked="acceptedTerms"
required
class="grid place-items-center w-5 h-5 mt-0.5 rounded-md border border-border bg-bg-inset outline-none transition-colors data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500 dark:data-[state=checked]:bg-emerald-400 dark:data-[state=checked]:border-emerald-400 focus-visible:ring-2 focus-visible:ring-ring"
>
<CheckboxIndicator class="text-white dark:text-bg">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm text-fg-muted">I accept the terms and conditions</span>
</label>
<p
class="text-xs"
:class="acceptedTerms ? 'text-emerald-600 dark:text-emerald-400' : 'text-fg-subtle'"
>
{{ acceptedTerms ? 'Ready to submit' : 'Please accept the terms to continue' }}
</p>
</div>
</template>
@@ -0,0 +1,15 @@
export { default as CheckboxGroupRoot } from './CheckboxGroupRoot.vue';
export { default as CheckboxIndicator } from './CheckboxIndicator.vue';
export { default as CheckboxRoot } from './CheckboxRoot.vue';
export type { AcceptableValue, CheckedState } from './context';
export {
provideCheckboxContext,
provideCheckboxGroupContext,
useCheckboxContext,
useCheckboxGroupContext,
} from './context';
export type { CheckboxContext, CheckboxGroupContext } from './context';
export type { CheckboxIndicatorProps } from './CheckboxIndicator.vue';
export type { CheckboxGroupRootEmits, CheckboxGroupRootProps } from './CheckboxGroupRoot.vue';
export type { CheckboxRootEmits, CheckboxRootProps } from './CheckboxRoot.vue';
export { getState, isIndeterminate } from './utils';
@@ -0,0 +1,18 @@
import type { CheckedState } from './context';
/**
* Shared checkbox state helpers, used by both `CheckboxRoot` and
* `CheckboxIndicator` so the indeterminate/`data-state` mapping cannot drift
* between the two parts.
*/
/** Narrows a {@link CheckedState} to the `'indeterminate'` literal. */
export function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
return checked === 'indeterminate';
}
/** Canonical `data-state` value for a {@link CheckedState}. */
export function getState(checked: CheckedState): 'checked' | 'unchecked' | 'indeterminate' {
if (isIndeterminate(checked)) return 'indeterminate';
return checked ? 'checked' : 'unchecked';
}
@@ -0,0 +1,35 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Wrapper that groups the preview and input. It mirrors edit/empty/disabled
* state via data attributes and, when `autoResize` is set, becomes the grid that
* sizes the input to match the preview text.
*/
export interface EditableAreaProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div' } = defineProps<EditableAreaProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="ctx.isEditing.value ? 'edit' : 'preview'"
:data-empty="ctx.isEmpty.value ? '' : undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:style="ctx.autoResize.value ? { display: 'inline-grid' } : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Button that discards the draft, restores the committed value, and leaves edit
* mode. It is only shown while editing.
*/
export interface EditableCancelTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<EditableCancelTriggerProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="cancel"
:disabled="ctx.disabled.value || undefined"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
:style="ctx.isEditing.value ? undefined : { display: 'none' }"
@click="ctx.cancel"
>
<slot>Cancel</slot>
</Primitive>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Button that enters edit mode. It is hidden while editing and disabled when the
* Root is disabled.
*/
export interface EditableEditTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<EditableEditTriggerProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="edit"
:disabled="ctx.disabled.value || undefined"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? '' : undefined"
:style="ctx.isEditing.value ? { display: 'none' } : undefined"
@click="ctx.edit"
>
<slot>Edit</slot>
</Primitive>
</template>
@@ -0,0 +1,91 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The text input shown while editing. It binds the draft value, auto-focuses
* (and optionally selects) when edit mode begins, and handles Enter to submit /
* Escape to cancel per the Root's `submitMode`.
*/
export interface EditableInputProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { nextTick, onMounted, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'input' } = defineProps<EditableInputProps>();
const ctx = useEditableContext();
const { forwardRef, currentElement } = useForwardExpose();
function syncRef(): void {
const el = currentElement.value as HTMLInputElement | undefined;
ctx.inputRef.value = el;
}
function focusAndSelect(): void {
const el = ctx.inputRef.value;
if (!el) return;
el.focus({ preventScroll: true });
if (ctx.selectOnFocus.value) el.select();
}
onMounted(() => {
syncRef();
if (ctx.startWithEditMode.value) focusAndSelect();
});
watch(ctx.isEditing, (editing) => {
if (!editing) return;
nextTick(() => {
syncRef();
focusAndSelect();
});
});
function onInput(event: Event): void {
ctx.inputValue.value = (event.target as HTMLInputElement).value;
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
ctx.cancel();
return;
}
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.isComposing) return;
const mode = ctx.submitMode.value;
if (mode === 'enter' || mode === 'both') {
event.preventDefault();
ctx.submit();
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="ctx.id.value"
:value="ctx.inputValue.value"
:placeholder="ctx.placeholder.value.edit"
:disabled="ctx.disabled.value"
:readonly="ctx.readonly.value"
:maxlength="ctx.maxLength.value"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:hidden="ctx.autoResize.value ? undefined : !ctx.isEditing.value"
:style="ctx.autoResize.value ? {
all: 'unset',
gridArea: '1 / 1 / auto / auto',
visibility: !ctx.isEditing.value ? 'hidden' : undefined,
} : (!ctx.isEditing.value ? { display: 'none' } : undefined)"
aria-label="editable input"
@input="onInput"
@keydown="onKeyDown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Read-only display of the current value (or the preview placeholder when
* empty). It is focusable and, depending on `activationMode`, enters edit mode
* on focus or double-click.
*/
export interface EditablePreviewProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<EditablePreviewProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
const text = computed(() => ctx.modelValue.value || ctx.placeholder.value.preview);
const showPlaceholder = computed(() => ctx.isEmpty.value);
function onFocus(): void {
if (ctx.activationMode.value === 'focus') ctx.edit();
}
function onDoubleClick(): void {
if (ctx.activationMode.value === 'dblclick') ctx.edit();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
tabindex="0"
:hidden="ctx.autoResize.value ? undefined : ctx.isEditing.value"
:data-placeholder-shown="showPlaceholder ? '' : undefined"
:data-state="ctx.isEditing.value ? 'edit' : 'preview'"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:style="ctx.autoResize.value ? {
whiteSpace: 'pre',
userSelect: 'none',
gridArea: '1 / 1 / auto / auto',
visibility: ctx.isEditing.value ? 'hidden' : undefined,
overflow: 'hidden',
textOverflow: 'ellipsis',
} : (ctx.isEditing.value ? { display: 'none' } : undefined)"
@focusin="onFocus"
@dblclick="onDoubleClick"
>
<slot>{{ text }}</slot>
</Primitive>
</template>
@@ -0,0 +1,254 @@
<script lang="ts">
import type { EditableActivationMode, EditablePlaceholder, EditableSubmitMode } from './context';
import type { Direction } from '../../utilities/config-provider';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Inline-editable text field that toggles between a read-only preview and an
* editable input. Root owns the value (via `v-model`), edit state, and submit /
* cancel behavior, providing them to its parts. Use it for click-to-edit labels,
* titles, and table cells where a full form input would be heavy.
*/
export interface EditableRootProps extends PrimitiveProps {
/** Uncontrolled initial value. @default '' */
defaultValue?: string;
/** Placeholder for edit / preview. A single string applies to both. */
placeholder?: string | EditablePlaceholder;
/** When the preview should switch to edit mode. @default 'focus' */
activationMode?: EditableActivationMode;
/** How edits are committed. @default 'blur' */
submitMode?: EditableSubmitMode;
/** Mount in edit mode. */
startWithEditMode?: boolean;
/** Select the input content on focus. */
selectOnFocus?: boolean;
/** Grid-based auto resize mode — preview and input share a grid cell. */
autoResize?: boolean;
/** Max input length. */
maxLength?: number;
/** Disabled state. */
disabled?: boolean;
/** Read-only state. */
readonly?: boolean;
/**
* Reading direction. When omitted, inherits from the active `ConfigProvider`
* and otherwise assumes left-to-right.
*/
dir?: Direction;
/** Stable id, applied to the input and forwarded through context. */
id?: string;
/**
* Native control name. When set and the Root is inside a `<form>`, a
* visually-hidden `<input>` submits the value with the surrounding form.
*/
name?: string;
/** Mark the field required so native form validation fires on empty submit. */
required?: boolean;
}
export interface EditableRootEmits {
'update:state': [state: 'edit' | 'submit' | 'cancel'];
submit: [value: string];
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideEditableContext } from './context';
import { useEventListener, useForwardExpose, useTimeoutFn } from '@robonen/vue';
import { useDirection, useId } from '../../utilities/config-provider';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue = '',
placeholder = 'Enter text…',
activationMode = 'focus',
submitMode = 'blur',
startWithEditMode = false,
selectOnFocus = false,
autoResize = false,
maxLength,
disabled = false,
readonly = false,
dir: dirProp,
id,
name,
required = false,
} = defineProps<EditableRootProps>();
const emit = defineEmits<EditableRootEmits>();
const dir = useDirection(() => dirProp);
const resolvedId = useId(() => id, 'editable');
// Uncontrolled fallback, seeded from `defaultValue`. In controlled mode the
// `get` reads the live prop, so local state can never go stale.
const localValue = ref<string>(defaultValue);
const model = defineModel<string>({
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const inputValue = ref<string>(model.value);
const isEditing = ref<boolean>(startWithEditMode);
const inputRef = shallowRef<HTMLInputElement | undefined>();
// Keep the draft in sync when the committed value changes from outside.
watch(model, (v) => {
inputValue.value = v;
});
const resolvedPlaceholder = computed<EditablePlaceholder>(() =>
typeof placeholder === 'string'
? { edit: placeholder, preview: placeholder }
: placeholder,
);
const isEmpty = computed(() => model.value === '');
function commitModel(v: string): void {
if (v === model.value) return;
model.value = v;
}
function edit(): void {
if (disabled || readonly) return;
inputValue.value = model.value;
isEditing.value = true;
emit('update:state', 'edit');
}
function cancel(): void {
if (!isEditing.value) return;
isEditing.value = false;
inputValue.value = model.value;
emit('update:state', 'cancel');
}
function submit(): void {
if (!isEditing.value) return;
commitModel(inputValue.value);
isEditing.value = false;
emit('update:state', 'submit');
emit('submit', inputValue.value);
}
// `defineExpose` runs before `useForwardExpose` so the imperative methods are
// merged onto the instance's exposed object alongside the forwarded element.
defineExpose({ edit, cancel, submit });
const { forwardRef, currentElement } = useForwardExpose();
const isFormControl = computed(() => {
const el = currentElement.value;
return !!el && !!el.closest('form');
});
// Commit or discard on an outside interaction, honoring `submitMode`.
function handleDismiss(): void {
if (!isEditing.value) return;
if (submitMode === 'blur' || submitMode === 'both') submit();
else cancel();
}
// Deferred dismiss check. Hiding the focused preview/trigger on entering edit
// mode fires a synchronous focusout with relatedTarget=null before the input's
// autofocus lands — defer the decision and re-check where focus actually ended
// up. Also covers the `relatedTarget === null` browser case where blur reports
// no next target even though focus stayed within the root. Auto-cancelled on
// scope dispose.
const { start: deferDismissCheck, stop: cancelDismissCheck } = useTimeoutFn(() => {
if (!isEditing.value) return;
const root = currentElement.value;
const active = document.activeElement;
if (root && active && root.contains(active)) return;
handleDismiss();
}, 0, { immediate: false });
function onFocusOutCapture(event: FocusEvent): void {
if (!isEditing.value) return;
const root = currentElement.value;
const next = event.relatedTarget as Node | null;
if (root && next && root.contains(next)) return;
cancelDismissCheck();
deferDismissCheck();
}
// Pointer-down outside the root also dismisses, covering clicks on
// non-focusable regions that never move DOM focus (and thus never fire a
// dismissing `focusout`). Document-level + capture so it sees the interaction
// before it is swallowed; auto-disposed with the component scope.
useEventListener(
() => (typeof document === 'undefined' ? undefined : document),
'pointerdown',
(event: Event) => {
if (!isEditing.value) return;
const root = currentElement.value;
const target = event.target as Node | null;
if (root && target && (root === target || root.contains(target))) return;
handleDismiss();
},
{ capture: true },
);
provideEditableContext({
modelValue: model,
inputValue,
isEditing,
placeholder: resolvedPlaceholder,
isEmpty,
disabled: toRef(() => disabled),
readonly: toRef(() => readonly),
maxLength: toRef(() => maxLength),
activationMode: toRef(() => activationMode),
submitMode: toRef(() => submitMode),
selectOnFocus: toRef(() => selectOnFocus),
autoResize: toRef(() => autoResize),
startWithEditMode: toRef(() => startWithEditMode),
id: resolvedId,
dir,
inputRef,
edit,
cancel,
submit,
});
</script>
<template>
<Primitive
v-bind="$attrs"
:ref="forwardRef"
:as="as"
:dir="dir"
:data-state="isEditing ? 'edit' : 'preview'"
:data-empty="isEmpty ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
@focusout.capture="onFocusOutCapture"
>
<slot
:model-value="model"
:is-editing="isEditing"
:is-empty="isEmpty"
:edit="edit"
:cancel="cancel"
:submit="submit"
/>
<VisuallyHiddenInput
v-if="isFormControl && name"
type="text"
:name="name"
:value="model"
:required="required"
:disabled="disabled"
/>
</Primitive>
</template>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Button that commits the draft value and leaves edit mode. It is only shown
* while editing.
*/
export interface EditableSubmitTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useEditableContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<EditableSubmitTriggerProps>();
const ctx = useEditableContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="submit"
:disabled="ctx.disabled.value || undefined"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:hidden="ctx.isEditing.value ? undefined : ''"
:style="ctx.isEditing.value ? undefined : { display: 'none' }"
@click="ctx.submit"
>
<slot>Submit</slot>
</Primitive>
</template>
@@ -0,0 +1,442 @@
import {
EditableArea,
EditableCancelTrigger,
EditableEditTrigger,
EditableInput,
EditablePreview,
EditableRoot,
EditableSubmitTrigger,
} from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createEditable(rootProps: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
rootProps,
{
default: () => h(EditableArea, null, {
default: () => [
h(EditablePreview),
h(EditableInput),
h(EditableEditTrigger),
h(EditableSubmitTrigger),
h(EditableCancelTrigger),
],
}),
},
);
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
// EditableRoot defers its outside-blur decision by a macrotask.
function waitForBlurTimers() {
return new Promise(resolve => setTimeout(resolve, 20));
}
describe('Editable', () => {
it('renders preview with default placeholder when empty', () => {
const w = createEditable({ placeholder: 'Click to edit' });
const preview = w.find('span');
expect(preview.text()).toBe('Click to edit');
expect(preview.attributes('data-placeholder-shown')).toBe('');
w.unmount();
});
it('renders model value in preview', () => {
const w = createEditable({ modelValue: 'Hello' });
expect(w.find('span').text()).toBe('Hello');
w.unmount();
});
it('focus activation enters edit mode', async () => {
const w = createEditable({ defaultValue: 'X', activationMode: 'focus' });
await w.find('span').trigger('focusin');
await nextTick();
const input = w.find('input').element as HTMLInputElement;
expect(input.hidden).toBe(false);
expect((w.find('span').element as HTMLElement).hidden).toBe(true);
w.unmount();
});
it('dblclick activation enters edit mode only on dblclick', async () => {
const w = createEditable({ activationMode: 'dblclick' });
await w.find('span').trigger('focusin');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
await w.find('span').trigger('dblclick');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
w.unmount();
});
it('edit trigger switches to edit mode', async () => {
const w = createEditable({ activationMode: 'none' });
const buttons = w.findAll('button');
const editBtn = buttons.find(b => b.attributes('aria-label') === 'edit')!;
await editBtn.trigger('click');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
w.unmount();
});
it('Enter submits when submitMode includes enter', async () => {
const value = ref('initial');
const w = mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
{
modelValue: value.value,
submitMode: 'enter',
startWithEditMode: true,
'onUpdate:modelValue': (v: string) => (value.value = v),
},
{
default: () => h(EditableArea, null, {
default: () => [h(EditablePreview), h(EditableInput)],
}),
},
);
},
}),
{ attachTo: document.body },
);
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'changed';
await input.trigger('input');
press(input.element, 'Enter');
await nextTick();
expect(value.value).toBe('changed');
w.unmount();
});
it('Escape cancels and restores model value', async () => {
const w = createEditable({ defaultValue: 'orig', submitMode: 'enter' });
const editBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!;
await editBtn.trigger('click');
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'new';
await input.trigger('input');
press(input.element, 'Escape');
await nextTick();
expect(w.find('span').text()).toBe('orig');
w.unmount();
});
it('submit trigger emits submit', async () => {
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
const submitBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'submit')!;
await submitBtn.trigger('click');
await nextTick();
const root = w.findComponent(EditableRoot);
expect(root.emitted('update:modelValue')?.at(-1)).toEqual(['v2']);
expect(root.emitted('submit')?.at(-1)).toEqual(['v2']);
w.unmount();
});
it('cancel trigger reverts draft', async () => {
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'draft';
await input.trigger('input');
const cancelBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'cancel')!;
await cancelBtn.trigger('click');
await nextTick();
expect(w.find('span').text()).toBe('v1');
expect(w.findComponent(EditableRoot).emitted('update:modelValue')).toBeUndefined();
w.unmount();
});
it('disabled blocks edit activation', async () => {
const w = createEditable({ defaultValue: 'v', disabled: true });
await w.find('span').trigger('focusin');
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
w.unmount();
});
it('keeps edit mode when the focused edit trigger hides itself (real focus)', async () => {
const w = createEditable({ defaultValue: 'v', activationMode: 'none' });
const editBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!;
(editBtn.element as HTMLButtonElement).focus();
(editBtn.element as HTMLButtonElement).click();
await nextTick();
await waitForBlurTimers();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
expect(document.activeElement).toBe(w.find('input').element);
expect(w.findComponent(EditableRoot).emitted('update:state')?.flat()).toEqual(['edit']);
w.unmount();
});
it('keeps edit mode when the really-focused preview hides itself (focus activation)', async () => {
const w = createEditable({ defaultValue: 'v', activationMode: 'focus' });
(w.find('span').element as HTMLElement).focus();
await nextTick();
await waitForBlurTimers();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
expect(document.activeElement).toBe(w.find('input').element);
expect(w.findComponent(EditableRoot).emitted('update:state')?.flat()).toEqual(['edit']);
w.unmount();
});
it('still submits when focus genuinely leaves the root (submitMode blur)', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true, submitMode: 'blur' });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).focus();
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
outside.focus();
await waitForBlurTimers();
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')?.at(-1)).toEqual(['v2']);
expect((w.find('input').element as HTMLInputElement).hidden).toBe(true);
outside.remove();
w.unmount();
});
it('hides parts even when consumer display utility classes override [hidden]', async () => {
const style = document.createElement('style');
style.textContent = '.u-block { display: block; } .u-inline-flex { display: inline-flex; }';
document.head.appendChild(style);
const w = mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
{ defaultValue: 'v', activationMode: 'none' },
{
default: () => h(EditableArea, null, {
default: () => [
h(EditablePreview, { class: 'u-block' }),
h(EditableInput, { class: 'u-block' }),
h(EditableEditTrigger, { class: 'u-inline-flex' }),
h(EditableSubmitTrigger, { class: 'u-inline-flex' }),
h(EditableCancelTrigger, { class: 'u-inline-flex' }),
],
}),
},
);
},
}),
{ attachTo: document.body },
);
const button = (label: string) =>
w.findAll('button').find(b => b.attributes('aria-label') === label)!.element as HTMLElement;
expect(getComputedStyle(w.find('input').element).display).toBe('none');
expect(getComputedStyle(button('submit')).display).toBe('none');
expect(getComputedStyle(button('cancel')).display).toBe('none');
expect(getComputedStyle(button('edit')).display).toBe('inline-flex');
await w.findAll('button').find(b => b.attributes('aria-label') === 'edit')!.trigger('click');
await nextTick();
expect(getComputedStyle(w.find('span').element).display).toBe('none');
expect(getComputedStyle(button('edit')).display).toBe('none');
expect(getComputedStyle(w.find('input').element).display).toBe('block');
expect(getComputedStyle(button('submit')).display).toBe('inline-flex');
expect(getComputedStyle(button('cancel')).display).toBe('inline-flex');
style.remove();
w.unmount();
});
it('submit and cancel triggers are no-ops while not editing', async () => {
const w = createEditable({ defaultValue: 'v1' });
const submitBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'submit')!;
const cancelBtn = w.findAll('button').find(b => b.attributes('aria-label') === 'cancel')!;
await submitBtn.trigger('click');
await cancelBtn.trigger('click');
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')).toBeUndefined();
expect(root.emitted('update:state')).toBeUndefined();
w.unmount();
});
it('threads id prop onto the input', () => {
const w = createEditable({ id: 'my-field', defaultValue: 'v' });
expect(w.find('input').attributes('id')).toBe('my-field');
w.unmount();
});
it('binds resolved dir on the root', () => {
const w = createEditable({ dir: 'rtl', defaultValue: 'v' });
expect(w.findComponent(EditableRoot).element.getAttribute('dir')).toBe('rtl');
w.unmount();
});
it('defaults dir to ltr when no override is provided', () => {
const w = createEditable({ defaultValue: 'v' });
expect(w.findComponent(EditableRoot).element.getAttribute('dir')).toBe('ltr');
w.unmount();
});
it('exposes imperative edit/cancel/submit via a template ref', async () => {
const exposedRef = ref<{ edit: () => void; cancel: () => void; submit: () => void } | null>(null);
const w = mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
{ ref: exposedRef as unknown as Record<string, unknown>, defaultValue: 'v' },
{
default: () => h(EditableArea, null, {
default: () => [h(EditablePreview), h(EditableInput)],
}),
},
);
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(typeof exposedRef.value?.edit).toBe('function');
expect(typeof exposedRef.value?.cancel).toBe('function');
expect(typeof exposedRef.value?.submit).toBe('function');
exposedRef.value!.edit();
await nextTick();
expect((w.find('input').element as HTMLInputElement).hidden).toBe(false);
w.unmount();
});
it('sets aria-disabled on triggers when disabled', () => {
const w = createEditable({ defaultValue: 'v', disabled: true });
w.findAll('button').forEach((b) => {
expect(b.attributes('aria-disabled')).toBe('true');
});
w.unmount();
});
it('omits aria-disabled on triggers when enabled', () => {
const w = createEditable({ defaultValue: 'v' });
w.findAll('button').forEach((b) => {
expect(b.attributes('aria-disabled')).toBeUndefined();
});
w.unmount();
});
it('does not render a hidden form input without a surrounding form', () => {
const w = createEditable({ defaultValue: 'v', name: 'field' });
const inputs = w.findAll('input');
// Only the EditableInput exists; no visually-hidden form input.
expect(inputs.filter(i => i.attributes('name') === 'field')).toHaveLength(0);
w.unmount();
});
it('renders a hidden form input mirroring the value inside a form', async () => {
const w = mount(
defineComponent({
setup() {
return () => h('form', null, [
h(
EditableRoot,
{ defaultValue: 'submitted-value', name: 'field' },
{
default: () => h(EditableArea, null, {
default: () => [h(EditablePreview), h(EditableInput)],
}),
},
),
]);
},
}),
{ attachTo: document.body },
);
await nextTick();
const hidden = w.findAll('input').find(i => i.attributes('name') === 'field');
expect(hidden).toBeTruthy();
expect((hidden!.element as HTMLInputElement).value).toBe('submitted-value');
w.unmount();
});
it('mirrors required onto the hidden form input', async () => {
const w = mount(
defineComponent({
setup() {
return () => h('form', null, [
h(
EditableRoot,
{ defaultValue: '', name: 'field', required: true },
{
default: () => h(EditableArea, null, {
default: () => [h(EditablePreview), h(EditableInput)],
}),
},
),
]);
},
}),
{ attachTo: document.body },
);
await nextTick();
const hidden = w.findAll('input').find(i => i.attributes('name') === 'field');
expect(hidden!.attributes('required')).toBeDefined();
w.unmount();
});
it('commits on pointerdown outside the root (submitMode blur)', async () => {
const outside = document.createElement('div');
document.body.appendChild(outside);
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true, submitMode: 'blur' });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
await waitForBlurTimers();
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')?.at(-1)).toEqual(['v2']);
outside.remove();
w.unmount();
});
it('cancels on pointerdown outside the root (submitMode enter)', async () => {
const outside = document.createElement('div');
document.body.appendChild(outside);
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true, submitMode: 'enter' });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
await waitForBlurTimers();
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')).toBeUndefined();
expect(w.find('span').text()).toBe('v1');
outside.remove();
w.unmount();
});
it('does not dismiss on pointerdown inside the root', async () => {
const w = createEditable({ defaultValue: 'v1', startWithEditMode: true, submitMode: 'blur' });
await nextTick();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'v2';
await input.trigger('input');
input.element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
await waitForBlurTimers();
const root = w.findComponent(EditableRoot);
expect(root.emitted('submit')).toBeUndefined();
expect((input.element as HTMLInputElement).hidden).toBe(false);
w.unmount();
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import axe from 'axe-core';
import {
EditableArea,
EditableCancelTrigger,
EditableEditTrigger,
EditableInput,
EditablePreview,
EditableRoot,
EditableSubmitTrigger,
} from '../index';
async function checkA11y(element: Element) {
const results = await axe.run(element);
return results.violations;
}
function createEditable(rootProps: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
return () => h(
EditableRoot,
rootProps,
{
default: () => h(EditableArea, null, {
default: () => [
h(EditablePreview),
h(EditableInput),
h(EditableEditTrigger),
h(EditableSubmitTrigger),
h(EditableCancelTrigger),
],
}),
},
);
},
}),
{ attachTo: document.body },
);
}
describe('Editable a11y', () => {
it('has no axe violations in preview mode', async () => {
const w = createEditable({ defaultValue: 'Hello' });
const violations = await checkA11y(w.element);
expect(violations).toEqual([]);
w.unmount();
});
it('has no axe violations in edit mode', async () => {
const w = createEditable({ defaultValue: 'Hello', startWithEditMode: true });
await nextTick();
const violations = await checkA11y(w.element);
expect(violations).toEqual([]);
w.unmount();
});
it('has no axe violations when disabled', async () => {
const w = createEditable({ defaultValue: 'Hello', disabled: true });
const violations = await checkA11y(w.element);
expect(violations).toEqual([]);
w.unmount();
});
it('has no axe violations when readonly', async () => {
const w = createEditable({ defaultValue: 'Hello', readonly: true, startWithEditMode: true });
await nextTick();
const violations = await checkA11y(w.element);
expect(violations).toEqual([]);
w.unmount();
});
it('keeps the input labelled', () => {
const w = createEditable({ defaultValue: 'Hello' });
expect(w.find('input').attributes('aria-label')).toBe('editable input');
w.unmount();
});
});
@@ -0,0 +1,50 @@
import type { ComputedRef, Ref, ShallowRef } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import { useContextFactory } from '@robonen/vue';
export type EditableActivationMode = 'focus' | 'dblclick' | 'none';
export type EditableSubmitMode = 'blur' | 'enter' | 'none' | 'both';
export interface EditablePlaceholder {
edit: string;
preview: string;
}
export interface EditableContext {
/** Current committed value (mirrors v-model). */
modelValue: Ref<string>;
/** Draft value bound to the input while editing. */
inputValue: Ref<string>;
/** Whether the component is in edit mode. */
isEditing: Ref<boolean>;
/** Resolved placeholder per mode. */
placeholder: Ref<EditablePlaceholder>;
/** Whether `modelValue` is empty. */
isEmpty: Ref<boolean>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
maxLength: Ref<number | undefined>;
activationMode: Ref<EditableActivationMode>;
submitMode: Ref<EditableSubmitMode>;
selectOnFocus: Ref<boolean>;
autoResize: Ref<boolean>;
startWithEditMode: Ref<boolean>;
/** Stable id, threaded for aria wiring on parts. */
id: Ref<string>;
/** Resolved reading direction (per-Root prop over `ConfigProvider`). */
dir: ComputedRef<Direction>;
/** Reactive ref to the `<EditableInput>` element, used for focus/select. */
inputRef: ShallowRef<HTMLInputElement | undefined>;
edit: () => void;
cancel: () => void;
submit: () => void;
}
export const {
inject: useEditableContext,
provide: provideEditableContext,
} = useContextFactory<EditableContext>('editable');
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
EditableArea,
EditableCancelTrigger,
EditableEditTrigger,
EditableInput,
EditablePreview,
EditableRoot,
EditableSubmitTrigger,
} from '@robonen/primitives';
const name = ref('Ada Lovelace');
</script>
<template>
<div class="demo-card w-full max-w-sm p-4 text-fg">
<span class="mb-2 block text-xs font-medium text-fg-muted">Display name</span>
<EditableRoot
v-model="name"
submit-mode="both"
select-on-focus
activation-mode="dblclick"
placeholder="Add your name…"
class="flex items-center gap-2"
>
<EditableArea class="flex-1">
<EditablePreview
class="block w-full cursor-text rounded-md px-2 py-1.5 outline-none transition hover:bg-bg-inset data-[placeholder-shown]:text-fg-subtle focus-visible:ring-2 focus-visible:ring-ring"
/>
<EditableInput
class="w-full rounded-md border border-border bg-bg px-2 py-1.5 text-fg outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</EditableArea>
<EditableEditTrigger
class="inline-flex h-8 items-center rounded-md border border-border bg-bg px-2.5 text-sm text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
Edit
</EditableEditTrigger>
<EditableSubmitTrigger
class="inline-flex h-8 items-center rounded-md bg-accent px-2.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-95 cursor-pointer"
>
Save
</EditableSubmitTrigger>
<EditableCancelTrigger
class="inline-flex h-8 items-center rounded-md border border-border bg-bg px-2.5 text-sm text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer"
>
Cancel
</EditableCancelTrigger>
</EditableRoot>
<p class="mt-3 text-xs text-fg-subtle">
Double-click the name to edit. Enter or blur saves, Escape cancels.
</p>
</div>
</template>
@@ -0,0 +1,24 @@
export { default as EditableRoot } from './EditableRoot.vue';
export { default as EditableArea } from './EditableArea.vue';
export { default as EditablePreview } from './EditablePreview.vue';
export { default as EditableInput } from './EditableInput.vue';
export { default as EditableEditTrigger } from './EditableEditTrigger.vue';
export { default as EditableSubmitTrigger } from './EditableSubmitTrigger.vue';
export { default as EditableCancelTrigger } from './EditableCancelTrigger.vue';
export {
provideEditableContext,
useEditableContext,
type EditableContext,
type EditableActivationMode,
type EditableSubmitMode,
type EditablePlaceholder,
} from './context';
export type { EditableRootProps, EditableRootEmits } from './EditableRoot.vue';
export type { EditableAreaProps } from './EditableArea.vue';
export type { EditablePreviewProps } from './EditablePreview.vue';
export type { EditableInputProps } from './EditableInput.vue';
export type { EditableEditTriggerProps } from './EditableEditTrigger.vue';
export type { EditableSubmitTriggerProps } from './EditableSubmitTrigger.vue';
export type { EditableCancelTriggerProps } from './EditableCancelTrigger.vue';
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A caption associated with a form control. Renders a native `<label>` and,
* when `for` matches a control's id, lets clicks focus or toggle that control
* while announcing the text to assistive technology. Double-click text
* selection is suppressed so labels stay clickable. Use it to label inputs,
* checkboxes, switches, and other custom controls.
*/
export interface LabelProps extends PrimitiveProps {
/** The id of the element the label is associated with (renders as `for`). */
for?: string;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
const { forwardRef } = useForwardExpose();
const { as = 'label', for: htmlFor } = defineProps<LabelProps>();
function onMouseDown(event: MouseEvent) {
// Prevent text selection when double-clicking a label.
if (!event.defaultPrevented && event.detail > 1) event.preventDefault();
}
</script>
<template>
<Primitive :ref="forwardRef" :as="as" :for="htmlFor" @mousedown="onMouseDown">
<slot />
</Primitive>
</template>
@@ -0,0 +1,130 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { Label } from '../index';
let mounted: ReturnType<typeof mount> | undefined;
afterEach(() => {
mounted?.unmount();
mounted = undefined;
});
describe('Label', () => {
it('renders a native <label>', () => {
const wrapper = mount(Label, { slots: { default: 'Name' } });
expect(wrapper.element.tagName).toBe('LABEL');
expect(wrapper.text()).toBe('Name');
});
it('forwards `for` to the `for` attribute', () => {
const wrapper = mount(Label, { props: { for: 'my-input' } });
expect(wrapper.attributes('for')).toBe('my-input');
});
it('renders nothing extra with neither slot nor `for`', () => {
const wrapper = mount(Label);
expect(wrapper.html()).toBe('<label></label>');
});
it('renders `for` together with slot content', () => {
const wrapper = mount(Label, { props: { for: 'input' }, slots: { default: 'Name' } });
expect(wrapper.html()).toBe('<label for="input">Name</label>');
});
it('prevents text selection on multi-click', () => {
const wrapper = mount(defineComponent({
setup: () => () => h(Label, null, { default: () => 'x' }),
}));
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 2 });
wrapper.element.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('does not prevent default on single click', () => {
const wrapper = mount(Label);
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 1 });
wrapper.element.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
it('does not re-prevent default when already prevented', () => {
const wrapper = mount(Label);
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 3 });
event.preventDefault();
wrapper.element.dispatchEvent(event);
// Stays prevented but the handler short-circuits on defaultPrevented.
expect(event.defaultPrevented).toBe(true);
});
describe('polymorphism', () => {
it('renders as a custom element via `as`', () => {
const wrapper = mount(Label, { props: { as: 'span' }, slots: { default: 'Name' } });
expect(wrapper.element.tagName).toBe('SPAN');
expect(wrapper.text()).toBe('Name');
});
it('merges behaviour onto the slotted child via as="template"', () => {
// as="template" is this package's composition mechanism: the label's
// props (for) and listeners (mousedown) are merged onto the single
// slotted child instead of rendering an extra wrapper element.
const wrapper = mount(Label, {
props: { as: 'template', for: 'merged' },
slots: { default: () => h('button', { type: 'button' }, 'Pick') },
});
expect(wrapper.element.tagName).toBe('BUTTON');
expect(wrapper.attributes('for')).toBe('merged');
});
it('forwards mousedown handling through as="template"', () => {
const wrapper = mount(defineComponent({
setup: () => () => h(Label, { as: 'template' }, {
default: () => h('span', null, 'x'),
}),
}));
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, detail: 2 });
wrapper.element.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
});
describe('control association edge cases', () => {
it('does not move focus when clicked without a `for`', async () => {
const wrapper = mount(defineComponent({
setup: () => () => h('div', [
h(Label, null, { default: () => 'Name' }),
h('input', { id: 'input' }),
]),
}), { attachTo: document.body });
mounted = wrapper;
wrapper.element.querySelector('label')!.click();
await nextTick();
expect(document.activeElement).not.toBe(wrapper.element.querySelector('input'));
});
it('does not move focus when `for` points to a missing id', async () => {
const wrapper = mount(defineComponent({
setup: () => () => h('div', [
h(Label, { for: 'missing' }, { default: () => 'Name' }),
h('input', { id: 'present' }),
]),
}), { attachTo: document.body });
mounted = wrapper;
wrapper.element.querySelector('label')!.click();
await nextTick();
expect(document.activeElement).not.toBe(wrapper.element.querySelector('input'));
});
});
describe('ref forwarding', () => {
it('exposes the underlying element via forwardRef', () => {
const wrapper = mount(Label, { props: { for: 'x' }, slots: { default: 'Name' } });
const exposed = wrapper.vm.$el as HTMLElement;
expect(exposed.tagName).toBe('LABEL');
});
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import axe from 'axe-core';
import { Label } from '../index';
async function checkA11y(element: Element) {
const results = await axe.run(element);
return results.violations;
}
function createLabelledField(props: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
return () =>
h('div', [
h(Label, { for: 'input', ...props }, { default: () => 'Name' }),
h('input', { id: 'input', type: 'text' }),
]);
},
}),
{ attachTo: document.body },
);
}
describe('Label a11y', () => {
it('has no axe violations when associated with a native control', async () => {
const wrapper = createLabelledField();
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
it('has no axe violations when wrapping a control', async () => {
const wrapper = mount(
defineComponent({
setup() {
return () =>
h('div', [
h(Label, null, {
default: () => [
'Subscribe',
h('input', { id: 'checkbox', type: 'checkbox' }),
],
}),
]);
},
}),
{ attachTo: document.body },
);
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
});
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { Label } from '@robonen/primitives';
import { ref } from 'vue';
const email = ref('');
const notify = ref(true);
</script>
<template>
<form
class="demo-card flex w-full max-w-sm flex-col gap-5 p-5 text-fg"
@submit.prevent
>
<div class="flex flex-col gap-1.5">
<Label
for="demo-email"
class="text-sm font-medium select-none"
>
Email address
</Label>
<input
id="demo-email"
v-model="email"
type="email"
placeholder="you@example.com"
class="rounded-md border border-border bg-bg-inset px-3 py-2 text-sm outline-none focus:border-accent focus:ring-2 focus:ring-ring"
>
<p class="text-xs text-fg-subtle">
Click the label above to focus the field.
</p>
</div>
<Label
for="demo-notify"
class="flex cursor-pointer items-center gap-2.5 text-sm select-none"
>
<input
id="demo-notify"
v-model="notify"
type="checkbox"
class="size-4 accent-accent"
>
Email me about product updates
</Label>
<p
class="text-sm"
:class="notify
? 'text-emerald-600 dark:text-emerald-400'
: 'text-fg-muted'"
>
{{ notify ? 'You will receive updates.' : 'Updates are off.' }}
</p>
</form>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as Label } from './Label.vue';
export type { LabelProps } from './Label.vue';
@@ -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, '\\$&');
}
@@ -0,0 +1,176 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
const DIGIT_RE = /\d/;
const NON_DIGIT_G = /\D/g;
/**
* A single cell of the pin input, identified by its zero-based `index`. Renders
* one masked-or-plain character box that reads/writes its slot of the root's
* value and handles typing (auto-advancing to the next cell), Backspace/Delete,
* arrow/Home/End navigation, and paste (spreading text across cells). Render one
* per character, with `index` from `0` to `length - 1`.
*
* Polymorphic via `as` (defaults to a native `<input>`); `as="template"` merges
* onto a single child. A per-cell `disabled` prop is honored on top of the
* root-level `disabled` and is skipped by arrow navigation.
*/
export interface PinInputInputProps extends PrimitiveProps {
index: number;
/** Disable this individual cell (merged with the root-level `disabled`). */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { rovingKeyToAction } from '../../internal/utils/roving-focus';
import { usePinInputContext } from './context';
const { index, disabled = false, as = 'input' } = defineProps<PinInputInputProps>();
const ctx = usePinInputContext();
const { forwardRef, currentElement } = useForwardExpose();
// `currentElement` works through `as="template"` and polymorphic tags, so the
// cell registers whatever element it resolves to.
watch(currentElement, (curr, prev) => {
if (prev)
ctx.unregister(prev as HTMLInputElement);
if (curr)
ctx.register(curr as HTMLInputElement);
});
const isDisabled = computed(() => disabled || ctx.disabled.value);
const displayed = computed(() => {
const ch = ctx.value.value[index] ?? '';
if (!ch)
return '';
return ctx.mask.value ? '•' : ch;
});
// Hide the placeholder on the focused empty cell for a cleaner look; restore it
// on blur (or once a value lands, since `displayed` then takes over).
const focused = ref(false);
const placeholderText = computed(() =>
focused.value && !ctx.value.value[index] ? '' : ctx.placeholder.value);
function onInput(e: Event): void {
const target = e.target as HTMLInputElement;
const raw = target.value;
// keep only the last typed character
let ch = raw.length > 0 ? raw[raw.length - 1]! : '';
if (ctx.type.value === 'number' && ch && !DIGIT_RE.test(ch))
ch = '';
ctx.setAt(index, ch);
// re-sync DOM input since we overwrite with displayed
target.value = ch ? (ctx.mask.value ? '•' : ch) : '';
if (ch && index < ctx.length.value - 1)
ctx.focusRelative(index, 1);
}
function onKeyDown(e: KeyboardEvent): void {
const i = index;
switch (e.key) {
case 'Backspace': {
const current = ctx.value.value[i] ?? '';
if (current) {
ctx.clearAt(i);
}
else if (i > 0) {
ctx.focusIndex(i - 1);
ctx.clearAt(i - 1);
}
e.preventDefault();
return;
}
case 'Delete': {
ctx.clearAt(i);
e.preventDefault();
return;
}
}
// Arrow/Home/End navigation is direction-aware (RTL flips left/right) and
// skips disabled cells. Vertical keys are ignored for this horizontal field.
const action = rovingKeyToAction(e, { orientation: 'horizontal', dir: ctx.dir.value, loop: false });
if (!action)
return;
e.preventDefault();
ctx.focusRelative(i, action.delta, action.absolute);
}
function onFocus(e: FocusEvent): void {
// OTP sequential-fill guard: never let the user start in the middle, redirect
// to the first empty cell so the code is entered left-to-right without gaps.
if (ctx.otp.value) {
const firstEmpty = ctx.firstEmptyIndex();
if (firstEmpty !== -1 && firstEmpty < index) {
ctx.focusIndex(firstEmpty);
return;
}
}
focused.value = true;
const target = e.target as HTMLInputElement;
// Place the caret after the (single) character so typing overwrites predictably.
try {
target.setSelectionRange(1, 1);
}
catch {
/* noop — non-text inputs */
}
}
function onBlur(): void {
focused.value = false;
}
function onPaste(e: ClipboardEvent): void {
const data = e.clipboardData?.getData('text') ?? '';
if (!data)
return;
e.preventDefault();
const chars = ctx.type.value === 'number'
? data.replaceAll(NON_DIGIT_G, '').split('')
: data.split('');
let idx = index;
for (const ch of chars) {
if (idx >= ctx.length.value)
break;
ctx.setAt(idx, ch);
idx++;
}
ctx.focusIndex(Math.min(idx, ctx.length.value - 1));
}
const inputType = computed(() => (ctx.mask.value ? 'password' : 'text'));
const inputMode = computed(() => (ctx.type.value === 'number' ? 'numeric' : 'text'));
const ariaLabel = computed(() => `pin input ${index + 1} of ${ctx.length.value}`);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="inputType"
autocapitalize="none"
:inputmode="inputMode"
:pattern="ctx.type.value === 'number' ? '[0-9]*' : undefined"
:value="displayed"
:placeholder="placeholderText"
:disabled="isDisabled || undefined"
:autocomplete="ctx.otp.value ? 'one-time-code' : 'off'"
:aria-label="ariaLabel"
:data-index="index"
:data-disabled="isDisabled ? '' : undefined"
:data-complete="ctx.isComplete.value ? '' : undefined"
maxlength="1"
@input="onInput"
@keydown="onKeyDown"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
/>
</template>
@@ -0,0 +1,268 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { Direction } from '../../utilities/config-provider';
/**
* A segmented input for short codes — OTP / one-time passwords, 2FA tokens, or
* PINs split across one box per character. The interactive root: it owns the
* value as a per-cell `string[]` (controlled via `v-model` / `update:modelValue`
* or uncontrolled via `defaultValue`), sizes the field to `length`, enforces the
* `type` ('text' | 'number') and `mask`, and provides context to each
* `PinInputInput`. Emits `complete` once every cell is filled. Use it for
* verification codes where each character gets its own cell with auto-advance,
* arrow-key navigation, and clipboard paste spreading across cells.
*
* Native form support: pass `name` (and optionally `required` / `id`) to render
* a visually-hidden form control holding the joined value, so the field submits
* with its owning `<form>` and participates in native `required` validation.
*/
export interface PinInputRootProps extends PrimitiveProps {
defaultValue?: string[];
length?: number;
mask?: boolean;
otp?: boolean;
type?: 'text' | 'number';
disabled?: boolean;
placeholder?: string;
/**
* Reading direction. Affects arrow-key navigation (in `rtl`, `ArrowRight`
* moves to the previous cell). Falls back to the active `ConfigProvider`
* `dir`, then `ltr`.
*/
dir?: Direction;
/** Name submitted with the owning form (enables the hidden form control). */
name?: string;
/** Mirror the `required` constraint so native form validation fires. */
required?: boolean;
/** Id forwarded to the hidden form control for label association. */
id?: string;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, ref, shallowRef, toRef, triggerRef, watch } from 'vue';
import { providePinInputContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { useDirection } from '../../utilities/config-provider';
import { resolveNextIndex } from '../../internal/utils/roving-focus';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
defineOptions({ inheritAttrs: false });
const {
defaultValue,
length = 4,
mask = false,
otp = false,
type = 'text',
disabled = false,
placeholder = '',
dir,
name,
required = false,
id,
as = 'div',
} = defineProps<PinInputRootProps>();
const { forwardRef } = useForwardExpose();
const direction = useDirection(() => dir);
const emit = defineEmits<{
complete: [value: string];
}>();
const lengthRef = computed(() => Math.max(1, length | 0));
function normalize(v: readonly string[] | undefined): string[] {
const out = Array.from<string>({ length: lengthRef.value }, () => '');
if (!v)
return out;
for (let i = 0; i < Math.min(v.length, lengthRef.value); i++)
out[i] = (v[i] ?? '').slice(0, 1);
return out;
}
// `defineModel` owns the `modelValue` prop in both modes: controlled (parent
// `v-model`) and uncontrolled (its own internal store). Writing `model.value`
// emits `update:modelValue`, so no manual emit is needed. `value` is the
// normalized, per-cell `string[]` source of truth read by the inputs — kept as
// a local ref so synchronous bursts (e.g. paste) always read the latest write
// rather than a not-yet-propagated controlled prop.
const model = defineModel<string[]>();
const value = ref<string[]>(normalize(model.value ?? defaultValue));
watch(model, (v) => {
if (v === undefined)
return;
const nv = normalize(v);
if (nv.join('\u0000') !== value.value.join('\u0000'))
value.value = nv;
});
watch(lengthRef, (n) => {
if (value.value.length === n)
return;
const next = Array.from<string>({ length: n }, () => '');
for (let i = 0; i < Math.min(value.value.length, n); i++)
next[i] = value.value[i]!;
value.value = next;
});
// `shallowRef` so the registered `<input>` elements are stored raw (a deep
// `ref` would proxy each element — breaking `includes(el)` identity checks and
// `.focus()`). The array is mutated in place, so `triggerRef` after each change.
const inputs = shallowRef<HTMLInputElement[]>([]);
function register(el: HTMLInputElement): void {
if (!inputs.value.includes(el)) {
inputs.value.push(el);
triggerRef(inputs);
}
}
function unregister(el: HTMLInputElement): void {
const i = inputs.value.indexOf(el);
if (i !== -1) {
inputs.value.splice(i, 1);
triggerRef(inputs);
}
}
function commit(v: string[]): void {
// `value` is the synchronous source of truth; `model.value` mirrors it and,
// via `defineModel`, emits `update:modelValue`. No manual emit needed.
value.value = v;
model.value = v;
if (v.every(ch => ch.length === 1))
emit('complete', v.join(''));
}
function setAt(index: number, char: string): void {
if (disabled)
return;
const ch = char.slice(0, 1);
if (ch && type === 'number' && !/\d/.test(ch))
return;
const next = value.value.slice();
next[index] = ch;
commit(next);
}
function clearAt(index: number): void {
if (disabled)
return;
const next = value.value.slice();
next[index] = '';
commit(next);
}
function isCellDisabled(el: HTMLInputElement | undefined): boolean {
return !el || el.hasAttribute('data-disabled');
}
function focusIndex(index: number): void {
const el = inputs.value[index];
if (el) {
el.focus();
try {
el.select();
}
catch {
/* noop */
}
}
}
// Move focus by `delta` (±1) or to an absolute edge, skipping disabled cells.
// `delta` is already resolved by the caller for reading direction; navigation
// never loops (a pin field has hard edges).
function focusRelative(index: number, delta: number, absolute?: 'home' | 'end'): void {
const count = inputs.value.length;
if (count === 0)
return;
if (absolute) {
let i = absolute === 'home' ? 0 : count - 1;
const step = absolute === 'home' ? 1 : -1;
while (i >= 0 && i < count && isCellDisabled(inputs.value[i]))
i += step;
if (i >= 0 && i < count)
focusIndex(i);
return;
}
let i = resolveNextIndex(index, delta, count, false);
while (i !== index && isCellDisabled(inputs.value[i])) {
const next = resolveNextIndex(i, delta, count, false);
if (next === i)
return;
i = next;
}
if (i !== index && !isCellDisabled(inputs.value[i]))
focusIndex(i);
}
function firstEmptyIndex(): number {
return value.value.findIndex(ch => ch.length === 0);
}
const isComplete = computed(() => value.value.every(ch => ch.length === 1));
providePinInputContext({
value,
length: lengthRef,
mask: toRef(() => mask),
otp: toRef(() => otp),
type: toRef(() => type),
disabled: toRef(() => disabled),
placeholder: toRef(() => placeholder),
dir: direction,
isComplete,
inputs,
register,
unregister,
setAt,
clearAt,
focusIndex,
focusRelative,
firstEmptyIndex,
});
defineSlots<{
default: (props: { value: string[]; isComplete: boolean }) => unknown;
}>();
const serialized = computed(() => value.value.join(''));
function onHiddenFocus(): void {
inputs.value[0]?.focus();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="group"
v-bind="$attrs"
:dir="direction"
:data-complete="isComplete ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
>
<slot :value="value" :is-complete="isComplete" />
<VisuallyHiddenInput
v-if="name !== undefined"
:id="id"
feature="focusable"
:tabindex="-1"
:name="name"
:value="serialized"
:required="required"
:disabled="disabled"
@focus="onHiddenFocus"
/>
</Primitive>
</template>
@@ -0,0 +1,301 @@
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 { PinInputInput, PinInputRoot } from '../index';
function mountPin(props: Record<string, unknown> = {}) {
const model = ref<string[] | undefined>(undefined);
const completed = ref<string | null>(null);
const Harness = defineComponent({
setup: () => () => h(PinInputRoot, {
modelValue: model.value,
length: 4,
'onUpdate:modelValue': (v: string[]) => { model.value = v; },
onComplete: (v: string) => { completed.value = v; },
...props,
}, {
default: () => [0, 1, 2, 3].map(i => h(PinInputInput as Component, { key: i, index: i })),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
return { wrapper, model, completed };
}
function inputs(): HTMLInputElement[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('input[data-index]'));
}
function type(el: HTMLInputElement, ch: string): void {
el.value = ch;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
function key(el: Element, k: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
}
describe('PinInput', () => {
it('renders N inputs based on length', () => {
const { wrapper } = mountPin();
expect(inputs().length).toBe(4);
wrapper.unmount();
});
it('typing auto-advances focus and fires complete', async () => {
const { wrapper, model, completed } = mountPin();
await nextTick();
const [a, b, c, d] = inputs();
type(a!, '1');
await nextTick();
expect(document.activeElement).toBe(b);
type(b!, '2');
type(c!, '3');
type(d!, '4');
await nextTick();
expect(model.value).toEqual(['1', '2', '3', '4']);
expect(completed.value).toBe('1234');
wrapper.unmount();
});
it('Backspace on empty moves to previous and clears', async () => {
const { wrapper, model } = mountPin();
await nextTick();
const [a, b] = inputs();
type(a!, '1');
await nextTick();
b!.focus();
key(b!, 'Backspace');
await nextTick();
expect(document.activeElement).toBe(a);
expect(model.value![0]).toBe('');
wrapper.unmount();
});
it('ArrowLeft/ArrowRight navigate', async () => {
const { wrapper } = mountPin();
await nextTick();
const [a, b, c] = inputs();
b!.focus();
key(b!, 'ArrowLeft');
expect(document.activeElement).toBe(a);
key(a!, 'ArrowRight');
expect(document.activeElement).toBe(b);
key(b!, 'ArrowRight');
expect(document.activeElement).toBe(c);
wrapper.unmount();
});
it('type=number rejects non-digit input', async () => {
const { wrapper, model } = mountPin({ type: 'number' });
await nextTick();
const [a] = inputs();
type(a!, 'x');
await nextTick();
expect(model.value?.[0] ?? '').toBe('');
type(a!, '7');
await nextTick();
expect(model.value![0]).toBe('7');
wrapper.unmount();
});
it('paste fills across inputs', async () => {
const { wrapper, model, completed } = mountPin();
await nextTick();
const [a] = inputs();
a!.focus();
const event = new Event('paste', { bubbles: true, cancelable: true }) as unknown as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: { getData: (_type: string) => '9876' },
});
a!.dispatchEvent(event);
await nextTick();
expect(model.value).toEqual(['9', '8', '7', '6']);
expect(completed.value).toBe('9876');
wrapper.unmount();
});
it('mask renders password type for each input', async () => {
const { wrapper } = mountPin({ mask: true });
await nextTick();
for (const el of inputs())
expect(el.getAttribute('type')).toBe('password');
wrapper.unmount();
});
it('sets per-input aria-label and group role', async () => {
const { wrapper } = mountPin();
await nextTick();
const group = document.querySelector('[role="group"]');
expect(group).toBeTruthy();
const all = inputs();
expect(all[0]!.getAttribute('aria-label')).toBe('pin input 1 of 4');
expect(all[3]!.getAttribute('aria-label')).toBe('pin input 4 of 4');
wrapper.unmount();
});
it('exposes data-complete on root and inputs once filled', async () => {
const { wrapper } = mountPin();
await nextTick();
const group = document.querySelector('[role="group"]')!;
expect(group.hasAttribute('data-complete')).toBe(false);
const [a, b, c, d] = inputs();
type(a!, '1');
type(b!, '2');
type(c!, '3');
type(d!, '4');
await nextTick();
expect(group.hasAttribute('data-complete')).toBe(true);
expect(inputs()[0]!.hasAttribute('data-complete')).toBe(true);
wrapper.unmount();
});
it('sets numeric hardening attributes when type=number', async () => {
const { wrapper } = mountPin({ type: 'number' });
await nextTick();
const a = inputs()[0]!;
expect(a.getAttribute('inputmode')).toBe('numeric');
expect(a.getAttribute('pattern')).toBe('[0-9]*');
expect(a.getAttribute('autocapitalize')).toBe('none');
wrapper.unmount();
});
it('renders a hidden form input when name is provided', async () => {
const { wrapper } = mountPin({ name: 'otp', required: true, id: 'otp-field' });
await nextTick();
const hidden = document.querySelector<HTMLInputElement>('input[name="otp"]');
expect(hidden).toBeTruthy();
expect(hidden!.required).toBe(true);
const [a, b] = inputs();
type(a!, '1');
type(b!, '2');
await nextTick();
expect(hidden!.value).toBe('12');
wrapper.unmount();
});
it('does not render a hidden form input without name', async () => {
const { wrapper } = mountPin();
await nextTick();
expect(document.querySelector('input[name]')).toBeNull();
wrapper.unmount();
});
it('hidden input focus forwards to the first cell', async () => {
const { wrapper } = mountPin({ name: 'otp' });
await nextTick();
const hidden = document.querySelector<HTMLInputElement>('input[name="otp"]')!;
hidden.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
await nextTick();
expect(document.activeElement).toBe(inputs()[0]);
wrapper.unmount();
});
it('RTL flips arrow-key navigation', async () => {
const { wrapper } = mountPin({ dir: 'rtl' });
await nextTick();
const [a, b, c] = inputs();
b!.focus();
key(b!, 'ArrowRight');
expect(document.activeElement).toBe(a);
key(a!, 'ArrowLeft');
expect(document.activeElement).toBe(b);
key(b!, 'ArrowLeft');
expect(document.activeElement).toBe(c);
wrapper.unmount();
});
it('Home/End jump to edges', async () => {
const { wrapper } = mountPin();
await nextTick();
const all = inputs();
all[1]!.focus();
key(all[1]!, 'End');
expect(document.activeElement).toBe(all[3]);
key(all[3]!, 'Home');
expect(document.activeElement).toBe(all[0]);
wrapper.unmount();
});
it('per-input disabled blocks navigation onto the cell and skips it', async () => {
const model = ref<string[] | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(PinInputRoot, {
modelValue: model.value,
length: 3,
'onUpdate:modelValue': (v: string[]) => { model.value = v; },
}, {
default: () => [
h(PinInputInput as Component, { key: 0, index: 0 }),
h(PinInputInput as Component, { key: 1, index: 1, disabled: true }),
h(PinInputInput as Component, { key: 2, index: 2 }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
const all = inputs();
expect(all[1]!.disabled).toBe(true);
expect(all[1]!.hasAttribute('data-disabled')).toBe(true);
all[0]!.focus();
key(all[0]!, 'ArrowRight');
// skips the disabled middle cell, lands on index 2
expect(document.activeElement).toBe(all[2]);
wrapper.unmount();
});
it('OTP mode redirects focus to the first empty cell', async () => {
const { wrapper, model } = mountPin({ otp: true });
await nextTick();
const all = inputs();
type(all[0]!, '1');
await nextTick();
expect(model.value?.[0]).toBe('1');
// attempt to focus the last (empty, out-of-order) cell -> redirected to index 1
all[3]!.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
await nextTick();
expect(document.activeElement).toBe(all[1]);
wrapper.unmount();
});
it('renders inputs polymorphically via as', async () => {
const model = ref<string[] | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(PinInputRoot, {
modelValue: model.value,
length: 2,
}, {
default: () => [0, 1].map(i => h(PinInputInput as Component, { key: i, index: i, as: 'input' })),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
expect(inputs().length).toBe(2);
wrapper.unmount();
});
it('disabled root blocks mutation', async () => {
const { wrapper, model } = mountPin({ disabled: true });
await nextTick();
const a = inputs()[0]!;
expect(a.disabled).toBe(true);
type(a, '5');
await nextTick();
expect(model.value?.[0] ?? '').toBe('');
wrapper.unmount();
});
it('hides placeholder on the focused empty cell', async () => {
const { wrapper } = mountPin({ placeholder: '○' });
await nextTick();
const a = inputs()[0]!;
expect(a.getAttribute('placeholder')).toBe('○');
a.dispatchEvent(new FocusEvent('focus', { bubbles: true }));
await nextTick();
expect(a.getAttribute('placeholder')).toBe('');
a.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
await nextTick();
expect(a.getAttribute('placeholder')).toBe('○');
wrapper.unmount();
});
});
@@ -0,0 +1,33 @@
import type { ComputedRef, Ref } from 'vue';
import type { Direction } from '../../utilities/config-provider';
import { useContextFactory } from '@robonen/vue';
export interface PinInputContext {
value: Ref<string[]>;
length: ComputedRef<number>;
mask: ComputedRef<boolean>;
otp: ComputedRef<boolean>;
type: ComputedRef<'text' | 'number'>;
disabled: ComputedRef<boolean>;
placeholder: ComputedRef<string>;
dir: ComputedRef<Direction>;
isComplete: ComputedRef<boolean>;
inputs: Ref<HTMLInputElement[]>;
register: (el: HTMLInputElement) => void;
unregister: (el: HTMLInputElement) => void;
setAt: (index: number, char: string) => void;
clearAt: (index: number) => void;
focusIndex: (index: number) => void;
/**
* Move focus relative to `index` by `delta` (`±1`) or to an absolute edge,
* skipping disabled cells. RTL-aware navigation is resolved by the caller.
*/
focusRelative: (index: number, delta: number, absolute?: 'home' | 'end') => void;
/** Index of the first cell whose value is still empty, or `-1` if all filled. */
firstEmptyIndex: () => number;
}
const ctx = useContextFactory<PinInputContext>('PinInputContext');
export const providePinInputContext = ctx.provide;
export const usePinInputContext = ctx.inject;
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { PinInputInput, PinInputRoot } from '@robonen/primitives';
import { ref } from 'vue';
const LENGTH = 6;
const code = ref<string[]>([]);
const status = ref<'idle' | 'verifying' | 'ok' | 'error'>('idle');
function onComplete(value: string) {
status.value = 'verifying';
// Pretend to call an API; "123456" is the happy path.
window.setTimeout(() => {
status.value = value === '123456' ? 'ok' : 'error';
}, 600);
}
function reset() {
code.value = [];
status.value = 'idle';
}
</script>
<template>
<div class="demo-card w-full max-w-sm p-6 text-fg">
<h3 class="text-base font-semibold">
Verify your email
</h3>
<p class="mt-1 text-sm text-fg-muted">
Enter the 6-digit code we sent to your inbox.
</p>
<PinInputRoot
v-slot="{ isComplete }"
v-model="code"
:length="LENGTH"
type="number"
otp
:disabled="status === 'verifying' || status === 'ok'"
class="mt-5 flex items-center gap-2 data-[disabled]:opacity-60"
@complete="onComplete"
>
<PinInputInput
v-for="i in LENGTH"
:key="i"
:index="i - 1"
class="h-12 w-10 rounded-lg border bg-bg text-center text-lg font-medium text-fg outline-none transition-colors placeholder:text-fg-subtle focus:border-accent focus:ring-2 focus:ring-ring disabled:cursor-not-allowed"
:class="status === 'error'
? 'border-red-500 dark:border-red-400'
: isComplete && status === 'ok'
? 'border-emerald-500 dark:border-emerald-400'
: 'border-border'"
/>
</PinInputRoot>
<div class="mt-4 flex min-h-5 items-center justify-between text-sm">
<p
class="font-medium"
:class="{
'text-fg-subtle': status === 'idle' || status === 'verifying',
'text-emerald-600 dark:text-emerald-400': status === 'ok',
'text-red-600 dark:text-red-400': status === 'error',
}"
>
<span v-if="status === 'verifying'">Verifying</span>
<span v-else-if="status === 'ok'">Code accepted</span>
<span v-else-if="status === 'error'">Invalid code try again</span>
<span v-else>Tip: try 123456</span>
</p>
<button
type="button"
class="rounded-md px-2 py-1 text-sm text-accent transition-colors hover:bg-bg-inset"
@click="reset"
>
Reset
</button>
</div>
</div>
</template>
@@ -0,0 +1,5 @@
export { default as PinInputInput } from './PinInputInput.vue';
export { default as PinInputRoot } from './PinInputRoot.vue';
export type { PinInputInputProps } from './PinInputInput.vue';
export type { PinInputRootProps } from './PinInputRoot.vue';
export * from './context';
@@ -0,0 +1,106 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue } from './utils';
/**
* A standalone radio, usable on its own outside a `RadioGroupRoot`. It owns its
* own `checked` state (controlled via `v-model:checked` or uncontrolled), and
* — when given a `name` inside a `<form>` — renders a hidden native input so its
* value participates in form submission and native validation.
*
* Like `RadioGroupItem` it emits a cancelable `select` event before toggling;
* call `event.preventDefault()` to veto.
*/
export interface RadioProps extends PrimitiveProps {
/** Element `id`, also used to derive an `aria-label` from an associated `<label for=id>`. */
id?: string;
/** The value submitted with the owning form when `name` is set. */
value?: AcceptableValue;
/** When `true`, the radio cannot be interacted with. */
disabled?: boolean;
/** Marks the radio as required for assistive tech and native validation. */
required?: boolean;
/** Name of the hidden form field submitted with the owning `<form>`. */
name?: string;
}
export interface RadioEmits {
/** Fired before `checked` flips. Call `event.preventDefault()` to cancel. */
select: [event: CustomEvent<{ originalEvent: Event; value: AcceptableValue | undefined }>];
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
const { value, disabled = false, required = false, name, id, as = 'button' } = defineProps<RadioProps>();
const emit = defineEmits<RadioEmits>();
const checked = defineModel<boolean>('checked', { default: false });
defineSlots<{
default?: (props: { checked: boolean }) => unknown;
}>();
const { forwardRef, currentElement } = useForwardExpose();
const dataState = computed(() => checked.value ? 'checked' : 'unchecked');
const isFormControl = computed(() => {
const el = currentElement.value;
return !!el && !!el.closest('form');
});
const ariaLabel = computed(() => {
if (!id || !currentElement.value || globalThis.document === undefined) return undefined;
const label = globalThis.document.querySelector<HTMLLabelElement>(`[for="${id}"]`);
return label?.textContent?.trim() || (value !== undefined && value !== null && typeof value !== 'object' ? String(value) : undefined);
});
function onClick(event: MouseEvent): void {
if (disabled) return;
const select = new CustomEvent('radio.select', { bubbles: true, cancelable: true, detail: { originalEvent: event, value } });
emit('select', select);
if (select.defaultPrevented) return;
checked.value = true;
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter') event.preventDefault();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="id"
:type="as === 'button' ? 'button' : undefined"
role="radio"
:aria-checked="checked"
:aria-label="ariaLabel"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:data-state="dataState"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled || undefined"
@click="onClick"
@keydown="onKeyDown"
>
<slot :checked="checked" />
<input
v-if="isFormControl && name"
type="radio"
tabindex="-1"
aria-hidden="true"
:name="name"
:value="value === undefined || value === null ? '' : (typeof value === 'object' ? JSON.stringify(value) : String(value))"
:checked="checked"
:required="required"
:disabled="disabled"
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
>
</Primitive>
</template>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Renders its content only when the parent `RadioGroupItem` is selected,
* mirroring that state via `data-state`. Place the filled dot or check mark
* inside it. Wrapped in `Presence`, so it can animate out via CSS leave
* animations; use `forceMount` to keep it mounted for animation control.
*/
export interface RadioGroupIndicatorProps extends PrimitiveProps {
/** Keep the indicator mounted regardless of checked state (for exit animations / measuring). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Presence } from '../../utilities/presence';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useRadioGroupItemContext } from './context';
const { as = 'span', forceMount = false } = defineProps<RadioGroupIndicatorProps>();
const { forwardRef } = useForwardExpose();
const item = useRadioGroupItemContext();
</script>
<template>
<Presence :present="forceMount || item.checked.value">
<Primitive
:ref="forwardRef"
:as="as"
:data-state="item.checked.value ? 'checked' : 'unchecked'"
:data-disabled="item.disabled.value ? '' : undefined"
style="pointer-events: none;"
>
<slot />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue } from './utils';
/**
* A single selectable option within a `RadioGroupRoot`, rendered by default as
* a native `<button role="radio">`. Clicking, pressing Space, or arrow-keying
* onto it selects its `value`; it reflects selection via `data-state` and
* participates in the group's roving tab order. Provides context to a nested
* `RadioGroupIndicator`.
*
* Emits a cancelable `select` event before the value commits — call
* `event.preventDefault()` to veto the selection.
*/
export interface RadioGroupItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** The value this item represents — any structural value, not just strings. */
value: T;
/** When `true`, the item cannot be selected or focused. */
disabled?: boolean;
/** Marks the item as required (merged with the group-level `required`). */
required?: boolean;
/** Associates a `<label for=id>`; its text becomes the radio's `aria-label`. */
id?: string;
}
export interface RadioGroupItemEmits<T extends AcceptableValue = AcceptableValue> {
/** Fired before the value commits. Call `event.preventDefault()` to cancel. */
select: [event: CustomEvent<{ originalEvent: Event; value: T }>];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCollectionInjector } from '../../utilities/collection';
import { useForwardExpose } from '@robonen/vue';
import { provideRadioGroupItemContext, useRadioGroupContext } from './context';
const { value, disabled = false, required = false, id, as = 'button' } = defineProps<RadioGroupItemProps<T>>();
const emit = defineEmits<RadioGroupItemEmits<T>>();
defineSlots<{
default?: (props: {
/** Whether this item is the selected one. */
checked: boolean;
/** Whether this item is required (group-level or item-level). */
required: boolean;
/** Whether this item is disabled (group-level or item-level). */
disabled: boolean;
}) => unknown;
}>();
const ctx = useRadioGroupContext();
const { CollectionItem } = useCollectionInjector<AcceptableValue>();
const { forwardRef, currentElement } = useForwardExpose();
const isChecked = computed(() => ctx.isChecked(value));
const isDisabled = computed(() => ctx.disabled.value || disabled);
const isRequired = computed(() => ctx.required.value || required);
const dataState = computed(() => isChecked.value ? 'checked' : 'unchecked');
// Derive an accessible name from an associated `<label for=id>` for icon-only
// radios that have no visible text content of their own.
const ariaLabel = computed(() => {
if (!id || !currentElement.value || globalThis.document === undefined) return undefined;
const label = globalThis.document.querySelector<HTMLLabelElement>(`[for="${id}"]`);
return label?.textContent?.trim() || undefined;
});
provideRadioGroupItemContext({
value,
checked: isChecked,
disabled: isDisabled,
});
// Only one item should be in the tab order:
// - the checked one, or
// - the first enabled one if nothing is checked.
const isTabStop = computed(() => {
if (isDisabled.value) return false;
const el = currentElement.value;
return !!el && ctx.tabStopElement.value === el;
});
function commit(originalEvent: Event): void {
if (isDisabled.value) return;
const select = new CustomEvent('radio.select', { bubbles: true, cancelable: true, detail: { originalEvent, value } });
emit('select', select);
if (select.defaultPrevented) return;
ctx.setValue(value);
}
function onClick(event: MouseEvent): void {
commit(event);
currentElement.value?.focus();
}
function onKeyDown(event: KeyboardEvent): void {
// Radios must not activate on Enter (WAI-ARIA): suppress the native button
// activation so Enter does not commit a selection or submit a form.
if (event.key === 'Enter') {
event.preventDefault();
return;
}
if (!currentElement.value) return;
ctx.onItemKeyDown(event, currentElement.value);
}
</script>
<template>
<CollectionItem :value="value">
<Primitive
:as="as"
:ref="forwardRef"
:id="id"
:type="as === 'button' ? 'button' : undefined"
role="radio"
:aria-checked="isChecked"
:aria-label="ariaLabel"
:aria-required="isRequired || undefined"
:aria-disabled="isDisabled || undefined"
:data-state="dataState"
:data-disabled="isDisabled ? '' : undefined"
:tabindex="isTabStop ? 0 : -1"
:disabled="isDisabled || undefined"
@click="onClick"
@keydown="onKeyDown"
>
<slot :checked="isChecked" :required="isRequired" :disabled="isDisabled" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,250 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { RovingDirection } from '../../internal/utils/roving-focus';
import type { AcceptableValue, RadioCompareBy } from './utils';
/**
* A set of mutually exclusive options where only one may be selected at a time,
* built on `role="radiogroup"` with full keyboard roving focus (arrow keys move
* and select, Space selects, Home/End and PageUp/PageDown jump to ends). The
* container and state owner: it tracks the selected value (controlled via
* `v-model` or uncontrolled via `defaultValue`), provides context to
* `RadioGroupItem`, and renders a hidden form input when `name` is set and the
* group lives inside a `<form>`.
*
* Values are not limited to strings — numbers, booleans, `null`, and plain
* objects are supported and compared structurally (override with `by`). Reach
* for it whenever a user must pick exactly one choice from a small, visible
* list.
*/
export interface RadioGroupRootProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** The value of the radio item that should be checked when initially rendered (uncontrolled). */
defaultValue?: T;
/** When `true`, prevents the user from interacting with radio items. */
disabled?: boolean;
/** Marks the group, and every item, as required for assistive tech and native validation. */
required?: boolean;
/** Name of the hidden form field submitted with the owning `<form>`. */
name?: string;
/** The orientation arrow navigation follows. */
orientation?: 'horizontal' | 'vertical';
/**
* Reading direction. When omitted, inherits from the active `ConfigProvider`
* (falling back to `'ltr'`), so an app-wide RTL setting flips arrow navigation.
*/
dir?: RovingDirection;
/** When `true`, arrow navigation wraps from the last item to the first and vice versa. */
loop?: boolean;
/**
* How an item `value` is compared against the selected value. Omitted →
* structural deep equality; a function → custom comparator; a string →
* compare that property key.
*/
by?: RadioCompareBy;
}
export interface RadioGroupRootEmits<T extends AcceptableValue = AcceptableValue> {
/** Emitted whenever the selected value changes (alias of `update:modelValue`). */
valueChange: [value: T];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { Ref } from 'vue';
import { computed, ref, toRef, useTemplateRef, watch } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { useCollectionProvider } from '../../utilities/collection';
import { useDirection } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { provideRadioGroupContext } from './context';
import { compareValues } from './utils';
const {
disabled = false,
required = false,
orientation = 'vertical',
dir,
loop = true,
defaultValue,
name,
by,
as = 'div',
} = defineProps<RadioGroupRootProps<T>>();
const emit = defineEmits<RadioGroupRootEmits<T>>();
const model = defineModel<T | undefined>({ default: undefined });
defineSlots<{
default?: (props: {
/** The currently selected value (or `undefined` when nothing is selected). */
value: AcceptableValue | undefined;
}) => unknown;
}>();
const { forwardRef, currentElement } = useForwardExpose();
// Resolve `dir` against the global ConfigProvider so an app-wide RTL setting is
// honoured; a per-group `dir` prop still wins.
const direction = useDirection(() => dir);
const localValue = ref<AcceptableValue | undefined>(model.value ?? defaultValue) as Ref<AcceptableValue | undefined>;
watch(model, (v) => {
if (v === undefined) return;
if (v !== localValue.value) localValue.value = v;
});
function isChecked(v: AcceptableValue): boolean {
return compareValues(localValue.value, v, by);
}
function setValue(v: AcceptableValue): boolean {
if (disabled) return false;
localValue.value = v;
model.value = v as T;
emit('valueChange', v as T);
return true;
}
// DOM-order items via Collection primitive — survives `v-for` reorders. The
// Collection carries each item's `value`, so non-string values round-trip
// without serialising through a DOM attribute.
const { getItems, CollectionSlot } = useCollectionProvider<AcceptableValue>();
const items = computed(() => getItems(true).map(i => i.ref));
// The single roving tab-stop element, derived ONCE here instead of having every
// item independently scan `items`: the checked item when a value is selected,
// otherwise the first enabled item. Reuses the Collection's `value`-carrying
// records so checked-ness is tested without per-item DOM reads.
const tabStopElement = computed<HTMLElement | undefined>(() => {
const records = getItems(true);
if (localValue.value !== undefined) {
for (const record of records) {
if (record.value !== undefined && isChecked(record.value)) return record.ref;
}
return undefined;
}
for (const record of records) {
if (!record.ref.hasAttribute('data-disabled')) return record.ref;
}
return undefined;
});
// Only render the hidden field when the group is genuinely inside a form, so a
// stray named input is not added to the document otherwise.
const isFormControl = computed(() => {
const el = currentElement.value;
return !!el && !!el.closest('form');
});
// Serialise the selected value for the native input. Objects/arrays go through
// JSON so non-primitive values still round-trip through native form submission.
const submittedValue = computed(() => {
const v = localValue.value;
if (v === undefined || v === null) return '';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
});
const hiddenInput = useTemplateRef<HTMLInputElement>('hiddenInput');
// Mirror programmatic value changes onto the native input by driving its value
// through the native setter and dispatching the events a real edit would
// produce, so native validation and third-party form listeners observe them.
watch(submittedValue, (next) => {
const input = hiddenInput.value;
if (!input || globalThis.window === undefined) return;
const descriptor = Object.getOwnPropertyDescriptor(globalThis.HTMLInputElement.prototype, 'value');
descriptor?.set?.call(input, next);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}, { flush: 'post' });
function focusIndex(i: number): void {
const el = items.value[i];
if (!el || el.hasAttribute('data-disabled')) return;
el.focus();
// Route selection through the element's native click so the item's cancelable
// `select` event fires and a native `click`/`change` is dispatched for
// downstream listeners — instead of mutating state behind their back.
el.click();
}
function onItemKeyDown(event: KeyboardEvent, el: HTMLElement): void {
// Space selects the focused item (via its native click) without moving focus.
if (event.key === ' ') {
event.preventDefault();
if (!el.hasAttribute('data-disabled')) el.click();
return;
}
const enabled = items.value.filter(x => !x.hasAttribute('data-disabled'));
// PageUp/PageDown jump to the first/last enabled item (WAI-ARIA optional).
if (event.key === 'PageUp' || event.key === 'PageDown') {
event.preventDefault();
if (enabled.length === 0) return;
const target = event.key === 'PageUp' ? enabled[0]! : enabled[enabled.length - 1]!;
return focusIndex(items.value.indexOf(target));
}
const action = rovingKeyToAction(event, { orientation, dir: direction.value, loop });
if (!action) return;
event.preventDefault();
if (enabled.length === 0) return;
const current = enabled.indexOf(el);
if (action.absolute === 'home') return focusIndex(items.value.indexOf(enabled[0]!));
if (action.absolute === 'end') return focusIndex(items.value.indexOf(enabled[enabled.length - 1]!));
const nextIdx = resolveNextIndex(current === -1 ? 0 : current, action.delta, enabled.length, loop);
focusIndex(items.value.indexOf(enabled[nextIdx]!));
}
provideRadioGroupContext({
value: localValue,
setValue,
isChecked,
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
orientation: toRef(() => orientation),
direction,
loop: toRef(() => loop),
disabled: toRef(() => disabled),
required: toRef(() => required),
name: toRef(() => name),
items,
tabStopElement,
onItemKeyDown,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
role="radiogroup"
:aria-orientation="orientation"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:dir="direction"
:data-orientation="orientation"
:data-disabled="disabled ? '' : undefined"
>
<slot :value="localValue" />
<input
v-if="isFormControl && name"
ref="hiddenInput"
type="radio"
tabindex="-1"
aria-hidden="true"
:name="name"
:value="submittedValue"
:checked="localValue !== undefined && localValue !== null"
:required="required"
:disabled="disabled"
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
>
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,430 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { provideConfig } from '../../../utilities/config-provider';
import {
Radio,
RadioGroupIndicator,
RadioGroupItem,
RadioGroupRoot,
useRadioGroupContext,
useRadioGroupItemContext,
} from '../index';
function press(el: Element, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('RadioGroup — non-string values', () => {
it('supports number values (controlled selection + check state)', async () => {
const model = ref<number | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: number | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 1, id: 'one' }),
h(RadioGroupItem, { value: 2, id: 'two' }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
document.querySelector<HTMLButtonElement>('#two')!.click();
await nextTick();
expect(model.value).toBe(2);
expect(document.querySelector('#two')!.getAttribute('aria-checked')).toBe('true');
expect(document.querySelector('#one')!.getAttribute('aria-checked')).toBe('false');
wrapper.unmount();
});
it('supports boolean values', async () => {
const model = ref<boolean | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: boolean | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: true, id: 'yes' }),
h(RadioGroupItem, { value: false, id: 'no' }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
document.querySelector<HTMLButtonElement>('#no')!.click();
await nextTick();
expect(model.value).toBe(false);
expect(document.querySelector('#no')!.getAttribute('aria-checked')).toBe('true');
wrapper.unmount();
});
it('compares object values structurally (deep equality)', async () => {
const optA = { id: 'a' };
const model = ref<Record<string, unknown> | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: Record<string, unknown> | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: optA, id: 'oa' }),
h(RadioGroupItem, { value: { id: 'b' }, id: 'ob' }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
// Select via a structurally-equal (not identical) object.
model.value = { id: 'a' };
await nextTick();
expect(document.querySelector('#oa')!.getAttribute('aria-checked')).toBe('true');
wrapper.unmount();
});
it('honours a `by` property-key comparator', async () => {
const model = ref<Record<string, unknown> | undefined>({ id: 1, label: 'stale' });
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
by: 'id',
'onUpdate:modelValue': (v: Record<string, unknown> | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: { id: 1, label: 'fresh' }, id: 'i1' }),
h(RadioGroupItem, { value: { id: 2, label: 'other' }, id: 'i2' }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
// Different object identity + different label, same `id` → checked.
expect(document.querySelector('#i1')!.getAttribute('aria-checked')).toBe('true');
expect(document.querySelector('#i2')!.getAttribute('aria-checked')).toBe('false');
wrapper.unmount();
});
});
function mountGroup(opts: Record<string, unknown> = {}, itemOpts: Array<Record<string, unknown>> = []) {
const model = ref<string | undefined>(undefined);
const defaults = itemOpts.length
? itemOpts
: [
{ value: 'a', id: 'a' },
{ value: 'b', id: 'b' },
{ value: 'c', id: 'c', disabled: true },
];
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
...opts,
}, {
default: () => defaults.map(d => h(RadioGroupItem, d)),
}),
});
return { wrapper: mount(Harness, { attachTo: document.body }), model };
}
describe('RadioGroup — PageUp/PageDown', () => {
it('PageDown jumps to last enabled item, PageUp to first', async () => {
const { wrapper, model } = mountGroup({ defaultValue: 'a' });
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
const b = document.querySelector<HTMLButtonElement>('#b')!;
a.focus();
press(a, 'PageDown');
await nextTick();
// 'c' is disabled in the default set, so last enabled is 'b'.
expect(document.activeElement).toBe(b);
expect(model.value).toBe('b');
press(b, 'PageUp');
await nextTick();
expect(document.activeElement).toBe(a);
expect(model.value).toBe('a');
wrapper.unmount();
});
});
describe('RadioGroup — cancelable select event', () => {
it('emits select and applies value when not prevented', async () => {
const model = ref<string | undefined>(undefined);
const onSelect = vi.fn();
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'a', onSelect }),
h(RadioGroupItem, { value: 'b', id: 'b' }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
document.querySelector<HTMLButtonElement>('#a')!.click();
await nextTick();
expect(onSelect).toHaveBeenCalledTimes(1);
expect(model.value).toBe('a');
wrapper.unmount();
});
it('vetoes selection when preventDefault is called', async () => {
const model = ref<string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'a', onSelect: (e: CustomEvent) => e.preventDefault() }),
h(RadioGroupItem, { value: 'b', id: 'b' }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
document.querySelector<HTMLButtonElement>('#a')!.click();
await nextTick();
expect(model.value).toBeUndefined();
expect(document.querySelector('#a')!.getAttribute('aria-checked')).toBe('false');
wrapper.unmount();
});
});
describe('RadioGroup — accessibility', () => {
it('group-level required propagates aria-required to each item', async () => {
const { wrapper } = mountGroup({ required: true });
await nextTick();
expect(document.querySelector('#a')!.getAttribute('aria-required')).toBe('true');
expect(document.querySelector('#b')!.getAttribute('aria-required')).toBe('true');
wrapper.unmount();
});
it('derives aria-label from an associated <label for=id>', async () => {
const label = document.createElement('label');
label.setAttribute('for', 'labelled');
label.textContent = 'Pick me';
document.body.appendChild(label);
const { wrapper } = mountGroup({}, [
{ value: 'x', id: 'labelled' },
{ value: 'y', id: 'plain' },
]);
await nextTick();
await nextTick();
expect(document.querySelector('#labelled')!.getAttribute('aria-label')).toBe('Pick me');
expect(document.querySelector('#plain')!.getAttribute('aria-label')).toBeNull();
wrapper.unmount();
label.remove();
});
it('Enter does not select a radio (WAI-ARIA)', async () => {
const { wrapper, model } = mountGroup();
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
a.focus();
press(a, 'Enter');
await nextTick();
expect(model.value).toBeUndefined();
wrapper.unmount();
});
});
describe('RadioGroup — RTL via ConfigProvider', () => {
it('inherits dir=rtl from ConfigProvider and flips horizontal arrows', async () => {
const model = ref<string | undefined>('b');
const RtlProvider = defineComponent({
setup(_, { slots }) {
provideConfig({ dir: 'rtl' });
return () => slots.default?.();
},
});
const Harness = defineComponent({
setup: () => () => h(RtlProvider, null, {
default: () => h(RadioGroupRoot, {
modelValue: model.value,
orientation: 'horizontal',
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'a' }),
h(RadioGroupItem, { value: 'b', id: 'b' }),
],
}),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
const b = document.querySelector<HTMLButtonElement>('#b')!;
const a = document.querySelector<HTMLButtonElement>('#a')!;
b.focus();
// In RTL, ArrowLeft moves to the *next* item (a is after b visually).
press(b, 'ArrowLeft');
await nextTick();
expect(document.activeElement).toBe(a);
expect(model.value).toBe('a');
expect((wrapper.element.querySelector('[role="radiogroup"]') as HTMLElement).getAttribute('dir')).toBe('rtl');
wrapper.unmount();
});
});
describe('RadioGroup — form gating', () => {
it('does NOT render a hidden input outside a form even when name is set', async () => {
const { wrapper } = mountGroup({ name: 'fruit' });
await nextTick();
await nextTick();
expect(document.querySelector('input[type="radio"][name="fruit"]')).toBeNull();
wrapper.unmount();
});
});
describe('RadioGroup — Indicator presence', () => {
it('renders the indicator for the checked item and removes it when unchecked', async () => {
const model = ref<string | undefined>('a');
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'a' }, { default: () => h(RadioGroupIndicator) }),
h(RadioGroupItem, { value: 'b', id: 'b' }, { default: () => h(RadioGroupIndicator) }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
expect(document.querySelector('#a')!.querySelector('span')).toBeTruthy();
expect(document.querySelector('#b')!.querySelector('span')).toBeNull();
model.value = 'b';
await nextTick();
await nextTick();
expect(document.querySelector('#b')!.querySelector('span')).toBeTruthy();
wrapper.unmount();
});
it('forceMount keeps the indicator mounted while unchecked', async () => {
const model = ref<string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'fa' }, { default: () => h(RadioGroupIndicator, { forceMount: true }) }),
],
}),
});
const w2 = mount(Harness, { attachTo: document.body });
await nextTick();
expect(document.querySelector('#fa')!.querySelector('span')).toBeTruthy();
w2.unmount();
});
});
describe('RadioGroup — context exports', () => {
it('exposes useRadioGroupContext and useRadioGroupItemContext to custom parts', async () => {
const seen: { group?: boolean; item?: boolean } = {};
const CustomPart = defineComponent({
setup() {
const group = useRadioGroupContext();
const item = useRadioGroupItemContext();
seen.group = typeof group.setValue === 'function';
seen.item = item.value !== undefined;
return () => h('i', { 'data-checked': item.checked.value });
},
});
const model = ref<string | undefined>('a');
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'a' }, { default: () => h(CustomPart) }),
],
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
expect(seen.group).toBe(true);
expect(seen.item).toBe(true);
expect(document.querySelector('i')!.getAttribute('data-checked')).toBe('true');
wrapper.unmount();
});
});
describe('Radio — standalone', () => {
it('toggles its own checked state on click', async () => {
const checked = ref(false);
const Harness = defineComponent({
setup: () => () => h(Radio, {
checked: checked.value,
value: 'solo',
id: 'solo',
'onUpdate:checked': (v: boolean) => { checked.value = v; },
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
const el = document.querySelector<HTMLButtonElement>('#solo')!;
expect(el.getAttribute('role')).toBe('radio');
expect(el.getAttribute('aria-checked')).toBe('false');
el.click();
await nextTick();
expect(checked.value).toBe(true);
expect(el.getAttribute('aria-checked')).toBe('true');
wrapper.unmount();
});
it('honours a vetoing select handler', async () => {
const checked = ref(false);
const Harness = defineComponent({
setup: () => () => h(Radio, {
checked: checked.value,
value: 'solo',
id: 'solo',
'onUpdate:checked': (v: boolean) => { checked.value = v; },
onSelect: (e: CustomEvent) => e.preventDefault(),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
document.querySelector<HTMLButtonElement>('#solo')!.click();
await nextTick();
expect(checked.value).toBe(false);
wrapper.unmount();
});
it('renders a hidden form input inside a <form> and submits its value', async () => {
const submitted = ref<FormData | null>(null);
const Harness = defineComponent({
setup: () => () => h('form', {
onSubmit: (e: SubmitEvent) => {
e.preventDefault();
submitted.value = new FormData(e.target as HTMLFormElement);
},
}, [
h(Radio, { value: 'solo', name: 'choice', id: 'solo', checked: true }),
h('button', { type: 'submit', id: 'submit' }, 'go'),
]),
});
const wrapper = mount(Harness, { attachTo: document.body });
await nextTick();
await nextTick();
const input = wrapper.element.querySelector('input[type="radio"][name="choice"]') as HTMLInputElement;
expect(input).toBeTruthy();
(wrapper.element as HTMLFormElement).requestSubmit();
await nextTick();
expect(submitted.value!.get('choice')).toBe('solo');
wrapper.unmount();
});
});
@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from '../index';
function mountInForm(props: Record<string, unknown> = {}) {
const model = ref<string | undefined>(undefined);
const submitted = ref<FormData | null>(null);
const Harness = defineComponent({
setup: () => () => h(
'form',
{
onSubmit: (e: SubmitEvent) => {
e.preventDefault();
submitted.value = new FormData(e.target as HTMLFormElement);
},
},
[
h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
...props,
}, {
default: () => [
h(RadioGroupItem, { value: 'apple', id: 'a' }, { default: () => h(RadioGroupIndicator) }),
h(RadioGroupItem, { value: 'banana', id: 'b' }, { default: () => h(RadioGroupIndicator) }),
],
}),
h('button', { type: 'submit', id: 'submit' }, 'Submit'),
],
),
});
return { wrapper: mount(Harness, { attachTo: document.body }), model, submitted };
}
describe('RadioGroup — form submission', () => {
it('does not render a hidden input when `name` is omitted', async () => {
const { wrapper } = mountInForm();
await nextTick();
expect(wrapper.element.querySelector('input[type="radio"]')).toBeNull();
wrapper.unmount();
});
it('renders a hidden, accessibility-hidden input when `name` is set', async () => {
const { wrapper } = mountInForm({ name: 'fruit' });
await nextTick();
const input = wrapper.element.querySelector('input[type="radio"][name="fruit"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.getAttribute('aria-hidden')).toBe('true');
expect(input.tabIndex).toBe(-1);
wrapper.unmount();
});
it('hidden input forwards the selected value into FormData on submit', async () => {
const { wrapper, submitted } = mountInForm({ name: 'fruit' });
await nextTick();
document.querySelector<HTMLButtonElement>('#a')!.click();
await nextTick();
(wrapper.element as HTMLFormElement).requestSubmit();
await nextTick();
expect(submitted.value).not.toBeNull();
expect(submitted.value!.get('fruit')).toBe('apple');
wrapper.unmount();
});
it('hidden input mirrors `required`/`disabled`', async () => {
const { wrapper } = mountInForm({ name: 'fruit', required: true, disabled: true });
await nextTick();
const input = wrapper.element.querySelector('input[type="radio"][name="fruit"]') as HTMLInputElement;
expect(input.required).toBe(true);
expect(input.disabled).toBe(true);
wrapper.unmount();
});
});
@@ -0,0 +1,118 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from '../index';
function mountGroup(opts: Record<string, unknown> = {}) {
const model = ref<string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(RadioGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | undefined) => { model.value = v; },
...opts,
}, {
default: () => [
h(RadioGroupItem, { value: 'a', id: 'a' }, { default: () => h(RadioGroupIndicator) }),
h(RadioGroupItem, { value: 'b', id: 'b' }, { default: () => h(RadioGroupIndicator) }),
h(RadioGroupItem, { value: 'c', id: 'c', disabled: true }, { default: () => h(RadioGroupIndicator) }),
],
}),
});
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('RadioGroup', () => {
it('renders role="radiogroup" and items with role="radio"', () => {
const { wrapper } = mountGroup();
const root = wrapper.element as HTMLElement;
expect(root.getAttribute('role')).toBe('radiogroup');
expect(document.querySelectorAll('[role="radio"]')).toHaveLength(3);
wrapper.unmount();
});
it('click selects and emits valueChange', async () => {
const { wrapper, model } = mountGroup();
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
a.click();
await nextTick();
expect(model.value).toBe('a');
expect(a.getAttribute('aria-checked')).toBe('true');
wrapper.unmount();
});
it('ArrowDown moves focus+selection to next enabled item', async () => {
const { wrapper, model } = mountGroup({ defaultValue: 'a' });
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
const b = document.querySelector<HTMLButtonElement>('#b')!;
a.focus();
press(a, 'ArrowDown');
await nextTick();
expect(document.activeElement).toBe(b);
expect(model.value).toBe('b');
wrapper.unmount();
});
it('skips disabled items on arrow', async () => {
const { wrapper, model } = mountGroup({ defaultValue: 'b' });
await nextTick();
const b = document.querySelector<HTMLButtonElement>('#b')!;
const a = document.querySelector<HTMLButtonElement>('#a')!;
b.focus();
press(b, 'ArrowDown');
await nextTick();
// Loops past disabled 'c' to 'a'
expect(document.activeElement).toBe(a);
expect(model.value).toBe('a');
wrapper.unmount();
});
it('only checked (or first enabled) has tabindex 0', async () => {
const { wrapper } = mountGroup();
await nextTick();
const [a, b] = document.querySelectorAll<HTMLButtonElement>('[role="radio"]');
expect(a!.tabIndex).toBe(0);
expect(b!.tabIndex).toBe(-1);
a!.click();
await nextTick();
expect(a!.tabIndex).toBe(0);
wrapper.unmount();
});
it('disabled item: aria-disabled=true, not clickable', async () => {
const { wrapper, model } = mountGroup();
await nextTick();
const c = document.querySelector<HTMLButtonElement>('#c')!;
expect(c.getAttribute('aria-disabled')).toBe('true');
c.click();
await nextTick();
expect(model.value).toBeUndefined();
wrapper.unmount();
});
it('RadioGroupIndicator renders only for checked item', async () => {
const { wrapper } = mountGroup({ defaultValue: 'b' });
await nextTick();
const b = document.querySelector<HTMLButtonElement>('#b')!;
expect(b.querySelector('span')).toBeTruthy();
const a = document.querySelector<HTMLButtonElement>('#a')!;
expect(a.querySelector('span')).toBeNull();
wrapper.unmount();
});
it('Space selects the focused item', async () => {
const { wrapper, model } = mountGroup();
await nextTick();
const b = document.querySelector<HTMLButtonElement>('#b')!;
b.focus();
press(b, ' ');
await nextTick();
expect(model.value).toBe('b');
wrapper.unmount();
});
});
@@ -0,0 +1,49 @@
import type { ComputedRef, Ref } from 'vue';
import type { RovingDirection, RovingOrientation } from '../../internal/utils/roving-focus';
import type { AcceptableValue } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface RadioGroupContext {
value: Ref<AcceptableValue | undefined>;
/**
* Commits a selection. Returns `true` when the value was applied, `false`
* when it was blocked (disabled group/item or a vetoed `select` event), so
* callers can decide whether to follow up (e.g. move focus).
*/
setValue: (v: AcceptableValue) => boolean;
/** Structural equality test against the selected value, honouring `by`. */
isChecked: (v: AcceptableValue) => boolean;
orientation: Ref<RovingOrientation>;
direction: Ref<RovingDirection>;
loop: Ref<boolean>;
disabled: Ref<boolean>;
required: Ref<boolean>;
name: Ref<string | undefined>;
/** DOM-ordered item elements, sourced from the internal Collection. */
items: ComputedRef<HTMLElement[]>;
/**
* The single element that holds the roving tab stop (the checked item, or the
* first enabled item when nothing is selected). Computed once in the Root so
* each item does an O(1) identity check instead of scanning `items`.
*/
tabStopElement: ComputedRef<HTMLElement | undefined>;
onItemKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
}
const rootCtx = useContextFactory<RadioGroupContext>('RadioGroupContext');
export const provideRadioGroupContext = rootCtx.provide;
export const useRadioGroupContext = rootCtx.inject;
export interface RadioGroupItemContext {
value: AcceptableValue;
checked: ComputedRef<boolean>;
disabled: ComputedRef<boolean>;
}
const itemCtx = useContextFactory<RadioGroupItemContext>('RadioGroupItemContext');
export const provideRadioGroupItemContext = itemCtx.provide;
export const useRadioGroupItemContext = itemCtx.inject;
export type { AcceptableValue, RadioCompareBy } from './utils';
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from '@robonen/primitives';
import { ref } from 'vue';
const plans = [
{ value: 'starter', label: 'Starter', hint: 'For side projects', price: '$0' },
{ value: 'pro', label: 'Pro', hint: 'For growing teams', price: '$19' },
{ value: 'enterprise', label: 'Enterprise', hint: 'Custom limits & SSO', price: 'Contact us', disabled: true },
];
const plan = ref('pro');
</script>
<template>
<div class="flex flex-col gap-4 p-6 max-w-sm bg-(--bg) text-(--fg) border border-(--border) rounded-xl">
<div>
<h3 class="text-sm font-semibold">
Choose a plan
</h3>
<p class="text-xs text-(--fg-subtle)">
Use the arrow keys to move between options.
</p>
</div>
<RadioGroupRoot v-model="plan" class="flex flex-col gap-2">
<label
v-for="p in plans"
:key="p.value"
class="group flex items-center gap-3 p-3 rounded-lg border border-(--border) bg-(--bg-inset) cursor-pointer transition-colors has-[[data-state=checked]]:border-(--accent) has-[[data-state=checked]]:bg-(--bg-subtle) has-[[data-disabled]]:opacity-50 has-[[data-disabled]]:cursor-not-allowed"
>
<RadioGroupItem
:value="p.value"
:disabled="p.disabled"
class="grid place-items-center shrink-0 w-4 h-4 rounded-full border border-(--border) bg-(--bg) outline-none transition-colors data-[state=checked]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
>
<RadioGroupIndicator class="w-2 h-2 rounded-full bg-(--accent)" />
</RadioGroupItem>
<div class="flex flex-col">
<span class="text-sm font-medium">{{ p.label }}</span>
<span class="text-xs text-(--fg-subtle)">{{ p.hint }}</span>
</div>
<span class="ml-auto text-sm font-semibold text-(--fg-muted)">{{ p.price }}</span>
</label>
</RadioGroupRoot>
<p class="text-xs text-(--fg-muted)">
Selected plan: <span class="font-semibold text-(--fg)">{{ plan }}</span>
</p>
</div>
</template>
@@ -0,0 +1,16 @@
export { default as Radio } from './Radio.vue';
export { default as RadioGroupIndicator } from './RadioGroupIndicator.vue';
export { default as RadioGroupItem } from './RadioGroupItem.vue';
export { default as RadioGroupRoot } from './RadioGroupRoot.vue';
export type { RadioEmits, RadioProps } from './Radio.vue';
export type { RadioGroupIndicatorProps } from './RadioGroupIndicator.vue';
export type { RadioGroupItemEmits, RadioGroupItemProps } from './RadioGroupItem.vue';
export type { RadioGroupRootEmits, RadioGroupRootProps } from './RadioGroupRoot.vue';
export {
provideRadioGroupContext,
provideRadioGroupItemContext,
useRadioGroupContext,
useRadioGroupItemContext,
} from './context';
export type { RadioGroupContext, RadioGroupItemContext } from './context';
export type { AcceptableValue as RadioGroupAcceptableValue, RadioCompareBy as RadioGroupCompareBy } from './utils';
@@ -0,0 +1,34 @@
import { isEqual } from '@robonen/stdlib';
/**
* Any value a radio item can carry — not just strings. Mirrors the
* select/listbox value model so a radio group can hold numbers, booleans, or
* plain objects (compared structurally or via `by`).
*/
export type AcceptableValue = string | number | boolean | Record<string, unknown> | null;
/**
* Strategy for comparing a radio item's `value` against the group's selected
* value. Omitted → structural deep equality (`@robonen/stdlib` `isEqual`), so
* object/array values toggle correctly; a function → custom comparator; a
* string → compare that property key.
*/
export type RadioCompareBy = string | ((a: AcceptableValue, b: AcceptableValue) => boolean);
/**
* Compare two radio values. `undefined` on the selected side never matches, so
* an unselected group reports every item as unchecked.
*/
export function compareValues(
selected: AcceptableValue | undefined,
candidate: AcceptableValue,
by?: RadioCompareBy,
): boolean {
if (selected === undefined) return false;
if (typeof by === 'function') return by(selected, candidate);
if (typeof by === 'string') {
return (selected as Record<string, unknown> | null)?.[by]
=== (candidate as Record<string, unknown> | null)?.[by];
}
return isEqual(selected, candidate);
}
@@ -0,0 +1,82 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The filled portion of the track representing the selected span, rendered
* inside `SliderTrack`. It reads the values and bounds from context and absolutely
* positions itself from the slider's start to the active thumb (or between the
* lowest and highest thumbs in range mode), respecting orientation and direction.
*/
export interface SliderRangeProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useSliderContext } from './context';
const { as = 'span' } = defineProps<SliderRangeProps>();
const { forwardRef } = useForwardExpose();
const ctx = useSliderContext();
// Single-pass min/max over values normalized to percent; no spread, no `.map`.
const percentages = computed<[number, number]>(() => {
const values = ctx.values.value;
const min = ctx.min.value;
const range = ctx.max.value - min;
if (range === 0 || values.length === 0) return [0, 0];
const first = ((values[0]! - min) / range) * 100;
if (values.length === 1) return [0, first];
let lo = first;
let hi = first;
for (let i = 1; i < values.length; i++) {
const p = ((values[i]! - min) / range) * 100;
if (p < lo) lo = p;
else if (p > hi) hi = p;
}
return [lo, hi];
});
// Stable shape: always the same keys in the same order. Unused sides explicitly
// `undefined` so V8 (and Vue's style patcher) sees a monomorphic object.
const style = computed<{
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
}>(() => {
const [start, end] = percentages.value;
const startPct = `${start}%`;
const endPct = `${100 - end}%`;
const horizontal = ctx.orientation.value === 'horizontal';
if (horizontal) {
const flip = (ctx.direction.value === 'rtl') !== ctx.inverted.value;
return {
left: flip ? endPct : startPct,
right: flip ? startPct : endPct,
top: undefined,
bottom: undefined,
};
}
const flip = ctx.inverted.value;
return {
left: undefined,
right: undefined,
top: flip ? startPct : endPct,
bottom: flip ? endPct : startPct,
};
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:style="style"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,350 @@
<script lang="ts">
import type { SliderDirection, SliderOrientation, SliderThumbAlignment, SliderValueText } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* An accessible slider for picking one or more numeric values from a range by
* dragging a thumb along a track or using the keyboard. The root owns the value
* state (controlled via `v-model` or uncontrolled via `defaultValue`), snaps to
* `step`, clamps to `min`/`max`, and handles pointer drags, focus, and arrow /
* Page / Home / End keys. Pass multiple values for a range (multi-thumb) slider
* and keep thumbs apart with `minStepsBetweenThumbs`; supports horizontal and
* vertical `orientation`, `dir`/`inverted` direction, and emits `valueCommit`
* when a drag or keypress settles. It provides context to `SliderTrack`,
* `SliderRange`, and `SliderThumb`, and renders hidden form inputs when `name`
* is set. Reach for it whenever a user should choose a value within known bounds
* (volume, price range, brightness).
*/
export interface SliderRootProps extends PrimitiveProps {
/** Min value. @default 0 */
min?: number;
/** Max value. @default 100 */
max?: number;
/** Step granularity. @default 1 */
step?: number;
/** Minimum step count between adjacent thumbs in range mode. @default 0 */
minStepsBetweenThumbs?: number;
/** Orientation. @default 'horizontal' */
orientation?: SliderOrientation;
/**
* Writing direction. When omitted it is inherited from the nearest
* `ConfigProvider` (falling back to `'ltr'`); an explicit value wins.
*/
dir?: SliderDirection;
/** Invert the direction of interaction. @default false */
inverted?: boolean;
/** Disable all interaction. */
disabled?: boolean;
/** Uncontrolled initial value(s). Accepts single number or array. */
defaultValue?: number | number[];
/** Hidden input `name` attribute. */
name?: string;
/** Mark hidden inputs as required. */
required?: boolean;
/**
* Thumb positioning strategy.
* - `'overflow'` (default): thumbs are positioned purely by percentage and
* may overflow the track at the extremes (offset via CSS if needed).
* - `'contain'`: thumbs are inset so they stay fully within the track.
* @default 'overflow'
*/
thumbAlignment?: SliderThumbAlignment;
/**
* Multiplier applied to `step` for large jumps (Page Up/Down and
* Shift+Arrow). @default 10
*/
largeStep?: number;
/**
* Optional formatter producing a human-friendly `aria-valuetext` for each
* thumb. Receives the raw value and its thumb index.
*/
valueText?: SliderValueText;
}
export interface SliderRootEmits {
valueCommit: [value: number[]];
}
</script>
<script setup lang="ts">
import {
getClosestValueIndex,
getStepDecimals,
hasMinStepsBetweenSortedValues,
roundToStep,
scaleLinear,
} from './utils';
import { computed, nextTick, onScopeDispose, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import { provideSliderContext } from './context';
import { useDirection } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
import { clamp } from '@robonen/stdlib';
const {
min = 0,
max = 100,
step = 1,
minStepsBetweenThumbs = 0,
orientation = 'horizontal',
dir,
inverted = false,
disabled = false,
defaultValue,
name,
required,
thumbAlignment = 'overflow',
largeStep = 10,
valueText,
as = 'span',
} = defineProps<SliderRootProps>();
// Resolve direction: explicit `dir` prop wins, otherwise inherit from the
// nearest `ConfigProvider` (falls back to `'ltr'`).
const direction = useDirection(() => dir);
const emit = defineEmits<SliderRootEmits>();
// `defineModel` drives both controlled (`v-model`) and uncontrolled modes; in
// uncontrolled mode `model.value` is `undefined` until first write, so the
// internal `localValues` below seeds from `defaultValue`/`min`. `null` is
// accepted (and treated like "no value") for parity with controllers that
// reset by binding `null`.
const model = defineModel<number[] | null>();
function toArray(v: number | number[] | null | undefined): number[] {
if (Array.isArray(v)) return v.slice();
if (typeof v === 'number') return [v];
return [];
}
const seed = Array.isArray(model.value) ? model.value.slice() : toArray(defaultValue);
// `shallowRef` — the array is always replaced wholesale; no need to proxy items.
const localValues = shallowRef<number[]>(seed.length > 0 ? seed : [min]);
// Cache decimals per `step` — `String(step).split('.')` out of the pointermove path.
let stepDecimals = getStepDecimals(step);
watch(() => step, (s) => {
stepDecimals = getStepDecimals(s);
});
watch(model, (v) => {
if (v === null || v === undefined) return;
// Ref-level check is enough; the setter below guards on deep equality again.
if (v === localValues.value) return;
localValues.value = v.slice();
});
const values = computed<number[]>({
get: () => localValues.value,
set: (v) => {
localValues.value = v;
// `defineModel` emits `update:modelValue` on write — no manual emit needed.
model.value = v;
},
});
const trackRef = shallowRef<HTMLElement | null>(null);
const thumbEls: HTMLElement[] = [];
function registerThumb(el: HTMLElement): number {
const existing = thumbEls.indexOf(el);
if (existing !== -1) return existing;
thumbEls.push(el);
return thumbEls.length - 1;
}
function unregisterThumb(el: HTMLElement): void {
const i = thumbEls.indexOf(el);
if (i !== -1) thumbEls.splice(i, 1);
}
function getThumbIndex(el: HTMLElement): number {
return thumbEls.indexOf(el);
}
function setValue(next: number[]): void {
const prev = localValues.value;
if (prev.length === next.length) {
let equal = true;
for (let i = 0; i < next.length; i++) {
if (prev[i] !== next[i]) {
equal = false;
break;
}
}
if (equal) return;
}
values.value = next;
}
function commit(): void {
emit('valueCommit', localValues.value.slice());
}
function updateValue(index: number, raw: number): void {
if (disabled) return;
const prev = localValues.value;
// Snap to step & clamp to bounds.
let v = clamp(roundToStep(raw, step, min, stepDecimals), min, max);
// Clamp to neighbours (prev/next) to preserve sort order & minStepsBetweenThumbs.
const minGap = minStepsBetweenThumbs * step;
const prevVal = prev[index - 1];
const nextVal = prev[index + 1];
if (prevVal !== undefined) v = Math.max(v, prevVal + minGap);
if (nextVal !== undefined) v = Math.min(v, nextVal - minGap);
v = clamp(v, min, max);
if (v === prev[index]) return;
// Single allocation: copy + assign. Sort order is preserved by neighbour-clamp.
const candidate = prev.slice();
candidate[index] = v;
if (!hasMinStepsBetweenSortedValues(candidate, minStepsBetweenThumbs, step)) return;
setValue(candidate);
}
function getValueFromPointer(event: PointerEvent): number {
const track = trackRef.value;
if (!track) return min;
const rect = track.getBoundingClientRect();
const horizontal = orientation === 'horizontal';
const size = horizontal ? rect.width : rect.height;
if (size === 0) return min;
let offset = horizontal ? event.clientX - rect.left : event.clientY - rect.top;
// ltr horizontal: left = min. rtl or inverted flip.
// For vertical we invert by default (top = max visually); `inverted` flips back.
const flip = (horizontal ? direction.value === 'rtl' : true) !== inverted;
if (flip) offset = size - offset;
return scaleLinear(offset, 0, size, min, max);
}
let activeIndex = -1;
function handlePointerMove(event: PointerEvent): void {
if (activeIndex === -1) return;
updateValue(activeIndex, getValueFromPointer(event));
}
// Single teardown for the gesture-scoped window listeners, shared by pointerup,
// pointercancel and scope-dispose so none of them can strand the others.
function stopDrag(): void {
activeIndex = -1;
globalThis.removeEventListener('pointermove', handlePointerMove);
globalThis.removeEventListener('pointerup', handlePointerUp);
globalThis.removeEventListener('pointercancel', handlePointerCancel);
}
function handlePointerUp(): void {
if (activeIndex === -1) return;
stopDrag();
commit();
}
// Without this, an OS-interrupted gesture (touch cancelled, lost pointer capture,
// context menu, scroll takeover) never fires pointerup: the window listeners stay
// attached and `activeIndex` stays pinned to a stale thumb. Abort with no commit
// (the live value already reflects the last committed move).
function handlePointerCancel(): void {
if (activeIndex === -1) return;
stopDrag();
}
function startDragFromTrack(event: PointerEvent): void {
if (disabled) return;
const raw = getValueFromPointer(event);
const index = getClosestValueIndex(localValues.value, raw);
activeIndex = index;
updateValue(index, raw);
// Focus the thumb we just grabbed.
nextTick(() => thumbEls[index]?.focus());
globalThis.addEventListener('pointermove', handlePointerMove);
globalThis.addEventListener('pointerup', handlePointerUp);
globalThis.addEventListener('pointercancel', handlePointerCancel);
}
// A mid-drag unmount (slider removed while a thumb is held) would otherwise leak
// the window listeners, which retain this instance via their closures.
onScopeDispose(stopDrag);
// Drag is initiated on the ROOT, not the track: thumbs are siblings of the
// track (the standard anatomy), so a press on a thumb does NOT bubble to the
// track — it bubbles here. Handling it on the root drags whether the press
// lands on the track, the range, or a thumb.
function onRootPointerDown(event: PointerEvent): void {
if (disabled || event.button !== 0) return;
event.preventDefault();
(event.target as HTMLElement).setPointerCapture?.(event.pointerId);
startDragFromTrack(event);
}
// Keep values clamped if bounds change.
watch([() => min, () => max], ([nmin, nmax]) => {
const cur = localValues.value;
// Packed array: push into `[]` rather than `new Array(n)` (holey SMI → can't
// transition back). Small array, V8 keeps it PACKED_DOUBLE/SMI_ELEMENTS.
const clamped: number[] = [];
let changed = false;
for (let i = 0; i < cur.length; i++) {
const c = clamp(cur[i]!, nmin, nmax);
clamped.push(c);
if (c !== cur[i]) changed = true;
}
if (changed) setValue(clamped);
});
function contextUpdateValue(i: number, v: number): void {
updateValue(i, v);
commit();
}
provideSliderContext({
values,
// `toRef(() => prop)` → `GetterRefImpl`: reactive `Ref` without a
// `ReactiveEffect`/cache, since these are identity passthroughs.
min: toRef(() => min),
max: toRef(() => max),
step: toRef(() => step),
orientation: toRef(() => orientation),
// `direction` is already a `ComputedRef<SliderDirection>` from `useDirection`.
direction,
disabled: toRef(() => disabled),
inverted: toRef(() => inverted),
thumbAlignment: toRef(() => thumbAlignment),
valueText: toRef(() => valueText),
largeStepMultiplier: toRef(() => largeStep),
trackRef,
registerThumb,
unregisterThumb,
getThumbIndex,
updateValue: contextUpdateValue,
startDragFromTrack,
});
defineExpose({ values });
// `useForwardExpose` runs AFTER `defineExpose` so the composable merges the
// prior expose bindings (plus props + `$el`) instead of `defineExpose`'s
// `expose()` clobbering them and warning "expose() should be called only once".
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
:data-orientation="orientation"
:dir="direction"
@pointerdown="onRootPointerDown"
>
<slot :values="values" />
<template v-if="name">
<input
v-for="(v, i) in values"
:key="i"
type="hidden"
:name="values.length > 1 ? `${name}[]` : name"
:value="v"
:required="required"
>
</template>
</Primitive>
</template>
@@ -0,0 +1,193 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A draggable handle rendered as `role="slider"`, one per value, placed inside
* `SliderTrack`. It registers with the root to claim its index, positions itself
* along the track by its value's percentage, and handles keyboard interaction
* (arrow keys step by `step`, Page Up/Down by a larger step, Home/End jump to
* the bounds) with full ARIA value attributes. Render one thumb for a single
* value or several for a range; give each an `aria-label`. Exposes `value` and
* `percent` as slot props.
*/
export interface SliderThumbProps extends PrimitiveProps {
// `aria-label` (and other ARIA attributes) are intentionally NOT declared as
// props so they fall through to the rendered `role="slider"` element — give
// each thumb an `aria-label` for its accessible name.
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, onBeforeUnmount, ref, useAttrs, watch } from 'vue';
import { useElementSize, useForwardExpose } from '@robonen/vue';
import { useSliderContext } from './context';
import { getDefaultThumbLabel, getThumbInBoundsOffset } from './utils';
const { as = 'span' } = defineProps<SliderThumbProps>();
const ctx = useSliderContext();
const attrs = useAttrs();
const { forwardRef, currentElement } = useForwardExpose();
const index = ref(-1);
watch(currentElement, (node) => {
if (node) index.value = ctx.registerThumb(node);
else index.value = -1;
});
onBeforeUnmount(() => {
if (currentElement.value) ctx.unregisterThumb(currentElement.value);
});
const value = computed(() => ctx.values.value[index.value] ?? ctx.min.value);
const percentage = computed(() => {
const min = ctx.min.value;
const range = ctx.max.value - min;
if (range === 0) return 0;
return ((value.value - min) / range) * 100;
});
// Measure the thumb only matters for `thumbAlignment: 'contain'`; for the
// default `'overflow'` path the size is never read, so it adds no reactivity.
const { width, height } = useElementSize(currentElement);
// In-bounds inset (px) so the thumb stays fully within the track at the
// extremes. `0` for `'overflow'` (the default) — same positioning as before.
const inBoundsOffset = computed(() => {
if (ctx.thumbAlignment.value !== 'contain') return 0;
const horizontal = ctx.orientation.value === 'horizontal';
const size = horizontal ? width.value : height.value;
if (size === 0) return 0;
const rtl = ctx.direction.value === 'rtl';
const inverted = ctx.inverted.value;
// `direction` mirrors the positioning edge: +1 when the start edge sits at
// the low end, -1 when flipped (rtl/inverted in horizontal, inverted in
// vertical).
const flip = horizontal ? rtl !== inverted : inverted;
const direction = flip ? -1 : 1;
return getThumbInBoundsOffset(size, percentage.value, direction);
});
// `left: calc(P% + Opx)` keeps the monomorphic style shape while folding in
// the contain offset (which is `0px` for overflow).
function edge(pct: number, offset: number): string {
return offset === 0 ? `${pct}%` : `calc(${pct}% + ${offset}px)`;
}
// Stable shape: always return the same keys in the same order so V8 keeps
// one hidden class for this object and the style patcher sees a monomorphic
// input. Unused sides are explicit `undefined`.
const positionStyle = computed<{
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
}>(() => {
const pct = percentage.value;
const offset = inBoundsOffset.value;
const horizontal = ctx.orientation.value === 'horizontal';
const rtl = ctx.direction.value === 'rtl';
const inverted = ctx.inverted.value;
if (horizontal) {
const flip = rtl !== inverted;
return {
left: flip ? undefined : edge(pct, offset),
right: flip ? edge(pct, offset) : undefined,
top: undefined,
bottom: undefined,
};
}
return {
left: undefined,
right: undefined,
top: inverted ? edge(pct, offset) : undefined,
bottom: inverted ? undefined : edge(pct, offset),
};
});
// Fall back to a generated label (`Minimum`/`Maximum`/`Value N of M`) only when
// the consumer has not supplied an explicit `aria-label`/`aria-labelledby`.
const accessibleLabel = computed<string | undefined>(() => {
const hasLabel = attrs['aria-label'] !== undefined && attrs['aria-label'] !== null;
const hasLabelledBy = attrs['aria-labelledby'] !== undefined && attrs['aria-labelledby'] !== null;
if (hasLabel || hasLabelledBy) return undefined;
return getDefaultThumbLabel(index.value, ctx.values.value.length);
});
// Humanised `aria-valuetext` from the optional formatter; the consumer can
// still override by passing their own `aria-valuetext` (it wins via `$attrs`).
const valueText = computed<string | undefined>(() => {
if (attrs['aria-valuetext'] !== undefined && attrs['aria-valuetext'] !== null) return undefined;
const fmt = ctx.valueText.value;
return fmt ? fmt(value.value, index.value) : undefined;
});
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value || index.value === -1) return;
const horizontal = ctx.orientation.value === 'horizontal';
const rtl = ctx.direction.value === 'rtl';
const step = ctx.step.value;
// Shift+Arrow jumps by the same large step as Page Up/Down.
const big = step * ctx.largeStepMultiplier.value;
const current = ctx.values.value[index.value] ?? ctx.min.value;
const unit = event.shiftKey ? big : step;
let delta: number;
switch (event.key) {
case 'ArrowRight':
delta = horizontal ? (rtl ? -unit : unit) : 0;
break;
case 'ArrowLeft':
delta = horizontal ? (rtl ? unit : -unit) : 0;
break;
case 'ArrowUp':
delta = horizontal ? 0 : unit;
break;
case 'ArrowDown':
delta = horizontal ? 0 : -unit;
break;
case 'PageUp':
delta = big;
break;
case 'PageDown':
delta = -big;
break;
case 'Home':
event.preventDefault();
ctx.updateValue(index.value, ctx.min.value);
return;
case 'End':
event.preventDefault();
ctx.updateValue(index.value, ctx.max.value);
return;
default:
return;
}
if (delta === 0) return;
event.preventDefault();
ctx.updateValue(index.value, current + delta);
}
</script>
<template>
<Primitive
:as="as"
:ref="forwardRef"
role="slider"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="accessibleLabel"
:aria-valuemin="ctx.min.value"
:aria-valuemax="ctx.max.value"
:aria-valuenow="value"
:aria-valuetext="valueText"
:aria-orientation="ctx.orientation.value"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:style="positionStyle"
@keydown="onKeyDown"
>
<slot :value="value" :percent="percentage" />
</Primitive>
</template>
@@ -0,0 +1,44 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The full-length rail the thumbs travel along, rendered inside `SliderRoot`.
* It registers itself as the geometry reference for pointer math and starts a
* drag (moving the nearest thumb to the click position) when pressed. Use it as
* the container for `SliderRange` and one or more `SliderThumb`.
*/
export interface SliderTrackProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { onBeforeUnmount, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useSliderContext } from './context';
const { as = 'span' } = defineProps<SliderTrackProps>();
const ctx = useSliderContext();
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (node) => {
ctx.trackRef.value = node ?? null;
});
onBeforeUnmount(() => {
ctx.trackRef.value = null;
});
// NOTE: the drag-initiating `pointerdown` lives on `SliderRoot`, not here —
// thumbs are siblings of the track, so a press on a thumb would never reach a
// track-level handler. The track only registers itself as the geometry ref.
</script>
<template>
<Primitive
:as="as"
:ref="forwardRef"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,571 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { provideConfig } from '../../../utilities/config-provider';
import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from '../index';
// Minimal declarative config wrapper (the package ships no ConfigProvider
// component; config is provided via `provideConfig` inside a setup).
const ConfigProvider = defineComponent({
props: { dir: { type: String, default: undefined } },
setup(props, { slots }) {
provideConfig({ dir: () => props.dir as 'ltr' | 'rtl' | undefined });
return () => slots.default?.();
},
});
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountSingle(opts: Partial<{ min: number; max: number; step: number; defaultValue: number; disabled: boolean; orientation: 'horizontal' | 'vertical'; dir: 'ltr' | 'rtl' }> = {}) {
const model = ref<number[] | undefined>(undefined);
const Harness = defineComponent({
setup() {
return () => h(SliderRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: number[]) => { model.value = v; },
...opts,
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'Volume' }),
],
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model };
}
function mountRange(opts: Partial<{ min: number; max: number; step: number; defaultValue: number[]; minStepsBetweenThumbs: number }> = {}) {
const model = ref<number[] | undefined>(undefined);
const Harness = defineComponent({
setup() {
return () => h(SliderRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: number[]) => { model.value = v; },
...opts,
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { id: 'thumb-0' }),
h(SliderThumb, { id: 'thumb-1' }),
],
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model };
}
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false });
el.dispatchEvent(event);
}
describe('Slider — single thumb', () => {
it('renders with role="slider" and ARIA attrs', async () => {
mountSingle({ defaultValue: 40, min: 0, max: 100 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb).toBeTruthy();
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
expect(thumb.getAttribute('aria-valuemax')).toBe('100');
expect(thumb.getAttribute('aria-valuenow')).toBe('40');
expect(thumb.getAttribute('aria-orientation')).toBe('horizontal');
expect(thumb.tabIndex).toBe(0);
});
it('ArrowRight/ArrowLeft adjust by step', async () => {
const { model } = mountSingle({ defaultValue: 50, step: 5 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toEqual([55]);
keydown(thumb, 'ArrowLeft');
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(model.value).toEqual([45]);
});
it('ArrowLeft is reversed in RTL', async () => {
const { model } = mountSingle({ defaultValue: 50, step: 5, dir: 'rtl' });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(model.value).toEqual([55]);
});
it('Home/End clamp to min/max', async () => {
const { model } = mountSingle({ defaultValue: 50, min: 0, max: 100 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'Home');
await nextTick();
expect(model.value).toEqual([0]);
keydown(thumb, 'End');
await nextTick();
expect(model.value).toEqual([100]);
});
it('PageUp / PageDown step by 10×', async () => {
const { model } = mountSingle({ defaultValue: 50, step: 1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'PageUp');
await nextTick();
expect(model.value).toEqual([60]);
keydown(thumb, 'PageDown');
keydown(thumb, 'PageDown');
await nextTick();
expect(model.value).toEqual([40]);
});
it('clamps at min / max', async () => {
const { model } = mountSingle({ defaultValue: 95, min: 0, max: 100, step: 10 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toEqual([100]);
keydown(thumb, 'End');
await nextTick();
expect(model.value).toEqual([100]);
keydown(thumb, 'Home');
await nextTick();
expect(model.value).toEqual([0]);
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(model.value).toEqual([0]);
});
it('disabled: tabindex=-1 and keys do nothing', async () => {
const { model } = mountSingle({ defaultValue: 50, disabled: true });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.tabIndex).toBe(-1);
expect(thumb.getAttribute('aria-disabled')).toBe('true');
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toBeUndefined();
});
it('vertical orientation reports aria-orientation', async () => {
mountSingle({ defaultValue: 50, orientation: 'vertical' });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-orientation')).toBe('vertical');
});
it('writes hidden input when name is set', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: 30, name: 'volume' }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const input = document.querySelector<HTMLInputElement>('input[type="hidden"][name="volume"]')!;
expect(input).toBeTruthy();
expect(input.value).toBe('30');
});
});
describe('Slider — range (two thumbs)', () => {
it('renders two thumbs with independent aria-valuenow', async () => {
mountRange({ defaultValue: [20, 80] });
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs).toHaveLength(2);
expect(thumbs[0]!.getAttribute('aria-valuenow')).toBe('20');
expect(thumbs[1]!.getAttribute('aria-valuenow')).toBe('80');
});
it('preserves order by clamping against neighbour', async () => {
const { model } = mountRange({ defaultValue: [40, 50], step: 1 });
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
// Push first thumb right past second
for (let i = 0; i < 20; i++) keydown(thumbs[0]!, 'ArrowRight');
await nextTick();
expect(model.value![0]).toBeLessThanOrEqual(model.value![1]!);
expect(model.value![0]).toBe(50);
});
it('respects minStepsBetweenThumbs', async () => {
const { model } = mountRange({ defaultValue: [30, 50], step: 1, minStepsBetweenThumbs: 10 });
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
// Try to move first thumb up; should stop 10 below second.
for (let i = 0; i < 30; i++) keydown(thumbs[0]!, 'ArrowRight');
await nextTick();
expect(model.value![0]).toBe(40);
expect(model.value![1]).toBe(50);
});
it('writes hidden inputs with [] suffix for range', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: [10, 90], name: 'range' }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const inputs = document.querySelectorAll<HTMLInputElement>('input[type="hidden"][name="range[]"]');
expect(inputs).toHaveLength(2);
expect(inputs[0]!.value).toBe('10');
expect(inputs[1]!.value).toBe('90');
});
});
describe('Slider — Shift+Arrow large step', () => {
it('Shift+ArrowRight/Left jumps by step × largeStep multiplier (default 10×)', async () => {
const { model } = mountSingle({ defaultValue: 50, step: 1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight', { shiftKey: true });
await nextTick();
expect(model.value).toEqual([60]);
keydown(thumb, 'ArrowLeft', { shiftKey: true });
keydown(thumb, 'ArrowLeft', { shiftKey: true });
await nextTick();
expect(model.value).toEqual([40]);
});
it('Shift+ArrowUp/Down jumps by large step in vertical orientation', async () => {
const { model } = mountSingle({ defaultValue: 50, step: 1, orientation: 'vertical' });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowUp', { shiftKey: true });
await nextTick();
expect(model.value).toEqual([60]);
});
it('honours a custom largeStep prop for both Page and Shift+Arrow', async () => {
const Harness = defineComponent({
setup() {
const model = ref<number[]>([50]);
return () => h(SliderRoot, {
modelValue: model.value,
step: 1,
largeStep: 25,
'onUpdate:modelValue': (v: number[]) => { model.value = v; },
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'V' }),
],
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'PageUp');
await nextTick();
expect(thumb.getAttribute('aria-valuenow')).toBe('75');
keydown(thumb, 'ArrowLeft', { shiftKey: true });
await nextTick();
expect(thumb.getAttribute('aria-valuenow')).toBe('50');
w.unmount();
});
it('Shift has no effect on Home/End (still clamps to bounds)', async () => {
const { model } = mountSingle({ defaultValue: 50, min: 0, max: 100 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'Home', { shiftKey: true });
await nextTick();
expect(model.value).toEqual([0]);
});
});
describe('Slider — default accessible thumb labels', () => {
it('single thumb has no auto label (value is self-describing)', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: 40 }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-label')).toBeNull();
});
it('two thumbs default to Minimum / Maximum', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: [20, 80] }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs[0]!.getAttribute('aria-label')).toBe('Minimum');
expect(thumbs[1]!.getAttribute('aria-label')).toBe('Maximum');
});
it('three or more thumbs default to "Value N of M"', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: [10, 50, 90] }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
h(SliderThumb),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs[0]!.getAttribute('aria-label')).toBe('Value 1 of 3');
expect(thumbs[1]!.getAttribute('aria-label')).toBe('Value 2 of 3');
expect(thumbs[2]!.getAttribute('aria-label')).toBe('Value 3 of 3');
});
it('an explicit aria-label wins over the default', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: [20, 80] }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'Lower bound' }),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs[0]!.getAttribute('aria-label')).toBe('Lower bound');
expect(thumbs[1]!.getAttribute('aria-label')).toBe('Maximum');
});
it('aria-labelledby suppresses the default label', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, { defaultValue: [20, 80] }, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-labelledby': 'lbl-0' }),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs[0]!.getAttribute('aria-label')).toBeNull();
expect(thumbs[0]!.getAttribute('aria-labelledby')).toBe('lbl-0');
});
});
describe('Slider — aria-valuetext formatter', () => {
it('applies the valueText formatter to each thumb', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, {
defaultValue: [20, 80],
valueText: (v: number) => `${v}%`,
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs[0]!.getAttribute('aria-valuetext')).toBe('20%');
expect(thumbs[1]!.getAttribute('aria-valuetext')).toBe('80%');
});
it('passes the thumb index to the formatter', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, {
defaultValue: [20, 80],
valueText: (v: number, i: number) => `idx${i}:${v}`,
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb),
h(SliderThumb),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumbs = document.querySelectorAll<HTMLElement>('[role="slider"]');
expect(thumbs[0]!.getAttribute('aria-valuetext')).toBe('idx0:20');
expect(thumbs[1]!.getAttribute('aria-valuetext')).toBe('idx1:80');
});
it('no aria-valuetext when no formatter is provided', async () => {
mountSingle({ defaultValue: 40 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-valuetext')).toBeNull();
});
it('an explicit aria-valuetext attr wins over the formatter', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, {
defaultValue: [50],
valueText: (v: number) => `${v}%`,
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-valuetext': 'fifty' }),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-valuetext')).toBe('fifty');
});
});
describe('Slider — global direction inheritance', () => {
it('inherits dir="rtl" from a ConfigProvider when no dir prop is set', async () => {
const Harness = defineComponent({
setup() {
const model = ref<number[]>([50]);
return () => h(ConfigProvider, { dir: 'rtl' }, {
default: () => h(SliderRoot, {
modelValue: model.value,
step: 5,
'onUpdate:modelValue': (v: number[]) => { model.value = v; },
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'V', id: 't' }),
],
}),
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.getElementById('t')!;
// In RTL, ArrowLeft increases the value (reversed) — proving the inherited dir reached the thumb.
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(thumb.getAttribute('aria-valuenow')).toBe('55');
w.unmount();
});
it('an explicit dir prop overrides the ConfigProvider dir', async () => {
const Harness = defineComponent({
setup() {
const model = ref<number[]>([50]);
return () => h(ConfigProvider, { dir: 'rtl' }, {
default: () => h(SliderRoot, {
modelValue: model.value,
step: 5,
dir: 'ltr',
'onUpdate:modelValue': (v: number[]) => { model.value = v; },
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'V', id: 't2' }),
],
}),
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.getElementById('t2')!;
// Explicit ltr: ArrowLeft decreases.
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(thumb.getAttribute('aria-valuenow')).toBe('45');
w.unmount();
});
});
describe('Slider — null modelValue tolerance', () => {
it('seeds from defaultValue when modelValue is null', async () => {
const Harness = defineComponent({
setup() {
const model = ref<number[] | null>(null);
return () => h(SliderRoot, {
modelValue: model.value,
defaultValue: 30,
'onUpdate:modelValue': (v: number[]) => { model.value = v; },
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'V' }),
],
});
},
});
const w = track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-valuenow')).toBe('30');
w.unmount();
});
});
describe('Slider — thumbAlignment', () => {
it('overflow (default) positions thumbs purely by percentage', async () => {
mountSingle({ defaultValue: 50, min: 0, max: 100 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
// 50% with no in-bounds offset.
expect(thumb.style.left).toBe('50%');
});
it('contain produces a calc() with an in-bounds offset', async () => {
const Harness = defineComponent({
setup: () => () => h(SliderRoot, {
defaultValue: 0,
min: 0,
max: 100,
thumbAlignment: 'contain',
}, {
default: () => [
h(SliderTrack, null, { default: () => h(SliderRange) }),
h(SliderThumb, { 'aria-label': 'V' }),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
// jsdom reports element size as 0, so the offset collapses to 0px and the
// contain path still yields a percentage (no crash, monomorphic shape).
expect(thumb.style.left).toBe('0%');
expect(thumb.getAttribute('data-orientation')).toBe('horizontal');
});
});
@@ -0,0 +1,59 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type SliderOrientation = 'horizontal' | 'vertical';
export type SliderDirection = 'ltr' | 'rtl';
/**
* Thumb positioning strategy.
* - `'overflow'` — thumbs are positioned purely by percentage; at the
* extremes half the thumb sits outside the track (default — preserves the
* original behaviour and lets the consumer offset via CSS `transform`).
* - `'contain'` — thumbs are inset so they stay fully within the track bounds
* at `0 %` / `100 %`.
*/
export type SliderThumbAlignment = 'overflow' | 'contain';
/**
* Formatter turning a raw thumb value into a human-friendly string for
* `aria-valuetext`. Receives the value and its thumb index; return `undefined`
* to omit `aria-valuetext` for that thumb.
*/
export type SliderValueText = (value: number, index: number) => string | undefined;
/**
* Context shared between `SliderRoot` and its descendants.
*
* Scalar props are exposed as plain `Ref<T>` values, but `SliderRoot` builds
* them with `toRef(() => prop)` — a `GetterRefImpl` that is reactive without
* allocating a `ReactiveEffect` / cache (unlike `computed`). For identity
* passthrough of scalar props this avoids seven redundant effects per
* instance while keeping template auto-unwrap and `.value` ergonomics.
*/
export interface SliderContext {
values: Ref<number[]>;
min: Ref<number>;
max: Ref<number>;
step: Ref<number>;
orientation: Ref<SliderOrientation>;
direction: Ref<SliderDirection>;
disabled: Ref<boolean>;
inverted: Ref<boolean>;
/** Thumb positioning strategy — `'overflow'` (default) or `'contain'`. */
thumbAlignment: Ref<SliderThumbAlignment>;
/** Optional formatter for per-thumb `aria-valuetext`; `undefined` when unset. */
valueText: Ref<SliderValueText | undefined>;
trackRef: Ref<HTMLElement | null>;
registerThumb: (el: HTMLElement) => number;
unregisterThumb: (el: HTMLElement) => void;
getThumbIndex: (el: HTMLElement) => number;
/** Large-step multiplier (Page keys / Shift+Arrow) applied to `step`. */
largeStepMultiplier: Ref<number>;
updateValue: (index: number, next: number) => void;
startDragFromTrack: (event: PointerEvent) => void;
}
const ctx = useContextFactory<SliderContext>('SliderContext');
export const provideSliderContext = ctx.provide;
export const useSliderContext = ctx.inject;
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from '@robonen/primitives';
import { ref } from 'vue';
const volume = ref([60]);
const price = ref([20, 80]);
</script>
<template>
<div class="demo-card w-full max-w-sm space-y-8 p-6 text-fg">
<!-- Single value -->
<div class="space-y-3">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Volume</span>
<span class="font-mono text-fg-muted">{{ volume[0] }}%</span>
</div>
<SliderRoot
v-model="volume"
:min="0"
:max="100"
:step="1"
class="relative flex h-5 w-full touch-none select-none items-center"
>
<SliderTrack class="relative h-1.5 w-full grow rounded-full bg-bg-inset">
<SliderRange class="absolute h-full rounded-full bg-accent" />
</SliderTrack>
<SliderThumb
aria-label="Volume"
class="absolute block h-4 w-4 -translate-x-1/2 rounded-full border-2 border-accent bg-bg shadow-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
/>
</SliderRoot>
</div>
<!-- Range (two thumbs) -->
<div class="space-y-3">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Price range</span>
<span class="font-mono text-fg-muted">${{ price[0] }} ${{ price[1] }}</span>
</div>
<SliderRoot
v-model="price"
:min="0"
:max="100"
:step="5"
:min-steps-between-thumbs="1"
class="relative flex h-5 w-full touch-none select-none items-center"
>
<SliderTrack class="relative h-1.5 w-full grow rounded-full bg-bg-inset">
<SliderRange class="absolute h-full rounded-full bg-accent" />
</SliderTrack>
<SliderThumb
aria-label="Minimum price"
class="absolute block h-4 w-4 -translate-x-1/2 rounded-full border-2 border-accent bg-bg shadow-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
/>
<SliderThumb
aria-label="Maximum price"
class="absolute block h-4 w-4 -translate-x-1/2 rounded-full border-2 border-accent bg-bg shadow-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
/>
</SliderRoot>
</div>
<!-- Disabled -->
<div class="space-y-3">
<span class="text-sm font-medium text-fg-muted">Disabled</span>
<SliderRoot
:default-value="40"
disabled
class="relative flex h-5 w-full touch-none select-none items-center opacity-50"
>
<SliderTrack class="relative h-1.5 w-full grow rounded-full bg-bg-inset">
<SliderRange class="absolute h-full rounded-full bg-fg-subtle" />
</SliderTrack>
<SliderThumb
aria-label="Disabled value"
class="absolute block h-4 w-4 -translate-x-1/2 rounded-full border-2 border-fg-subtle bg-bg"
/>
</SliderRoot>
</div>
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as SliderRange } from './SliderRange.vue';
export { default as SliderRoot } from './SliderRoot.vue';
export { default as SliderThumb } from './SliderThumb.vue';
export { default as SliderTrack } from './SliderTrack.vue';
export type { SliderDirection, SliderOrientation, SliderThumbAlignment, SliderValueText } from './context';
export type { SliderRangeProps } from './SliderRange.vue';
export type { SliderRootEmits, SliderRootProps } from './SliderRoot.vue';
export type { SliderThumbProps } from './SliderThumb.vue';
export type { SliderTrackProps } from './SliderTrack.vue';
+98
View File
@@ -0,0 +1,98 @@
/**
* Returns the number of decimal digits in a numeric `step`.
*
* Used by {@link roundToStep} to compensate floating-point drift without
* allocating strings on every invocation (the cost is paid once per `step`
* change and cached by the caller).
*/
export function getStepDecimals(step: number): number {
if (!Number.isFinite(step)) return 0;
const str = String(step);
const dot = str.indexOf('.');
if (dot === -1) return 0;
return str.length - dot - 1;
}
/**
* Snap `value` to the nearest multiple of `step` anchored at `min`.
*
* `decimals` must be pre-computed by the caller via {@link getStepDecimals}
* and cached per-`step` — this function is on the pointermove hot path.
*/
export function roundToStep(value: number, step: number, min: number, decimals: number): number {
const nearest = Math.round((value - min) / step) * step + min;
return decimals > 0 ? Number(nearest.toFixed(decimals)) : nearest;
}
/**
* Linear projection of `value` from the input domain onto the output range.
*
* Plain (non-curried) form — no per-call closure allocation.
*/
export function scaleLinear(value: number, d0: number, d1: number, r0: number, r1: number): number {
if (d0 === d1 || r0 === r1) return r0;
return r0 + ((r1 - r0) / (d1 - d0)) * (value - d0);
}
/**
* Verify that adjacent values in an already-sorted `values` array differ by
* at least `minStepsBetween * step`.
*
* The caller is expected to maintain the invariant that `values` is sorted
* ascending (the slider Root guarantees this by construction).
*/
export function hasMinStepsBetweenSortedValues(values: number[], minStepsBetween: number, step: number): boolean {
if (minStepsBetween <= 0 || values.length < 2) return true;
const minGap = minStepsBetween * step;
for (let i = 1; i < values.length; i++) {
if (values[i]! - values[i - 1]! < minGap) return false;
}
return true;
}
/**
* Index of the value in `values` closest to `nextValue`.
*
* Single-pass — no intermediate distance array.
*/
export function getClosestValueIndex(values: number[], nextValue: number): number {
if (values.length <= 1) return 0;
let bestIdx = 0;
let bestDist = Math.abs(values[0]! - nextValue);
for (let i = 1; i < values.length; i++) {
const d = Math.abs(values[i]! - nextValue);
if (d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
return bestIdx;
}
/**
* Offset (in pixels) that keeps a thumb of `size` px centred on its track
* position fully inside the track at the extremes. At 0 % the thumb is nudged
* inward by half its size, at 100 % outward by the same amount, scaling
* linearly in between. `direction` is `1` when the start edge grows from the
* low end and `-1` when inverted, so the sign matches the positioning edge.
*
* Returns `0` for the `'overflow'` alignment (caller short-circuits) — used only
* for `'contain'`.
*/
export function getThumbInBoundsOffset(size: number, percent: number, direction: number): number {
const halfSize = size / 2;
const offset = scaleLinear(percent, 0, 50, 0, halfSize);
return (halfSize - offset * direction) * direction;
}
/**
* Default accessible label for a thumb when the consumer does not supply an
* explicit `aria-label`. Single-thumb sliders return `undefined` (the value is
* self-describing via `aria-valuenow`); two thumbs become `Minimum` / `Maximum`;
* three or more become `Value N of M`.
*/
export function getDefaultThumbLabel(index: number, total: number): string | undefined {
if (total > 2) return `Value ${index + 1} of ${total}`;
if (total === 2) return index === 0 ? 'Minimum' : 'Maximum';
return undefined;
}
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Optional supporting text for a step. Its `id` is wired to the trigger's
* `aria-describedby` so it is announced as a description of the step.
*/
export interface StepperDescriptionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useStepperItemContext } from './context';
const { as = 'p' } = defineProps<StepperDescriptionProps>();
const item = useStepperItemContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="item.descriptionId"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { StepperState } from './context';
/**
* The visual marker for a step — typically a numbered circle or check. Defaults
* to rendering the step number, and exposes the current `step` and `state` via
* slot props so you can swap in icons (e.g. a check when completed).
*/
export interface StepperIndicatorProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useStepperItemContext } from './context';
const { as = 'div' } = defineProps<StepperIndicatorProps>();
defineSlots<{
default?: (props: { step: number; state: StepperState }) => unknown;
}>();
const item = useStepperItemContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-state="item.state.value"
>
<slot :step="item.step.value" :state="item.state.value">
{{ item.step.value }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,73 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { StepperState } from './context';
/**
* A single step within the stepper. Associates its child trigger, indicator,
* title, and description with a step number and derives that step's state
* (`active`, `completed`, or `inactive`) from the root's current value.
*/
export interface StepperItemProps extends PrimitiveProps {
/** 1-based index associating this item with a step. */
step: number;
/** Disable this specific step. */
disabled?: boolean;
/** Mark the step as completed regardless of current `modelValue`. */
completed?: boolean;
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { provideStepperItemContext, useStepperRootContext } from './context';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
const { as = 'div', step, disabled = false, completed = false } = defineProps<StepperItemProps>();
defineSlots<{
default?: (props: { state: StepperState; step: number }) => unknown;
}>();
const root = useStepperRootContext();
const { forwardRef } = useForwardExpose();
const state = computed(() => {
if (completed) return 'completed' as const;
if (root.value.value === step) return 'active' as const;
if (root.value.value > step) return 'completed' as const;
return 'inactive' as const;
});
const focusable = computed(() => {
if (disabled || root.disabled.value) return false;
if (!root.linear.value) return true;
return step <= root.value.value + 1;
});
const titleId = useId(undefined, 'stepper-item-title').value;
const descriptionId = useId(undefined, 'stepper-item-description').value;
provideStepperItemContext({
step: toRef(() => step),
state,
disabled: toRef(() => disabled || root.disabled.value),
focusable,
titleId,
descriptionId,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:aria-current="state === 'active' ? 'step' : undefined"
:data-state="state"
:data-orientation="root.orientation.value"
:data-disabled="disabled || root.disabled.value || !focusable ? '' : undefined"
>
<slot :state="state" :step="step" />
</Primitive>
</template>
@@ -0,0 +1,264 @@
<script lang="ts">
import type { StepperDirection, StepperOrientation } from './context';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A multi-step progress control that guides users through a sequence of steps —
* checkout flows, onboarding wizards, or any task split into ordered stages.
* Use it when you need to show where the user is, which steps are done, and
* (optionally) let them jump between steps.
*
* The root owns the active step (1-based), tracks the total via the Collection,
* arbitrates linear vs. free navigation, handles roving keyboard focus across
* triggers, and provides context to every `StepperItem`.
*/
export interface StepperRootProps extends PrimitiveProps {
/** Uncontrolled initial step. @default 1 */
defaultValue?: number;
/** Orientation. @default 'horizontal' */
orientation?: StepperOrientation;
/** Writing direction. Falls back to `ConfigProvider` when omitted. */
dir?: StepperDirection;
/** Require steps to be completed in order. @default true */
linear?: boolean;
/** Disable the entire stepper. */
disabled?: boolean;
/**
* Builds the message announced to screen readers via the visually-hidden
* live region whenever the active step changes. Override for i18n.
* @default ({ value, total }) => `Step ${value} of ${total}`
*/
announceLabel?: (state: { value: number; total: number }) => string;
}
export interface StepperRootEmits {
'update:modelValue': [value: number];
}
export interface StepperRootSlotProps {
/** Current active step (1-based). */
value: number;
/** Total number of registered steps. */
total: number;
/** `true` when the active step is the first step. */
isFirstStep: boolean;
/** `true` when the active step is the last step. */
isLastStep: boolean;
/** `true` when the next step's trigger is disabled (or absent). */
isNextDisabled: boolean;
/** `true` when the previous step's trigger is disabled (or absent). */
isPrevDisabled: boolean;
/** Navigate to an absolute step (1-based). */
goToStep: (step: number) => void;
/** Navigate to the next step. */
goToNextStep: () => void;
/** Navigate to the previous step. */
goToPrevStep: () => void;
/** `true` when there is a step after the active one. */
hasNext: () => boolean;
/** `true` when there is a step before the active one. */
hasPrev: () => boolean;
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { provideStepperRootContext } from './context';
import { useCollectionProvider } from '../../utilities/collection';
import { useConfig } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
const {
as = 'div',
defaultValue = 1,
orientation = 'horizontal',
linear = true,
disabled = false,
dir,
announceLabel,
} = defineProps<StepperRootProps>();
defineSlots<{
default?: (props: StepperRootSlotProps) => unknown;
}>();
const model = defineModel<number>();
// Uncontrolled mode: seed the active step from `defaultValue` (parent passed no `v-model`).
if (model.value === undefined)
model.value = defaultValue;
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
// Always a defined step for consumers — `model` is seeded above and the setter clamps to numbers.
const value = computed(() => model.value ?? defaultValue);
const { getItems, CollectionSlot } = useCollectionProvider();
const total = computed(() => getItems(true).length);
function commit(next: number): void {
if (next === model.value) return;
model.value = next;
}
function goToStep(step: number): void {
if (disabled || step < 1) return;
const items = getItems(true);
const count = items.length;
if (count > 0 && step > count) return;
// respect linear gate — at most one step ahead of current.
if (linear && step > value.value + 1) return;
// skip if target item is marked disabled in DOM.
const target = items[step - 1]?.ref;
if (target?.hasAttribute('data-disabled')) return;
commit(step);
}
function goToNextStep(): void {
goToStep(value.value + 1);
}
function goToPrevStep(): void {
goToStep(value.value - 1);
}
function hasNext(): boolean {
return value.value < total.value;
}
function hasPrev(): boolean {
return value.value > 1;
}
const isFirstStep = computed(() => value.value <= 1);
const isLastStep = computed(() => total.value > 0 && value.value >= total.value);
// Boundary or DOM-disabled state of the adjacent triggers — lets consumers wire
// Next/Prev buttons without re-deriving step math themselves.
const isNextDisabled = computed(() => {
if (disabled || !hasNext()) return true;
const next = getItems(true)[value.value]?.ref;
return next ? next.hasAttribute('data-disabled') : true;
});
const isPrevDisabled = computed(() => {
if (disabled || !hasPrev()) return true;
const prev = getItems(true)[value.value - 2]?.ref;
return prev ? prev.hasAttribute('data-disabled') : true;
});
function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
const action = rovingKeyToAction(event, {
orientation,
dir: direction.value,
loop: false,
});
if (!action) return;
event.preventDefault();
// Collect enabled triggers with a single pass (PACKED array via push — no filter closure).
const items = getItems(true);
const enabled: HTMLElement[] = [];
for (let i = 0; i < items.length; i++) {
const ref = items[i]!.ref;
if (!ref.hasAttribute('data-disabled')) enabled.push(ref);
}
if (enabled.length === 0) return;
if (action.absolute === 'home') {
enabled[0]!.focus();
return;
}
if (action.absolute === 'end') {
enabled[enabled.length - 1]!.focus();
return;
}
const current = enabled.indexOf(el);
const nextIdx = resolveNextIndex(current === -1 ? 0 : current, action.delta, enabled.length, false);
enabled[nextIdx]!.focus();
}
provideStepperRootContext({
value,
total,
orientation: toRef(() => orientation),
direction,
linear: toRef(() => linear),
disabled: toRef(() => disabled),
isFirstStep,
isLastStep,
isNextDisabled,
isPrevDisabled,
goToStep,
goToNextStep,
goToPrevStep,
hasNext,
hasPrev,
onTriggerKeyDown,
});
const announcement = computed(() => announceLabel
? announceLabel({ value: value.value, total: total.value })
: `Step ${value.value} of ${total.value}`);
defineExpose({
/** Current active step (1-based). */
value,
/** Total number of registered steps. */
total,
isFirstStep,
isLastStep,
isNextDisabled,
isPrevDisabled,
goToStep,
goToNextStep,
goToPrevStep,
hasNext,
hasPrev,
});
// `useForwardExpose` runs AFTER `defineExpose` so the composable merges the
// prior expose bindings (plus props + `$el`) instead of `defineExpose`'s
// `expose()` clobbering them and warning "expose() should be called only once".
const { forwardRef } = useForwardExpose();
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
role="group"
aria-label="progress"
:data-orientation="orientation"
:data-linear="linear ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:dir="direction"
>
<slot
:value="value"
:total="total"
:is-first-step="isFirstStep"
:is-last-step="isLastStep"
:is-next-disabled="isNextDisabled"
:is-prev-disabled="isPrevDisabled"
:go-to-step="goToStep"
:go-to-next-step="goToNextStep"
:go-to-prev-step="goToPrevStep"
:has-next="hasNext"
:has-prev="hasPrev"
/>
<VisuallyHidden
v-if="as !== 'template'"
role="status"
aria-live="polite"
aria-atomic="true"
>
{{ announcement }}
</VisuallyHidden>
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { StepperOrientation } from './context';
/**
* The decorative connector drawn between adjacent steps. It is `aria-hidden`
* and exposes the owning item's `state` and the stepper `orientation` as data
* attributes so the line can be styled to reflect progress.
*/
export interface StepperSeparatorProps extends PrimitiveProps {
/**
* Override the connector orientation. Defaults to the stepper's own
* orientation, so you usually do not need to set it.
*/
orientation?: StepperOrientation;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useStepperItemContext, useStepperRootContext } from './context';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', orientation } = defineProps<StepperSeparatorProps>();
const root = useStepperRootContext();
const item = useStepperItemContext();
const { forwardRef } = useForwardExpose();
const resolvedOrientation = computed(() => orientation ?? root.orientation.value);
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="separator"
aria-hidden="true"
:data-orientation="resolvedOrientation"
:data-state="item.state.value"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The accessible label for a step. Its `id` is wired to the trigger's
* `aria-labelledby`, so screen readers announce it when the trigger is focused.
*/
export interface StepperTitleProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useStepperItemContext } from './context';
const { as = 'h4' } = defineProps<StepperTitleProps>();
const item = useStepperItemContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="item.titleId"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The interactive control for a step. Clicking or pressing it navigates to the
* step (subject to the root's `linear` and `disabled` rules) and it participates
* in roving arrow-key focus across all enabled triggers.
*/
export interface StepperTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useStepperItemContext, useStepperRootContext } from './context';
import { Primitive } from '../../internal/primitive';
import { useCollectionInjector } from '../../utilities/collection';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<StepperTriggerProps>();
const root = useStepperRootContext();
const item = useStepperItemContext();
const { forwardRef, currentElement } = useForwardExpose();
const { CollectionItem } = useCollectionInjector();
function onMouseDown(event: MouseEvent): void {
if (!item.focusable.value || event.ctrlKey) {
event.preventDefault();
return;
}
root.goToStep(item.step.value);
}
function onKeyDown(event: KeyboardEvent): void {
if (item.disabled.value) return;
if ((event.key === 'Enter' || event.key === ' ') && !event.ctrlKey && !event.shiftKey) {
event.preventDefault();
root.goToStep(item.step.value);
return;
}
if (!currentElement.value) return;
root.onTriggerKeyDown(event, currentElement.value);
}
</script>
<template>
<CollectionItem>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:tabindex="item.focusable.value ? 0 : -1"
:disabled="item.disabled.value || !item.focusable.value || undefined"
:data-state="item.state.value"
:data-orientation="root.orientation.value"
:data-disabled="item.disabled.value || !item.focusable.value ? '' : undefined"
:aria-labelledby="item.titleId"
:aria-describedby="item.descriptionId"
@mousedown.left="onMouseDown"
@keydown="onKeyDown"
>
<slot :state="item.state.value" :step="item.step.value" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,409 @@
import {
StepperDescription,
StepperIndicator,
StepperItem,
StepperRoot,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '../index';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createStepper(rootProps: Record<string, unknown> = {}, stepCount = 3, itemProps: Record<number, Record<string, unknown>> = {}) {
return mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
rootProps,
{
default: () => Array.from({ length: stepCount }, (_, i) => {
const step = i + 1;
return h(
StepperItem,
{ key: step, step, ...itemProps[step] },
{
default: () => [
h(StepperTrigger, null, { default: () => [
h(StepperIndicator),
h(StepperTitle, null, { default: () => `Step ${step}` }),
h(StepperDescription, null, { default: () => `Description ${step}` }),
] }),
i < stepCount - 1 ? h(StepperSeparator) : null,
],
},
);
}),
},
);
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('Stepper', () => {
it('renders with role=group', () => {
const w = createStepper();
const root = w.find('[role="group"]');
expect(root.exists()).toBe(true);
expect(root.attributes('aria-label')).toBe('progress');
w.unmount();
});
it('first item is active by default (step=1)', () => {
const w = createStepper();
const items = w.findAllComponents(StepperItem);
expect(items[0]!.attributes('data-state')).toBe('active');
expect(items[0]!.attributes('aria-current')).toBe('step');
expect(items[1]!.attributes('data-state')).toBe('inactive');
w.unmount();
});
it('honors defaultValue', () => {
const w = createStepper({ defaultValue: 2 });
const items = w.findAllComponents(StepperItem);
expect(items[0]!.attributes('data-state')).toBe('completed');
expect(items[1]!.attributes('data-state')).toBe('active');
expect(items[2]!.attributes('data-state')).toBe('inactive');
w.unmount();
});
it('v-model moves the active step', async () => {
const value = ref(1);
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ modelValue: value.value, 'onUpdate:modelValue': (v: number) => (value.value = v) },
{
default: () => [1, 2, 3].map(step =>
h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger, null, { default: () => `S${step}` }) }),
),
},
);
},
}),
{ attachTo: document.body },
);
const triggers = w.findAll('button');
await triggers[1]!.trigger('mousedown');
await nextTick();
expect(value.value).toBe(2);
w.unmount();
});
it('linear mode blocks skipping ahead', async () => {
const w = createStepper();
const triggers = w.findAll('button');
await triggers[2]!.trigger('mousedown'); // try to skip to 3
await nextTick();
const items = w.findAllComponents(StepperItem);
expect(items[0]!.attributes('data-state')).toBe('active'); // unchanged
w.unmount();
});
it('non-linear mode allows arbitrary step', async () => {
const w = createStepper({ linear: false });
const triggers = w.findAll('button');
await triggers[2]!.trigger('mousedown');
await nextTick();
const items = w.findAllComponents(StepperItem);
expect(items[2]!.attributes('data-state')).toBe('active');
w.unmount();
});
it('disabled item is not focusable and cannot be activated', async () => {
const w = createStepper({ linear: false }, 3, { 2: { disabled: true } });
const items = w.findAllComponents(StepperItem);
expect(items[1]!.attributes('data-disabled')).toBe('');
const triggers = w.findAll('button');
expect(triggers[1]!.attributes('tabindex')).toBe('-1');
await triggers[1]!.trigger('mousedown');
await nextTick();
expect(items[0]!.attributes('data-state')).toBe('active'); // unchanged
w.unmount();
});
it('Enter/Space on trigger activates step', async () => {
const w = createStepper({ linear: false });
const triggers = w.findAll('button');
(triggers[1]!.element as HTMLElement).focus();
press(triggers[1]!.element, 'Enter');
await nextTick();
const items = w.findAllComponents(StepperItem);
expect(items[1]!.attributes('data-state')).toBe('active');
w.unmount();
});
it('ArrowRight / ArrowLeft move focus between triggers', () => {
const w = createStepper({ linear: false });
const triggers = w.findAll('button').map(t => t.element as HTMLElement);
triggers[0]!.focus();
press(triggers[0]!, 'ArrowRight');
expect(document.activeElement).toBe(triggers[1]);
press(triggers[1]!, 'ArrowRight');
expect(document.activeElement).toBe(triggers[2]);
press(triggers[2]!, 'ArrowLeft');
expect(document.activeElement).toBe(triggers[1]);
w.unmount();
});
it('Home / End jump to first / last trigger', () => {
const w = createStepper({ linear: false });
const triggers = w.findAll('button').map(t => t.element as HTMLElement);
triggers[1]!.focus();
press(triggers[1]!, 'End');
expect(document.activeElement).toBe(triggers[2]);
press(triggers[2]!, 'Home');
expect(document.activeElement).toBe(triggers[0]);
w.unmount();
});
it('completed prop forces completed state', () => {
const w = createStepper({}, 3, { 1: { completed: true } });
const items = w.findAllComponents(StepperItem);
expect(items[0]!.attributes('data-state')).toBe('completed');
w.unmount();
});
it('renders a visually-hidden status live region announcing the active step', async () => {
const w = createStepper({ defaultValue: 2 });
await nextTick();
const status = w.find('[role="status"]');
expect(status.exists()).toBe(true);
expect(status.attributes('aria-live')).toBe('polite');
expect(status.attributes('aria-atomic')).toBe('true');
expect(status.attributes('aria-hidden')).toBeUndefined();
expect(status.text()).toBe('Step 2 of 3');
w.unmount();
});
it('live region updates its message when the step changes', async () => {
const value = ref(1);
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ modelValue: value.value, 'onUpdate:modelValue': (v: number) => (value.value = v), linear: false },
{ default: () => [1, 2, 3].map(step => h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger, null, { default: () => `S${step}` }) })) },
);
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(w.find('[role="status"]').text()).toBe('Step 1 of 3');
const triggers = w.findAll('button');
await triggers[2]!.trigger('mousedown');
await nextTick();
expect(w.find('[role="status"]').text()).toBe('Step 3 of 3');
w.unmount();
});
it('announceLabel customizes the live-region message', async () => {
const w = createStepper({
defaultValue: 2,
announceLabel: ({ value, total }: { value: number; total: number }) => `${value}/${total} done`,
});
await nextTick();
expect(w.find('[role="status"]').text()).toBe('2/3 done');
w.unmount();
});
it('does not render the live region when as="template"', () => {
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ as: 'template' },
{ default: () => h('div', { class: 'root-shell' }, [1, 2].map(step => h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger) }))) },
);
},
}),
{ attachTo: document.body },
);
expect(w.find('[role="status"]').exists()).toBe(false);
expect(w.find('.root-shell').exists()).toBe(true);
w.unmount();
});
it('exposes imperative navigation API via template ref', async () => {
const value = ref(1);
const rootRef = ref<any>(null);
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ ref: rootRef, modelValue: value.value, 'onUpdate:modelValue': (v: number) => (value.value = v) },
{ default: () => [1, 2, 3].map(step => h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger) })) },
);
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(rootRef.value.value).toBe(1);
expect(rootRef.value.total).toBe(3);
expect(rootRef.value.isFirstStep).toBe(true);
expect(rootRef.value.isLastStep).toBe(false);
expect(rootRef.value.hasNext()).toBe(true);
expect(rootRef.value.hasPrev()).toBe(false);
rootRef.value.goToNextStep();
await nextTick();
expect(value.value).toBe(2);
expect(rootRef.value.isFirstStep).toBe(false);
rootRef.value.goToPrevStep();
await nextTick();
expect(value.value).toBe(1);
rootRef.value.goToStep(2);
await nextTick();
expect(value.value).toBe(2);
w.unmount();
});
it('prevStep at first step and nextStep at last step are no-ops', async () => {
const value = ref(1);
const rootRef = ref<any>(null);
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ ref: rootRef, modelValue: value.value, 'onUpdate:modelValue': (v: number) => (value.value = v), linear: false },
{ default: () => [1, 2, 3].map(step => h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger) })) },
);
},
}),
{ attachTo: document.body },
);
await nextTick();
rootRef.value.goToPrevStep();
await nextTick();
expect(value.value).toBe(1); // clamped at first
rootRef.value.goToStep(3);
await nextTick();
expect(value.value).toBe(3);
rootRef.value.goToNextStep();
await nextTick();
expect(value.value).toBe(3); // clamped at last
w.unmount();
});
it('disabled root blocks imperative navigation', async () => {
const value = ref(1);
const rootRef = ref<any>(null);
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ ref: rootRef, modelValue: value.value, 'onUpdate:modelValue': (v: number) => (value.value = v), disabled: true, linear: false },
{ default: () => [1, 2, 3].map(step => h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger) })) },
);
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(rootRef.value.isNextDisabled).toBe(true);
expect(rootRef.value.isPrevDisabled).toBe(true);
rootRef.value.goToNextStep();
await nextTick();
expect(value.value).toBe(1); // unchanged
w.unmount();
});
it('isNextDisabled reflects the next step being DOM-disabled', async () => {
const rootRef = ref<any>(null);
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ ref: rootRef, defaultValue: 1, linear: false },
{ default: () => [1, 2, 3].map(step => h(StepperItem, { key: step, step, disabled: step === 2 }, { default: () => h(StepperTrigger) })) },
);
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(rootRef.value.isNextDisabled).toBe(true); // step 2 is disabled
expect(rootRef.value.isPrevDisabled).toBe(true); // no prev at step 1
w.unmount();
});
it('exposes expanded slot props on the root default slot', async () => {
const captured: Record<string, unknown> = {};
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ defaultValue: 1 },
{
default: (slotProps: Record<string, unknown>) => {
Object.assign(captured, slotProps);
return [1, 2, 3].map(step => h(StepperItem, { key: step, step }, { default: () => h(StepperTrigger) }));
},
},
);
},
}),
{ attachTo: document.body },
);
await nextTick();
expect(captured.value).toBe(1);
expect(captured.total).toBe(3);
expect(captured.isFirstStep).toBe(true);
expect(captured.isLastStep).toBe(false);
expect(typeof captured.goToStep).toBe('function');
expect(typeof captured.goToNextStep).toBe('function');
expect(typeof captured.goToPrevStep).toBe('function');
expect(typeof captured.hasNext).toBe('function');
expect(typeof captured.hasPrev).toBe('function');
w.unmount();
});
it('StepperSeparator inherits orientation and can override it', () => {
const w = createStepper({ orientation: 'vertical' });
const sep = w.find('[role="separator"]');
expect(sep.exists()).toBe(true);
expect(sep.attributes('aria-hidden')).toBe('true');
expect(sep.attributes('data-orientation')).toBe('vertical');
w.unmount();
});
it('StepperSeparator orientation prop overrides the root orientation', () => {
const w = mount(
defineComponent({
setup() {
return () => h(
StepperRoot,
{ orientation: 'horizontal' },
{ default: () => h(StepperItem, { step: 1 }, { default: () => [h(StepperTrigger), h(StepperSeparator, { orientation: 'vertical' })] }) },
);
},
}),
{ attachTo: document.body },
);
const sep = w.find('[role="separator"]');
expect(sep.attributes('data-orientation')).toBe('vertical');
w.unmount();
});
});
@@ -0,0 +1,60 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type StepperOrientation = 'horizontal' | 'vertical';
export type StepperDirection = 'ltr' | 'rtl';
export type StepperState = 'completed' | 'active' | 'inactive';
export interface StepperRootContext {
/** Currently active step (1-based). */
value: Ref<number>;
/** Total registered items, tracked through the Collection. */
total: ComputedRef<number>;
/** Orientation of the stepper — drives arrow-key axis. */
orientation: Ref<StepperOrientation>;
/** Writing direction. */
direction: Ref<StepperDirection>;
/** When `true`, steps must be completed in order. */
linear: Ref<boolean>;
/** Whether the whole stepper is disabled. */
disabled: Ref<boolean>;
/** `true` when the active step is the first step. */
isFirstStep: ComputedRef<boolean>;
/** `true` when the active step is the last registered step. */
isLastStep: ComputedRef<boolean>;
/** `true` when the next step's trigger is disabled (or there is no next step). */
isNextDisabled: ComputedRef<boolean>;
/** `true` when the previous step's trigger is disabled (or there is no previous step). */
isPrevDisabled: ComputedRef<boolean>;
/** Navigate to an absolute step (1-based), respecting `linear`/`disabled`. */
goToStep: (step: number) => void;
/** Navigate to the step after the active one. */
goToNextStep: () => void;
/** Navigate to the step before the active one. */
goToPrevStep: () => void;
/** `true` when there is a step after the active one. */
hasNext: () => boolean;
/** `true` when there is a step before the active one. */
hasPrev: () => boolean;
onTriggerKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
}
export interface StepperItemContext {
step: Ref<number>;
state: Ref<StepperState>;
disabled: Ref<boolean>;
focusable: Ref<boolean>;
titleId: string;
descriptionId: string;
}
export const {
inject: useStepperRootContext,
provide: provideStepperRootContext,
} = useContextFactory<StepperRootContext>('stepper');
export const {
inject: useStepperItemContext,
provide: provideStepperItemContext,
} = useContextFactory<StepperItemContext>('stepper-item');
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
StepperDescription,
StepperIndicator,
StepperItem,
StepperRoot,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@robonen/primitives';
const steps = [
{ step: 1, title: 'Account', description: 'Your details' },
{ step: 2, title: 'Shipping', description: 'Delivery address' },
{ step: 3, title: 'Payment', description: 'Card or PayPal' },
{ step: 4, title: 'Review', description: 'Confirm order' },
];
const current = ref(1);
const isLast = computed(() => current.value === steps.length);
function next() {
if (current.value < steps.length) current.value++;
}
function prev() {
if (current.value > 1) current.value--;
}
</script>
<template>
<div class="w-full max-w-xl text-fg">
<StepperRoot
v-model="current"
class="flex items-start"
>
<StepperItem
v-for="(s, i) in steps"
:key="s.step"
:step="s.step"
class="group flex flex-1 flex-col items-center gap-2 last:flex-none"
>
<div class="flex w-full items-center">
<StepperTrigger
class="flex flex-col items-center gap-2 rounded-md p-1 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<StepperIndicator
v-slot="{ state, step }"
class="grid size-9 place-items-center rounded-full border text-sm font-semibold transition-colors data-[state=inactive]:border-border data-[state=inactive]:bg-bg data-[state=inactive]:text-fg-muted data-[state=active]:border-accent data-[state=active]:bg-accent data-[state=active]:text-accent-fg data-[state=completed]:border-emerald-500 data-[state=completed]:bg-emerald-500 data-[state=completed]:text-white dark:data-[state=completed]:border-emerald-400 dark:data-[state=completed]:bg-emerald-400 dark:data-[state=completed]:text-emerald-950"
>
<svg
v-if="state === 'completed'"
class="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<template v-else>
{{ step }}
</template>
</StepperIndicator>
</StepperTrigger>
<StepperSeparator
v-if="i < steps.length - 1"
class="mx-1 h-0.5 flex-1 rounded-full bg-border data-[state=completed]:bg-emerald-500 dark:data-[state=completed]:bg-emerald-400"
/>
</div>
<div class="flex flex-col items-center text-center">
<StepperTitle class="text-sm font-medium text-fg">
{{ s.title }}
</StepperTitle>
<StepperDescription class="text-xs text-fg-subtle">
{{ s.description }}
</StepperDescription>
</div>
</StepperItem>
</StepperRoot>
<div class="mt-6 rounded-lg border border-border bg-bg-subtle p-6 text-center">
<p class="text-sm text-fg-muted">
Step {{ current }} of {{ steps.length }}
<span class="font-medium text-fg">{{ steps[current - 1].title }}</span>
</p>
</div>
<div class="mt-4 flex items-center justify-between">
<button
type="button"
class="rounded-md border border-border bg-bg px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
:disabled="current === 1"
@click="prev"
>
Back
</button>
<button
type="button"
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isLast"
@click="next"
>
{{ isLast ? 'Done' : 'Continue' }}
</button>
</div>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
export { default as StepperRoot } from './StepperRoot.vue';
export { default as StepperItem } from './StepperItem.vue';
export { default as StepperTrigger } from './StepperTrigger.vue';
export { default as StepperIndicator } from './StepperIndicator.vue';
export { default as StepperTitle } from './StepperTitle.vue';
export { default as StepperDescription } from './StepperDescription.vue';
export { default as StepperSeparator } from './StepperSeparator.vue';
export {
provideStepperRootContext,
provideStepperItemContext,
useStepperRootContext,
useStepperItemContext,
type StepperRootContext,
type StepperItemContext,
type StepperState,
type StepperOrientation,
type StepperDirection,
} from './context';
export type { StepperRootProps, StepperRootEmits, StepperRootSlotProps } from './StepperRoot.vue';
export type { StepperItemProps } from './StepperItem.vue';
export type { StepperTriggerProps } from './StepperTrigger.vue';
export type { StepperIndicatorProps } from './StepperIndicator.vue';
export type { StepperTitleProps } from './StepperTitle.vue';
export type { StepperDescriptionProps } from './StepperDescription.vue';
export type { StepperSeparatorProps } from './StepperSeparator.vue';
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A control that toggles between an on and off state, mirroring a physical
* switch. Renders with `role="switch"`, exposes `data-state` and `data-disabled`
* for styling, and optionally mirrors its value into a hidden form input via
* `name`. The value is generic: it defaults to a boolean but can be any pair of
* `truthy`/`falsy` values (strings, numbers, objects compared by identity), and
* works uncontrolled (`defaultValue`) or controlled with `v-model`. Use it for
* instant settings toggles where the change applies immediately, as opposed to
* a checkbox that is typically submitted with a form.
*
* Pair it with `SwitchThumb` for the moving part: the thumb reads the switch
* context and mirrors `data-state`/`data-disabled`, enabling
* `data-[state=checked]` thumb animations.
*/
export interface SwitchProps<T = boolean> extends PrimitiveProps {
/** Value representing the "on" state. Defaults to `true`. */
truthy?: T;
/** Value representing the "off" state. Defaults to `false`. */
falsy?: T;
/** Initial uncontrolled value. Defaults to `falsy`. */
defaultValue?: T;
/** Prevents toggling and reflects a disabled state to assistive technology. */
disabled?: boolean;
/** Marks the control as required for form submission (sets `aria-required`). */
required?: boolean;
/** Name for the hidden form input. If provided, a hidden input mirrors state. */
name?: string;
/**
* Id of the root element. Anchors an associated `<label for>` and lets the
* switch derive an accessible name from that label when no explicit
* `aria-label` is supplied.
*/
id?: string;
/**
* Explicit string submitted with the form when the switch is on. When omitted
* the current `truthy`/`falsy` value is serialized automatically, so number,
* boolean and object pairs round-trip without extra wiring.
*/
value?: string;
}
export interface SwitchEmits<T = boolean> {
/** Emitted whenever the value changes (also drives `v-model`). */
'update:modelValue': [value: T];
}
</script>
<script setup lang="ts" generic="T = boolean">
import type { Ref } from 'vue';
import { Primitive } from '../../internal/primitive';
import { computed, ref, toRaw } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { VisuallyHiddenInputBubble } from '../../utilities/visually-hidden';
import { provideSwitchContext } from './context';
defineOptions({ inheritAttrs: false });
const {
truthy = true as unknown as T,
falsy = false as unknown as T,
defaultValue,
disabled = false,
required = false,
name,
id,
value: valueProp,
as = 'button',
} = defineProps<SwitchProps<T>>();
defineEmits<SwitchEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
const value = defineModel<T>({
get: v => (v ?? local.value) as T,
set: (v) => {
local.value = v as T;
return v;
},
});
const checked = computed<boolean>(() => Object.is(toRaw(value.value), toRaw(truthy)));
const disabledState = computed<boolean>(() => disabled);
// Derive an accessible name from an associated `<label for=id>` when no explicit
// `aria-label` is supplied. Guarded for SSR (no `document`).
const ariaLabel = computed<string | undefined>(() => {
if (!id || !currentElement.value || globalThis.document === undefined) return undefined;
const label = globalThis.document.querySelector(`[for="${id}"]`) as HTMLElement | null;
return label?.innerText || undefined;
});
// The form value: an explicit `value` prop wins, otherwise the active
// truthy/falsy value is serialized so number/boolean/object pairs round-trip.
const formValue = computed<string>(() => valueProp ?? serialize(checked.value ? truthy : falsy));
provideSwitchContext({
checked,
disabled: disabledState,
});
function toggle() {
if (disabled) return;
value.value = checked.value ? falsy : truthy;
}
function onClick() {
toggle();
}
function onKeydown(event: KeyboardEvent) {
// <button> handles Space/Enter natively; only synthesize for non-button hosts.
if (as === 'button') return;
if (event.key !== ' ' && event.key !== 'Enter') return;
event.preventDefault();
toggle();
}
function serialize(v: T): string {
if (v === null || v === undefined) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
try {
return JSON.stringify(v);
}
catch {
return String(v);
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
v-bind="$attrs"
:id="id"
:as="as"
:type="as === 'button' ? 'button' : undefined"
role="switch"
:tabindex="as === 'button' ? undefined : (disabled ? -1 : 0)"
:aria-label="($attrs['aria-label'] as string) || ariaLabel"
:aria-checked="checked"
:aria-required="required ? true : undefined"
:aria-disabled="as === 'button' ? undefined : (disabled ? true : undefined)"
:data-state="checked ? 'checked' : 'unchecked'"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="onClick"
@keydown="onKeydown"
>
<slot :checked="checked" :value="value" />
<VisuallyHiddenInputBubble
v-if="name"
:name="name"
:value="formValue"
:checked="checked"
:disabled="disabled"
:required="required"
/>
</Primitive>
</template>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The moving part of a switch. Renders alongside the root and mirrors the
* root's state through its own `data-state` (`checked`/`unchecked`) and
* `data-disabled` attributes, so the thumb can be animated with
* `data-[state=checked]` selectors — the most common switch UI pattern. It
* holds no state of its own; it reads the switch context provided by the root.
*/
export interface SwitchThumbProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useSwitchContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<SwitchThumbProps>();
const ctx = useSwitchContext();
defineOptions({ inheritAttrs: false });
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
v-bind="$attrs"
:data-state="ctx.checked.value ? 'checked' : 'unchecked'"
:data-disabled="ctx.disabled.value ? '' : undefined"
>
<slot :checked="ctx.checked.value" />
</Primitive>
</template>
@@ -0,0 +1,188 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { Switch } from '../index';
describe('switch (generic)', () => {
describe('boolean (default)', () => {
it('has role="switch" and toggles on click', async () => {
const wrapper = mount(Switch);
expect(wrapper.attributes('role')).toBe('switch');
expect(wrapper.attributes('aria-checked')).toBe('false');
await wrapper.trigger('click');
expect(wrapper.attributes('aria-checked')).toBe('true');
expect(wrapper.attributes('data-state')).toBe('checked');
});
it('respects defaultValue', () => {
const wrapper = mount(Switch, { props: { defaultValue: true } });
expect(wrapper.attributes('aria-checked')).toBe('true');
});
it('renders a hidden input when name is provided', () => {
const wrapper = mount(Switch, { props: { name: 'agree', defaultValue: true } });
const input = wrapper.find('input[type="checkbox"]');
expect(input.exists()).toBe(true);
expect(input.attributes('name')).toBe('agree');
expect(input.attributes('value')).toBe('true');
expect((input.element as HTMLInputElement).checked).toBe(true);
});
it('does not render input without name', () => {
const wrapper = mount(Switch);
expect(wrapper.find('input').exists()).toBe(false);
});
it('skips toggle when disabled', async () => {
const wrapper = mount(Switch, { props: { disabled: true } });
await wrapper.trigger('click');
expect(wrapper.attributes('aria-checked')).toBe('false');
});
it('sets aria-required when required', () => {
const wrapper = mount(Switch, { props: { required: true } });
expect(wrapper.attributes('aria-required')).toBe('true');
});
});
describe('string pair ("on" / "off")', () => {
it('derives checked via Object.is with truthy', async () => {
const wrapper = mount(Switch, { props: { truthy: 'on', falsy: 'off' } });
expect(wrapper.attributes('aria-checked')).toBe('false');
await wrapper.trigger('click');
expect(wrapper.attributes('aria-checked')).toBe('true');
});
it('hidden input serializes to current truthy/falsy', async () => {
const wrapper = mount(Switch, {
props: { truthy: 'on', falsy: 'off', name: 'mode', defaultValue: 'on' },
});
const input = wrapper.find('input[type="checkbox"]');
expect(input.attributes('value')).toBe('on');
await wrapper.trigger('click');
expect(wrapper.find('input[type="checkbox"]').attributes('value')).toBe('off');
});
it('v-model emits truthy/falsy, not booleans', async () => {
const parent = defineComponent({
components: { Switch },
setup() {
const val = ref<'on' | 'off'>('off');
return { val };
},
render() {
return h(Switch, {
truthy: 'on',
falsy: 'off',
modelValue: this.val,
'onUpdate:modelValue': (v: unknown) => { this.val = v as 'on' | 'off'; },
});
},
});
const wrapper = mount(parent);
await wrapper.find('[role="switch"]').trigger('click');
expect(wrapper.vm.val).toBe('on');
await wrapper.find('[role="switch"]').trigger('click');
expect(wrapper.vm.val).toBe('off');
});
});
describe('object pair (generic)', () => {
it('toggles between two distinct object identities', async () => {
const ON = { kind: 'on' as const };
const OFF = { kind: 'off' as const };
const parent = defineComponent({
components: { Switch },
setup() {
const val = ref<typeof ON | typeof OFF>(OFF);
return { val, ON, OFF };
},
render() {
return h(Switch, {
truthy: this.ON,
falsy: this.OFF,
modelValue: this.val,
'onUpdate:modelValue': (v: unknown) => { this.val = v as typeof ON | typeof OFF; },
});
},
});
const wrapper = mount(parent);
expect(wrapper.vm.val.kind).toBe('off');
await wrapper.find('[role="switch"]').trigger('click');
expect(wrapper.vm.val.kind).toBe('on');
await wrapper.find('[role="switch"]').trigger('click');
expect(wrapper.vm.val.kind).toBe('off');
});
});
describe('keyboard activation', () => {
it('toggles on Space (native button)', async () => {
const wrapper = mount(Switch);
await wrapper.trigger('keydown', { key: ' ' });
// <button> handles Space natively; jsdom dispatches click on Space-keyup, not keydown.
// We verify our keydown synth does NOT double-toggle on button.
expect(wrapper.attributes('aria-checked')).toBe('false');
await wrapper.trigger('click');
expect(wrapper.attributes('aria-checked')).toBe('true');
});
it('toggles on Space when as is not a button', async () => {
const wrapper = mount(Switch, { props: { as: 'div' } });
expect(wrapper.attributes('aria-checked')).toBe('false');
await wrapper.trigger('keydown', { key: ' ' });
expect(wrapper.attributes('aria-checked')).toBe('true');
await wrapper.trigger('keydown', { key: ' ' });
expect(wrapper.attributes('aria-checked')).toBe('false');
});
it('toggles on Enter when as is not a button', async () => {
const wrapper = mount(Switch, { props: { as: 'div' } });
await wrapper.trigger('keydown', { key: 'Enter' });
expect(wrapper.attributes('aria-checked')).toBe('true');
});
it('does not toggle on other keys', async () => {
const wrapper = mount(Switch, { props: { as: 'div' } });
await wrapper.trigger('keydown', { key: 'a' });
await wrapper.trigger('keydown', { key: 'Tab' });
expect(wrapper.attributes('aria-checked')).toBe('false');
});
});
describe('non-button host (as="div")', () => {
it('sets aria-disabled when disabled', () => {
const wrapper = mount(Switch, { props: { as: 'div', disabled: true } });
expect(wrapper.attributes('aria-disabled')).toBe('true');
});
it('does not set aria-disabled when not disabled', () => {
const wrapper = mount(Switch, { props: { as: 'div' } });
expect(wrapper.attributes('aria-disabled')).toBeUndefined();
});
it('does not toggle on keyboard when disabled', async () => {
const wrapper = mount(Switch, { props: { as: 'div', disabled: true } });
await wrapper.trigger('keydown', { key: ' ' });
await wrapper.trigger('keydown', { key: 'Enter' });
expect(wrapper.attributes('aria-checked')).toBe('false');
});
it('does not toggle on click when disabled', async () => {
const wrapper = mount(Switch, { props: { as: 'div', disabled: true } });
await wrapper.trigger('click');
expect(wrapper.attributes('aria-checked')).toBe('false');
});
it('has tabindex=0 when enabled, -1 when disabled', () => {
const enabled = mount(Switch, { props: { as: 'div' } });
expect(enabled.attributes('tabindex')).toBe('0');
const disabled = mount(Switch, { props: { as: 'div', disabled: true } });
expect(disabled.attributes('tabindex')).toBe('-1');
});
it('button host has no synthesized tabindex (native focusability)', () => {
const wrapper = mount(Switch);
expect(wrapper.attributes('tabindex')).toBeUndefined();
});
});
});
@@ -0,0 +1,221 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { Switch, SwitchThumb, useSwitchContext } from '../index';
describe('switchThumb — context-driven part', () => {
it('mirrors the root data-state and toggles with it', async () => {
const w = mount(Switch, {
attachTo: document.body,
slots: { default: () => h(SwitchThumb) },
});
const thumb = w.element.querySelector('span') as HTMLElement;
expect(thumb).toBeTruthy();
expect(thumb.getAttribute('data-state')).toBe('unchecked');
(w.element as HTMLElement).click();
await nextTick();
expect(thumb.getAttribute('data-state')).toBe('checked');
(w.element as HTMLElement).click();
await nextTick();
expect(thumb.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('reflects data-disabled from the root', () => {
const w = mount(Switch, {
attachTo: document.body,
props: { disabled: true },
slots: { default: () => h(SwitchThumb) },
});
const thumb = w.element.querySelector('span') as HTMLElement;
expect(thumb.getAttribute('data-disabled')).toBe('');
w.unmount();
});
it('has no data-disabled when enabled', () => {
const w = mount(Switch, {
attachTo: document.body,
slots: { default: () => h(SwitchThumb) },
});
const thumb = w.element.querySelector('span') as HTMLElement;
expect(thumb.getAttribute('data-disabled')).toBeNull();
w.unmount();
});
it('honours a polymorphic `as` host', () => {
const w = mount(Switch, {
attachTo: document.body,
slots: { default: () => h(SwitchThumb, { as: 'div' }) },
});
expect(w.element.querySelector('div')).toBeTruthy();
w.unmount();
});
it('exposes checked to its default slot', async () => {
let captured: unknown;
const w = mount(Switch, {
attachTo: document.body,
props: { defaultValue: true },
slots: {
default: () => h(SwitchThumb, null, {
default: (scope: { checked: boolean }) => {
captured = scope.checked;
return '';
},
}),
},
});
await nextTick();
expect(captured).toBe(true);
w.unmount();
});
it('throws when used without a switch ancestor', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() => mount(SwitchThumb, { attachTo: document.body })).toThrow();
spy.mockRestore();
});
});
describe('switchContext — public hook', () => {
it('provides checked + disabled to descendants', async () => {
let ctxChecked: boolean | undefined;
const Consumer = defineComponent({
setup() {
const ctx = useSwitchContext();
return () => {
ctxChecked = ctx.checked.value;
return h('i');
};
},
});
const w = mount(Switch, {
attachTo: document.body,
props: { defaultValue: true },
slots: { default: () => h(Consumer) },
});
await nextTick();
expect(ctxChecked).toBe(true);
w.unmount();
});
});
describe('switch — accessible name from <label for>', () => {
let label: HTMLLabelElement | undefined;
afterEach(() => {
label?.remove();
label = undefined;
});
it('derives aria-label from an associated label when id is set', async () => {
label = document.createElement('label');
label.setAttribute('for', 'sw-1');
label.textContent = 'Airplane mode';
document.body.appendChild(label);
const w = mount(Switch, { attachTo: document.body, props: { id: 'sw-1' } });
await nextTick();
expect(w.element.getAttribute('aria-label')).toBe('Airplane mode');
expect(w.element.getAttribute('id')).toBe('sw-1');
w.unmount();
});
it('an explicit aria-label wins over the derived one', async () => {
label = document.createElement('label');
label.setAttribute('for', 'sw-2');
label.textContent = 'Derived';
document.body.appendChild(label);
const w = mount(Switch, {
attachTo: document.body,
props: { id: 'sw-2' },
attrs: { 'aria-label': 'Explicit' },
});
await nextTick();
expect(w.element.getAttribute('aria-label')).toBe('Explicit');
w.unmount();
});
it('has no aria-label when neither id-label nor explicit label exist', () => {
const w = mount(Switch, { attachTo: document.body });
expect(w.element.getAttribute('aria-label')).toBeNull();
w.unmount();
});
});
describe('switch — hidden form input', () => {
it('renders a hidden checkbox input mirroring state', () => {
const w = mount(Switch, {
attachTo: document.body,
props: { name: 'agree', defaultValue: true },
});
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.checked).toBe(true);
expect(input.value).toBe('true');
w.unmount();
});
it('an explicit value prop overrides the serialized form value', () => {
const w = mount(Switch, {
attachTo: document.body,
props: { name: 'agree', value: 'yes-please', defaultValue: true },
});
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input.value).toBe('yes-please');
w.unmount();
});
it('serializes custom string truthy/falsy pairs', async () => {
const w = mount(Switch, {
attachTo: document.body,
props: { name: 'mode', truthy: 'on', falsy: 'off', defaultValue: 'on' },
});
expect((w.element.querySelector('input') as HTMLInputElement).value).toBe('on');
(w.element as HTMLElement).click();
await nextTick();
expect((w.element.querySelector('input') as HTMLInputElement).value).toBe('off');
w.unmount();
});
it('mirrors disabled and required onto the hidden input', () => {
const w = mount(Switch, {
attachTo: document.body,
props: { name: 'agree', disabled: true, required: true },
});
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input.disabled).toBe(true);
expect(input.required).toBe(true);
w.unmount();
});
it('dispatches native change events on the hidden input when toggled programmatically', async () => {
const model = ref(false);
const Harness = defineComponent({
setup: () => () => h(Switch, {
name: 'agree',
modelValue: model.value,
'onUpdate:modelValue': (v: unknown) => { model.value = v as boolean; },
}),
});
const w = mount(Harness, { attachTo: document.body });
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
const onChange = vi.fn();
input.addEventListener('change', onChange);
model.value = true;
await nextTick();
await nextTick();
expect(onChange).toHaveBeenCalled();
w.unmount();
});
it('does not render a hidden input without a name', () => {
const w = mount(Switch, { attachTo: document.body });
expect(w.element.querySelector('input[type="checkbox"]')).toBeNull();
w.unmount();
});
});
@@ -0,0 +1,19 @@
import type { ComputedRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
/**
* Context published by the switch root for descendant parts (e.g. the thumb).
* Both fields are derived state, so parts can mirror them into `data-state` /
* `data-disabled` without reaching back into the DOM.
*/
export interface SwitchContext {
/** Whether the switch is currently in its "on" state. */
checked: ComputedRef<boolean>;
/** Whether interaction is disabled. */
disabled: ComputedRef<boolean>;
}
const ctx = useContextFactory<SwitchContext>('switch');
export const provideSwitchContext = ctx.provide;
export const useSwitchContext = ctx.inject;
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Switch } from '@robonen/primitives';
import { ref } from 'vue';
const wifi = ref(true);
const notifications = ref(false);
// Generic value pair: this switch toggles between two strings, not booleans.
const theme = ref<'dark' | 'light'>('light');
</script>
<template>
<div class="demo-card flex w-full max-w-sm flex-col gap-1 p-5 text-fg">
<h3 class="mb-2 text-sm font-semibold text-fg">
Settings
</h3>
<label
for="demo-wifi"
class="flex cursor-pointer items-center justify-between gap-4 rounded-lg px-1 py-2.5 select-none"
>
<span class="flex flex-col">
<span class="text-sm font-medium">Wi-Fi</span>
<span class="text-xs text-fg-subtle">Connect to available networks</span>
</span>
<Switch
id="demo-wifi"
v-model="wifi"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-0 bg-bg-inset p-0.5 outline-none transition-colors data-[state=checked]:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<span
class="pointer-events-none block size-5 rounded-full bg-white shadow-sm transition-transform duration-200 ease-out"
:class="wifi ? 'translate-x-5' : 'translate-x-0'"
/>
</Switch>
</label>
<label
for="demo-notify"
class="flex cursor-pointer items-center justify-between gap-4 rounded-lg px-1 py-2.5 select-none"
>
<span class="flex flex-col">
<span class="text-sm font-medium">Notifications</span>
<span class="text-xs text-fg-subtle">Push alerts to this device</span>
</span>
<Switch
id="demo-notify"
v-model="notifications"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-0 bg-bg-inset p-0.5 outline-none transition-colors data-[state=checked]:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<span
class="pointer-events-none block size-5 rounded-full bg-white shadow-sm transition-transform duration-200 ease-out"
:class="notifications ? 'translate-x-5' : 'translate-x-0'"
/>
</Switch>
</label>
<div class="flex items-center justify-between gap-4 border-t border-border px-1 pt-3 mt-1">
<span class="flex flex-col">
<span class="text-sm font-medium">Appearance</span>
<span class="text-xs text-fg-subtle">Toggles between two string values</span>
</span>
<Switch
v-model="theme"
truthy="dark"
falsy="light"
aria-label="Theme"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-0 bg-bg-inset p-0.5 outline-none transition-colors data-[state=checked]:bg-fg focus-visible:ring-2 focus-visible:ring-ring"
>
<template #default="{ value }">
<span
class="pointer-events-none flex size-5 items-center justify-center rounded-full bg-white text-[10px] shadow-sm transition-transform duration-200 ease-out"
:class="value === 'dark' ? 'translate-x-5' : 'translate-x-0'"
>
{{ value === 'dark' ? '🌙' : '☀️' }}
</span>
</template>
</Switch>
</div>
<p class="mt-3 rounded-lg bg-bg-subtle px-3 py-2 text-xs text-fg-muted">
Wi-Fi is
<span :class="wifi ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'">{{ wifi ? 'on' : 'off' }}</span>,
notifications are
<span :class="notifications ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'">{{ notifications ? 'on' : 'off' }}</span>,
theme is <span class="text-fg">{{ theme }}</span>.
</p>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
export { default as Switch } from './Switch.vue';
export type { SwitchEmits, SwitchProps } from './Switch.vue';
export { default as SwitchThumb } from './SwitchThumb.vue';
export type { SwitchThumbProps } from './SwitchThumb.vue';
export { provideSwitchContext, useSwitchContext } from './context';
export type { SwitchContext } from './context';
@@ -0,0 +1,42 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that removes every tag at once. No-op when the list is already
* empty or the component is disabled.
*/
export interface TagsInputClearProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useTagsInputContext } from './context';
const { as = 'button' } = defineProps<TagsInputClearProps>();
const ctx = useTagsInputContext();
const { forwardRef } = useForwardExpose();
function onClick(): void {
if (ctx.disabled.value) return;
if (ctx.modelValue.value.length === 0) return;
// Drop everything; reuse root's commit path by replaying removals in reverse.
while (ctx.modelValue.value.length > 0) {
ctx.onRemoveValue(ctx.modelValue.value.length - 1);
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
@click="onClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,153 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The text field where new tags are typed. Commits the current value on Enter,
* on the configured delimiter, and (when enabled) on paste, Tab, or blur, and
* forwards Backspace/arrow keys to the root for navigating the tag strip.
*/
export interface TagsInputInputProps extends PrimitiveProps {
/** Placeholder text. */
placeholder?: string;
/** Focus on mount. */
autoFocus?: boolean;
/** Max input length. */
maxLength?: number;
}
</script>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useTagsInputContext } from './context';
const {
as = 'input',
placeholder,
autoFocus = false,
maxLength,
} = defineProps<TagsInputInputProps>();
const ctx = useTagsInputContext();
const { forwardRef, currentElement } = useForwardExpose();
const isComposing = ref(false);
function onCompositionStart(): void {
isComposing.value = true;
}
function onCompositionEnd(): void {
nextTick(() => {
isComposing.value = false;
});
}
function commitCurrent(target: HTMLInputElement): void {
if (!target.value) return;
const ok = ctx.onAddValue(target.value);
if (ok) target.value = '';
}
function onEnter(event: KeyboardEvent): void {
if (isComposing.value) return;
if (event.defaultPrevented) return;
const target = event.target as HTMLInputElement;
if (!target.value) return;
// Must run synchronously: after an await the dispatch is over and Enter's
// implicit form submission has already happened.
event.preventDefault();
commitCurrent(target);
}
function onTab(event: KeyboardEvent): void {
if (!ctx.addOnTab.value) return;
const target = event.target as HTMLInputElement;
if (!target.value) return;
event.preventDefault();
commitCurrent(target);
}
function onBlur(event: FocusEvent): void {
ctx.selectedElement.value = undefined;
if (!ctx.addOnBlur.value) return;
const target = event.target as HTMLInputElement;
// When composed with a popup (combobox/listbox), the input owns the popup via
// `aria-controls`. A blur whose focus lands inside that popup must NOT commit
// the typed text — the clicked option should be added instead.
const controlledId = target.getAttribute('aria-controls');
if (controlledId) {
const relatedTarget = event.relatedTarget as Element | null;
if (relatedTarget?.closest(`#${CSS.escape(controlledId)}`)) return;
}
if (!target.value) return;
commitCurrent(target);
}
function onInput(event: Event): void {
ctx.isInvalidInput.value = false;
const ev = event as InputEvent;
if (ev.data === null || ev.data === undefined) return;
const delim = ctx.delimiter.value;
const matches = typeof delim === 'string' ? ev.data === delim : delim.test(ev.data);
if (!matches) return;
const target = event.target as HTMLInputElement;
target.value = target.value.replace(delim, '');
if (target.value.trim() === '') {
target.value = '';
return;
}
commitCurrent(target);
}
function onPaste(event: ClipboardEvent): void {
if (!ctx.addOnPaste.value) return;
const data = event.clipboardData?.getData('text');
if (!data) return;
event.preventDefault();
const delim = ctx.delimiter.value;
const parts = delim ? data.split(delim) : [data];
for (const part of parts) {
const trimmed = part.trim();
if (trimmed) ctx.onAddValue(trimmed);
}
}
onMounted(() => {
if (!autoFocus) return;
const el = currentElement.value;
const input = el?.nodeName === 'INPUT' ? el : el?.querySelector('input');
// Defer to let the surrounding DOM settle before stealing focus.
setTimeout(() => {
(input as HTMLInputElement | null)?.focus();
}, 0);
});
</script>
<template>
<Primitive
:id="ctx.id.value"
:ref="forwardRef"
:as="as"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
:placeholder="placeholder"
:maxlength="maxLength"
:disabled="ctx.disabled.value || undefined"
:data-invalid="ctx.isInvalidInput.value ? '' : undefined"
@input="onInput"
@keydown.enter="onEnter"
@keydown.tab="onTab"
@keydown="ctx.onInputKeyDown"
@blur="onBlur"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@paste="onPaste"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,59 @@
<script lang="ts" generic="T extends TagValue = string">
import type { PrimitiveProps } from '../../internal/primitive';
import type { TagValue } from './context';
/**
* A single tag in the strip. Provides item context for its `ItemText` and
* `ItemDelete` children and reflects selected/disabled state via data attributes.
*/
export interface TagsInputItemProps<U extends TagValue = string> extends PrimitiveProps {
/** The value associated with this tag. */
value: U;
/** Disable this specific tag. */
disabled?: boolean;
}
</script>
<script setup lang="ts" generic="T extends TagValue = string">
import type { Ref } from 'vue';
import { computed, ref, toRef } from 'vue';
import { provideTagsInputItemContext, useTagsInputContext } from './context';
import { Primitive } from '../../internal/primitive';
import { useCollectionInjector } from '../../utilities/collection';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div', value, disabled = false } = defineProps<TagsInputItemProps<T>>();
const ctx = useTagsInputContext();
const { forwardRef, currentElement } = useForwardExpose();
const { CollectionItem } = useCollectionInjector();
const isSelected = computed(() => ctx.selectedElement.value === currentElement.value);
const isDisabled = computed(() => disabled || ctx.disabled.value);
const display = computed(() => ctx.displayValue(value));
const textId = ref<string>('');
provideTagsInputItemContext({
value: toRef(() => value) as Ref<T>,
displayValue: display,
isSelected,
disabled: isDisabled,
textId,
});
</script>
<template>
<CollectionItem :value="value">
<Primitive
:ref="forwardRef"
:as="as"
:aria-labelledby="textId || undefined"
:aria-current="isSelected ? 'true' : undefined"
:data-state="isSelected ? 'active' : 'inactive'"
:data-disabled="isDisabled ? '' : undefined"
>
<slot :value="value" :display-value="display" :is-selected="isSelected" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that removes its parent tag from the list when clicked. Render it
* inside `TagsInputItem`; it is labelled by the sibling `ItemText`.
*/
export interface TagsInputItemDeleteProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useTagsInputContext, useTagsInputItemContext } from './context';
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { isEqual } from '@robonen/stdlib';
const { as = 'button' } = defineProps<TagsInputItemDeleteProps>();
const ctx = useTagsInputContext();
const item = useTagsInputItemContext();
const { forwardRef } = useForwardExpose();
function onClick(): void {
if (item.disabled.value) return;
// Deep equality so object tags (whose stored reference may differ from the
// prop's reference) still resolve to the correct index.
const idx = ctx.modelValue.value.findIndex(v => isEqual(v, item.value.value));
if (idx !== -1) ctx.onRemoveValue(idx);
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
:aria-labelledby="item.textId.value || undefined"
:aria-current="item.isSelected.value"
:data-state="item.isSelected.value ? 'active' : 'inactive'"
:data-disabled="item.disabled.value ? '' : undefined"
:disabled="item.disabled.value || undefined"
@click="onClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The visible label for a tag. Renders the item's display value and exposes an
* id that the surrounding `Item` and `ItemDelete` use for `aria-labelledby`.
*/
export interface TagsInputItemTextProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useForwardExpose } from '@robonen/vue';
import { useId } from '../../utilities/config-provider';
import { useTagsInputItemContext } from './context';
const { as = 'span' } = defineProps<TagsInputItemTextProps>();
const item = useTagsInputItemContext();
const { forwardRef } = useForwardExpose();
// Lazily assign an id the first time the text part is mounted — kept on the
// shared context so the sibling `<TagsInputItem>` / `<TagsInputItemDelete>` can
// point `aria-labelledby` at it without us generating an id per tag eagerly.
if (!item.textId.value) item.textId.value = useId(undefined, 'tags-input-item-text').value;
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="item.textId.value"
>
<slot>{{ item.displayValue.value }}</slot>
</Primitive>
</template>
@@ -0,0 +1,300 @@
<script lang="ts" generic="T extends TagValue = string">
import type { PrimitiveProps } from '../../internal/primitive';
import type { TagValue } from './context';
/**
* A headless tags / token input: type a value, commit it on Enter (or paste,
* Tab, blur, or a custom delimiter), and manage the resulting list of tags with
* full keyboard navigation, duplicate/max guards, and accessible labelling.
* Use it for free-form multi-value entry such as email recipients, keywords,
* skills, or filter chips. Wraps the `Item`, `ItemText`, `ItemDelete`, `Input`,
* and `Clear` parts and provides their shared context.
*/
export interface TagsInputRootProps<U extends TagValue = string> extends PrimitiveProps {
/** Uncontrolled initial value. @default [] */
defaultValue?: U[];
/** Add on paste (respects `delimiter`). */
addOnPaste?: boolean;
/** Add on Tab. */
addOnTab?: boolean;
/** Add on blur. */
addOnBlur?: boolean;
/** Allow duplicate tags. */
duplicate?: boolean;
/** Disable the whole component. */
disabled?: boolean;
/** Character or regex that splits/commits input. @default ',' */
delimiter?: string | RegExp;
/** Writing direction. Falls back to `ConfigProvider` when omitted. */
dir?: 'ltr' | 'rtl';
/** Maximum number of tags. `0` disables the cap. @default 0 */
max?: number;
/** Map a raw input string to a tag. Required for non-string tag values. */
convertValue?: (raw: string) => U;
/** Render a tag value as text. @default `String(v)` */
displayValue?: (value: U) => string;
/**
* Native input name. When set, a visually-hidden mirror input bubbles the tag
* list into the owning `<form>` for native submission and validation.
*/
name?: string;
/** Mark the field as required for native form validation. */
required?: boolean;
/** Id forwarded onto the inner `<TagsInputInput>` for `label[for]`/aria wiring. */
id?: string;
}
export interface TagsInputRootEmits<U extends TagValue = string> {
addTag: [value: U];
removeTag: [value: U];
invalid: [value: U];
}
</script>
<script setup lang="ts" generic="T extends TagValue = string">
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { Primitive } from '../../internal/primitive';
import type { Ref } from 'vue';
import { provideTagsInputContext } from './context';
import { useCollectionProvider } from '../../utilities/collection';
import { useConfig } from '../../utilities/config-provider';
import { useFocusWithin, useForwardExpose } from '@robonen/vue';
import { isEqual } from '@robonen/stdlib';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
const {
as = 'div',
defaultValue,
addOnPaste = false,
addOnTab = false,
addOnBlur = false,
duplicate = false,
disabled = false,
delimiter = ',',
dir,
max = 0,
convertValue,
displayValue,
name,
required = false,
id,
} = defineProps<TagsInputRootProps<T>>();
const emit = defineEmits<TagsInputRootEmits<T>>();
// Widened controlled contract: a `null`/`undefined` controlled value resolves
// to `[]` instead of risking a throw on `.length`/iteration downstream.
const model = defineModel<T[] | null | undefined>();
function normalize(v: T[] | null | undefined): T[] {
return Array.isArray(v) ? v : [];
}
const { forwardRef, currentElement } = useForwardExpose();
const config = useConfig();
const direction = computed(() => dir ?? config.dir.value);
const { focused } = useFocusWithin(currentElement);
// shallowRef: array is always replaced via commit(), never mutated in place.
const localValue = shallowRef<T[]>(normalize(model.value ?? defaultValue).slice()) as Ref<T[]>;
watch(model, (v) => {
if (v === undefined) return;
const next = normalize(v);
const cur = localValue.value;
if (next.length === cur.length) {
let equal = true;
for (let i = 0; i < next.length; i++) {
if (next[i] !== cur[i]) {
equal = false;
break;
}
}
if (equal) return;
}
localValue.value = next.slice();
});
const selectedElement = shallowRef<HTMLElement>();
const isInvalidInput = ref(false);
const { getItems, CollectionSlot } = useCollectionProvider();
function commit(next: T[]): void {
localValue.value = next;
model.value = next;
}
const convert: (raw: string) => T = convertValue ?? ((raw: string) => raw as unknown as T);
const display: (value: T) => string = displayValue ?? ((value: T) => String(value));
// An object tag can only be produced through `convertValue`; without it the
// raw string is cast verbatim, which silently breaks equality and submission.
function assertConvertibleValues(cur: T[]): void {
if (convertValue) return;
const sample = cur.length > 0 ? cur[0] : (defaultValue && defaultValue.length > 0 ? defaultValue[0] : undefined);
if (sample !== undefined && typeof sample === 'object')
throw new Error('TagsInput: provide a `convertValue` function when using object tag values.');
}
function onAddValue(raw: string): boolean {
if (disabled) return false;
const cur = localValue.value;
assertConvertibleValues(cur);
if (max > 0 && cur.length >= max) {
const payload = convert(raw);
isInvalidInput.value = true;
emit('invalid', payload);
return false;
}
const payload = convert(raw);
if (!duplicate) {
// Deep structural equality catches object tags whose reference differs but
// whose shape matches; the display-aware check additionally rejects two
// distinct values that render to the same label.
const exists = cur.some(v => isEqual(v, payload) || display(v) === display(payload));
if (exists) {
isInvalidInput.value = true;
emit('invalid', payload);
return false;
}
}
commit([...cur, payload]);
emit('addTag', payload);
return true;
}
function onRemoveValue(index: number): void {
if (disabled || index < 0) return;
const cur = localValue.value;
if (index >= cur.length) return;
const removed = cur[index]!;
const next = cur.slice();
next.splice(index, 1);
commit(next);
emit('removeTag', removed);
}
function collectTagEls(): HTMLElement[] {
return getItems(false).map(i => i.ref);
}
function onInputKeyDown(event: KeyboardEvent): void {
const target = event.target as HTMLInputElement;
const tags = collectTagEls();
if (tags.length === 0) return;
const lastTag = tags[tags.length - 1]!;
const atCaretStart = target.selectionStart === 0 && target.selectionEnd === 0;
switch (event.key) {
case 'Delete':
case 'Backspace': {
if (!atCaretStart) return;
if (selectedElement.value) {
const idx = tags.indexOf(selectedElement.value);
if (idx === -1) return;
onRemoveValue(idx);
// After removal, focus the neighbouring tag or clear.
const after = collectTagEls();
const nextEl = event.key === 'Backspace'
? (after[idx - 1] ?? after[after.length - 1])
: (after[idx] ?? after[after.length - 1]);
selectedElement.value = nextEl;
event.preventDefault();
}
else if (event.key === 'Backspace') {
selectedElement.value = lastTag;
event.preventDefault();
}
return;
}
case 'ArrowLeft':
case 'ArrowRight': {
if (!atCaretStart) return;
const ltr = direction.value !== 'rtl';
const isBack = (event.key === 'ArrowLeft' && ltr) || (event.key === 'ArrowRight' && !ltr);
const isForward = !isBack;
if (isBack && !selectedElement.value) {
selectedElement.value = lastTag;
event.preventDefault();
return;
}
if (isForward && selectedElement.value === lastTag) {
selectedElement.value = undefined;
target.focus();
event.preventDefault();
return;
}
if (selectedElement.value) {
const idx = tags.indexOf(selectedElement.value);
if (idx === -1) return;
const delta = isBack ? -1 : 1;
const nextIdx = Math.max(0, Math.min(tags.length - 1, idx + delta));
selectedElement.value = tags[nextIdx]!;
event.preventDefault();
}
return;
}
case 'Home':
case 'End': {
if (!atCaretStart) return;
if (selectedElement.value) {
selectedElement.value = event.key === 'Home' ? tags[0]! : lastTag;
event.preventDefault();
}
return;
}
case 'ArrowUp':
case 'ArrowDown': {
if (selectedElement.value) event.preventDefault();
return;
}
default: {
selectedElement.value = undefined;
}
}
}
provideTagsInputContext({
modelValue: localValue,
selectedElement,
isInvalidInput,
addOnPaste: toRef(() => addOnPaste),
addOnTab: toRef(() => addOnTab),
addOnBlur: toRef(() => addOnBlur),
disabled: toRef(() => disabled),
delimiter: toRef(() => delimiter),
direction,
max: toRef(() => max),
duplicate: toRef(() => duplicate),
id: toRef(() => id),
convertValue: convert,
displayValue: display,
onAddValue,
onRemoveValue,
onInputKeyDown,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:data-disabled="disabled ? '' : undefined"
:data-invalid="isInvalidInput ? '' : undefined"
:data-focused="focused ? '' : undefined"
>
<slot :model-value="localValue" />
<VisuallyHiddenInput
v-if="name"
:name="name"
:value="localValue"
:required="required"
:disabled="disabled"
/>
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,441 @@
import {
TagsInputClear,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
TagsInputRoot,
} from '../index';
import type { TagValue } from '../index';
import type { Component } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
function createTagsInput(rootProps: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
const tags = ref<string[]>(
(rootProps.modelValue as string[] | undefined) ?? (rootProps.defaultValue as string[] | undefined) ?? [],
);
return () => h(
TagsInputRoot,
{
modelValue: tags.value,
'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as string[]),
...rootProps,
},
{
default: ({ modelValue }: { modelValue: string[] }) => [
...modelValue.map(tag =>
h(TagsInputItem, { key: tag, value: tag }, {
default: () => [
h(TagsInputItemText, null, { default: () => tag }),
h(TagsInputItemDelete, null, { default: () => '×' }),
],
}),
),
h(TagsInputInput, { placeholder: 'add tag' }),
h(TagsInputClear, null, { default: () => 'Clear' }),
],
},
);
},
}),
{ attachTo: document.body },
);
}
interface ObjTag { id: string; label: string }
function createObjectTagsInput(rootProps: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
const tags = ref<ObjTag[]>((rootProps.defaultValue as ObjTag[] | undefined) ?? []);
return () => h(
TagsInputRoot,
{
modelValue: tags.value,
'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as ObjTag[]),
...rootProps,
},
{
default: ({ modelValue }: { modelValue: ObjTag[] }) => [
...modelValue.map(tag =>
h(TagsInputItem, { key: tag.id, value: tag }, {
default: () => [
h(TagsInputItemText, null, { default: () => tag.label }),
h(TagsInputItemDelete, null, { default: () => '×' }),
],
}),
),
h(TagsInputInput),
],
},
);
},
}),
{ attachTo: document.body },
);
}
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('TagsInput', () => {
it('renders initial tags from defaultValue', () => {
const w = createTagsInput({ defaultValue: ['a', 'b'] });
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(2);
w.unmount();
});
it('Enter adds a tag from input', async () => {
const w = createTagsInput();
const input = w.find('input');
(input.element as HTMLInputElement).value = 'hello';
await input.trigger('keydown', { key: 'Enter' });
await nextTick();
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
expect(w.findComponent(TagsInputItem as Component).text()).toContain('hello');
expect((input.element as HTMLInputElement).value).toBe('');
w.unmount();
});
it('delimiter commits a tag on input', async () => {
const w = createTagsInput({ delimiter: ',' });
const input = w.find('input');
(input.element as HTMLInputElement).value = 'x,';
await input.trigger('input', { data: ',' });
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
expect(w.findComponent(TagsInputItem as Component).text()).toContain('x');
w.unmount();
});
it('rejects duplicates by default and emits invalid', async () => {
const w = createTagsInput({ defaultValue: ['foo'] });
const input = w.find('input');
(input.element as HTMLInputElement).value = 'foo';
await input.trigger('keydown', { key: 'Enter' });
await nextTick();
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
expect(w.findComponent(TagsInputRoot as Component).emitted('invalid')).toBeTruthy();
w.unmount();
});
it('duplicate: true allows duplicates', async () => {
const w = createTagsInput({ defaultValue: ['foo'], duplicate: true });
const input = w.find('input');
(input.element as HTMLInputElement).value = 'foo';
await input.trigger('keydown', { key: 'Enter' });
await nextTick();
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(2);
w.unmount();
});
it('max caps tag count and emits invalid', async () => {
const w = createTagsInput({ defaultValue: ['a', 'b'], max: 2 });
const input = w.find('input');
(input.element as HTMLInputElement).value = 'c';
await input.trigger('keydown', { key: 'Enter' });
await nextTick();
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(2);
expect(w.findComponent(TagsInputRoot as Component).emitted('invalid')).toBeTruthy();
w.unmount();
});
it('delete trigger removes its tag', async () => {
const w = createTagsInput({ defaultValue: ['a', 'b'] });
const deleteBtn = w.findAll('button').find(b => b.text() === '×');
await deleteBtn!.trigger('click');
await nextTick();
const items = w.findAllComponents(TagsInputItem as Component);
expect(items).toHaveLength(1);
expect(items[0]!.text()).toContain('b');
w.unmount();
});
it('Clear removes all tags', async () => {
const w = createTagsInput({ defaultValue: ['a', 'b', 'c'] });
const clearBtn = w.findAll('button').find(b => b.text() === 'Clear')!;
await clearBtn.trigger('click');
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(0);
w.unmount();
});
it('Backspace on empty input selects last tag; second Backspace deletes it', async () => {
const w = createTagsInput({ defaultValue: ['a', 'b'] });
const input = w.find('input').element as HTMLInputElement;
input.focus();
press(input, 'Backspace');
await nextTick();
press(input, 'Backspace');
await nextTick();
const items = w.findAllComponents(TagsInputItem as Component);
expect(items).toHaveLength(1);
expect(items[0]!.text()).toContain('a');
w.unmount();
});
it('addOnBlur commits pending value on blur', async () => {
const w = createTagsInput({ addOnBlur: true });
const input = w.find('input');
(input.element as HTMLInputElement).value = 'done';
await input.trigger('blur');
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
w.unmount();
});
it('disabled blocks adding tags', async () => {
const w = createTagsInput({ disabled: true });
const input = w.find('input');
expect((input.element as HTMLInputElement).disabled).toBe(true);
w.unmount();
});
it('tag item is labelled by its ItemText id', async () => {
const w = createTagsInput({ defaultValue: ['a'] });
// ItemText assigns the shared textId during its own setup, one tick after
// the item's first render.
await nextTick();
const item = w.findComponent(TagsInputItem as Component).element as HTMLElement;
const text = w.findComponent(TagsInputItemText as Component).element as HTMLElement;
expect(text.id).toBeTruthy();
expect(item.getAttribute('aria-labelledby')).toBe(text.id);
w.unmount();
});
it('Enter with pending text prevents default synchronously (blocks implicit form submit)', () => {
const w = createTagsInput();
const input = w.find('input').element as HTMLInputElement;
input.value = 'hello';
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
w.unmount();
});
it('Enter on an empty input leaves the default action alone', () => {
const w = createTagsInput();
const input = w.find('input').element as HTMLInputElement;
const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
w.unmount();
});
it('renders a hidden form-bubbling input per tag when name is set', async () => {
const w = createTagsInput({ defaultValue: ['a', 'b'], name: 'tags' });
await nextTick();
const hidden = w.findAll('input').filter(i => i.attributes('name')?.startsWith('tags'));
expect(hidden).toHaveLength(2);
expect(hidden.map(i => (i.element as HTMLInputElement).name).sort()).toEqual(['tags[0]', 'tags[1]']);
w.unmount();
});
it('renders no hidden input when name is omitted', () => {
const w = createTagsInput({ defaultValue: ['a'] });
const named = w.findAll('input').filter(i => i.attributes('name'));
expect(named).toHaveLength(0);
w.unmount();
});
it('keeps one hidden input for a required empty list (native required validation)', async () => {
const w = createTagsInput({ defaultValue: [], name: 'tags', required: true });
await nextTick();
const hidden = w.findAll('input').filter(i => i.attributes('name') === 'tags');
expect(hidden).toHaveLength(1);
expect((hidden[0]!.element as HTMLInputElement).required).toBe(true);
w.unmount();
});
it('forwards Root id onto the inner input', () => {
const w = createTagsInput({ id: 'my-tags' });
expect((w.find('input[type="text"]').element as HTMLInputElement).id).toBe('my-tags');
w.unmount();
});
it('reflects focus-within via data-focused on Root', async () => {
const w = createTagsInput();
const root = w.findComponent(TagsInputRoot as Component).element as HTMLElement;
const input = w.find('input').element as HTMLInputElement;
expect(root.hasAttribute('data-focused')).toBe(false);
input.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await nextTick();
expect(root.hasAttribute('data-focused')).toBe(true);
w.unmount();
});
it('normalizes a null controlled value to an empty list', async () => {
const w = mount(
defineComponent({
setup() {
const tags = ref<string[] | null>(null);
return () => h(
TagsInputRoot,
{
modelValue: tags.value,
'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as string[]),
},
{
default: ({ modelValue }: { modelValue: string[] }) => [
...modelValue.map(tag => h(TagsInputItem, { key: tag, value: tag }, {
default: () => [h(TagsInputItemText, null, { default: () => tag })],
})),
h(TagsInputInput),
],
},
);
},
}),
{ attachTo: document.body },
);
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(0);
const input = w.find('input');
(input.element as HTMLInputElement).value = 'x';
await input.trigger('keydown', { key: 'Enter' });
await nextTick();
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
w.unmount();
});
it('addOnBlur does NOT commit when focus moves into the aria-controls popup', async () => {
const popup = document.createElement('div');
popup.id = 'tags-popup';
const option = document.createElement('button');
popup.appendChild(option);
document.body.appendChild(popup);
const w = createTagsInput({ addOnBlur: true });
const input = w.find('input').element as HTMLInputElement;
input.setAttribute('aria-controls', 'tags-popup');
input.value = 'pending';
input.dispatchEvent(new FocusEvent('blur', { bubbles: true, relatedTarget: option }));
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(0);
document.body.removeChild(popup);
w.unmount();
});
it('addOnBlur commits when focus moves outside the aria-controls popup', async () => {
const w = createTagsInput({ addOnBlur: true });
const input = w.find('input').element as HTMLInputElement;
input.setAttribute('aria-controls', 'tags-popup');
input.value = 'pending';
input.dispatchEvent(new FocusEvent('blur', { bubbles: true, relatedTarget: null }));
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
w.unmount();
});
it('throws on add when object tags are used without convertValue', async () => {
const captured: unknown[] = [];
const w = mount(
defineComponent({
setup() {
const tags = ref<ObjTag[]>([{ id: 'a', label: 'A' }]);
return () => h(
TagsInputRoot,
{
modelValue: tags.value,
'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as ObjTag[]),
},
{ default: () => [h(TagsInputInput)] },
);
},
}),
{ attachTo: document.body, global: { config: { errorHandler: (err: unknown) => captured.push(err) } } },
);
const input = w.find('input');
(input.element as HTMLInputElement).value = 'b';
press(input.element, 'Enter');
await nextTick();
expect(captured).toHaveLength(1);
expect((captured[0] as Error).message).toMatch(/convertValue/);
w.unmount();
});
it('deletes an object tag via its delete button using deep equality', async () => {
const w = createObjectTagsInput({
defaultValue: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }],
convertValue: (raw: string) => ({ id: raw, label: raw.toUpperCase() }),
displayValue: (v: { label: string }) => v.label,
});
await nextTick();
// Click the first tag's delete button; deep equality resolves its index even
// though the stored object is a fresh reference per render.
const firstDelete = w.findAll('button').find(b => b.text() === '×')!;
await firstDelete.trigger('click');
await nextTick();
const remaining = w.findAllComponents(TagsInputItem as Component);
expect(remaining).toHaveLength(1);
expect(remaining[0]!.text()).toContain('B');
w.unmount();
});
it('rejects structurally-equal object tags as duplicates', async () => {
const w = createObjectTagsInput({
defaultValue: [{ id: 'a', label: 'A' }],
convertValue: (raw: string) => ({ id: raw, label: raw.toUpperCase() }),
displayValue: (v: { label: string }) => v.label,
});
const input = w.find('input');
(input.element as HTMLInputElement).value = 'a';
await input.trigger('keydown', { key: 'Enter' });
await nextTick();
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(1);
expect(w.findComponent(TagsInputRoot as Component).emitted('invalid')).toBeTruthy();
w.unmount();
});
// Mirrors demo.vue: Clear lives in a footer wrapper that is a deep
// descendant of Root, not a direct slot child.
it('Clear injects context when nested deeper inside Root (demo layout)', async () => {
const w = mount(
defineComponent({
setup() {
const tags = ref<string[]>(['a', 'b']);
return () => h(
TagsInputRoot,
{
modelValue: tags.value,
'onUpdate:modelValue': (v: TagValue[]) => (tags.value = v as string[]),
},
{
default: () => [
h('div', [
...tags.value.map(tag =>
h(TagsInputItem, { key: tag, value: tag }, {
default: () => [h(TagsInputItemText, null, { default: () => tag })],
}),
),
h(TagsInputInput),
]),
h('div', [h(TagsInputClear, null, { default: () => 'Clear all' })]),
],
},
);
},
}),
{ attachTo: document.body },
);
const clearBtn = w.findAll('button').find(b => b.text() === 'Clear all')!;
await clearBtn.trigger('click');
await nextTick();
expect(w.findAllComponents(TagsInputItem as Component)).toHaveLength(0);
w.unmount();
});
});
@@ -0,0 +1,55 @@
import type { Ref, ShallowRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type TagValue = string | number | Record<string, unknown>;
export interface TagsInputContext<T extends TagValue = TagValue> {
/** Current list of tags. */
modelValue: Ref<T[]>;
/** Currently focused tag element (for keyboard selection in the strip). */
selectedElement: ShallowRef<HTMLElement | undefined>;
/** Whether the last input attempt was rejected (duplicate or over max). */
isInvalidInput: Ref<boolean>;
addOnPaste: Ref<boolean>;
addOnTab: Ref<boolean>;
addOnBlur: Ref<boolean>;
disabled: Ref<boolean>;
delimiter: Ref<string | RegExp>;
direction: Ref<'ltr' | 'rtl'>;
max: Ref<number>;
/** Allow duplicate tags. */
duplicate: Ref<boolean>;
/** Optional id forwarded onto the `<TagsInputInput>` for label/aria wiring. */
id: Ref<string | undefined>;
/** Normalize a raw string into a tag value. */
convertValue: (raw: string) => T;
/** Format a tag value for display. */
displayValue: (value: T) => string;
/** Append a tag from a raw string. Returns `true` on success. */
onAddValue: (raw: string) => boolean;
/** Remove the tag at `index`. */
onRemoveValue: (index: number) => void;
/** Keyboard handler wired from `<TagsInputInput>` to the root. */
onInputKeyDown: (event: KeyboardEvent) => void;
}
export interface TagsInputItemContext<T extends TagValue = TagValue> {
value: Ref<T>;
displayValue: Ref<string>;
isSelected: Ref<boolean>;
disabled: Ref<boolean>;
textId: { value: string };
}
export const {
inject: useTagsInputContext,
provide: provideTagsInputContext,
} = useContextFactory<TagsInputContext<TagValue>>('tags-input');
export const {
inject: useTagsInputItemContext,
provide: provideTagsInputItemContext,
} = useContextFactory<TagsInputItemContext<TagValue>>('tags-input-item');
@@ -0,0 +1,83 @@
<script setup lang="ts">
import {
TagsInputClear,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText,
TagsInputRoot,
} from '@robonen/primitives';
import { ref } from 'vue';
const recipients = ref<string[]>(['ada@example.com', 'grace@example.com']);
const wasInvalid = ref(false);
function onInvalid() {
wasInvalid.value = true;
window.setTimeout(() => {
wasInvalid.value = false;
}, 1200);
}
</script>
<template>
<div class="demo-card w-full max-w-md p-6 text-fg">
<h3 class="text-base font-semibold">
Invite teammates
</h3>
<p class="mt-1 text-sm text-fg-muted">
Type an address and press Enter, comma, or paste a list.
</p>
<!-- Root wraps the footer too: TagsInputClear must be a descendant to inject the context. -->
<TagsInputRoot
v-model="recipients"
add-on-paste
add-on-blur
:max="5"
delimiter=","
class="group"
@invalid="onInvalid"
>
<div class="mt-4 flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-bg p-2 transition-colors focus-within:border-accent focus-within:ring-2 focus-within:ring-ring group-data-[invalid]:border-red-500 dark:group-data-[invalid]:border-red-400">
<TagsInputItem
v-for="tag in recipients"
:key="tag"
:value="tag"
class="flex items-center gap-1 rounded-md bg-bg-subtle py-0.5 pl-2 pr-1 text-sm text-fg data-[state=active]:bg-accent data-[state=active]:text-accent-fg"
>
<TagsInputItemText class="leading-none" />
<TagsInputItemDelete
class="grid h-4 w-4 place-items-center rounded text-fg-subtle transition-colors hover:bg-bg-inset hover:text-fg"
aria-label="Remove"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput
placeholder="name@company.com"
class="min-w-32 flex-1 bg-transparent px-1 py-0.5 text-sm text-fg outline-none placeholder:text-fg-subtle"
/>
</div>
<div class="mt-3 flex items-center justify-between text-sm">
<p
class="font-medium transition-colors"
:class="wasInvalid ? 'text-red-600 dark:text-red-400' : 'text-fg-subtle'"
>
<span v-if="wasInvalid">Duplicate or limit reached</span>
<span v-else>{{ recipients.length }} / 5 recipients</span>
</p>
<TagsInputClear
class="rounded-md px-2 py-1 text-accent transition-colors hover:bg-bg-inset disabled:cursor-not-allowed disabled:opacity-50"
>
Clear all
</TagsInputClear>
</div>
</TagsInputRoot>
</div>
</template>
@@ -0,0 +1,23 @@
export { default as TagsInputRoot } from './TagsInputRoot.vue';
export { default as TagsInputItem } from './TagsInputItem.vue';
export { default as TagsInputItemText } from './TagsInputItemText.vue';
export { default as TagsInputItemDelete } from './TagsInputItemDelete.vue';
export { default as TagsInputInput } from './TagsInputInput.vue';
export { default as TagsInputClear } from './TagsInputClear.vue';
export {
provideTagsInputContext,
useTagsInputContext,
provideTagsInputItemContext,
useTagsInputItemContext,
type TagsInputContext,
type TagsInputItemContext,
type TagValue,
} from './context';
export type { TagsInputRootProps, TagsInputRootEmits } from './TagsInputRoot.vue';
export type { TagsInputItemProps } from './TagsInputItem.vue';
export type { TagsInputItemTextProps } from './TagsInputItemText.vue';
export type { TagsInputItemDeleteProps } from './TagsInputItemDelete.vue';
export type { TagsInputInputProps } from './TagsInputInput.vue';
export type { TagsInputClearProps } from './TagsInputClear.vue';

Some files were not shown because too many files have changed in this diff Show More