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.
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,43 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { useContextFactory } from '@robonen/vue';
|
||||
|
||||
export interface NumberFieldContext {
|
||||
value: Ref<number | null>;
|
||||
min: Ref<number | undefined>;
|
||||
max: Ref<number | undefined>;
|
||||
step: Ref<number>;
|
||||
disabled: Ref<boolean>;
|
||||
readonly: Ref<boolean>;
|
||||
increment: (delta?: number) => void;
|
||||
decrement: (delta?: number) => void;
|
||||
setValue: (v: number | null) => void;
|
||||
inputId: string;
|
||||
/** Text shown in the field, formatted with the active locale/format options. */
|
||||
textValue: ComputedRef<string>;
|
||||
/** Suggested soft-keyboard mode derived from whether fractional input is allowed. */
|
||||
inputMode: ComputedRef<'numeric' | 'decimal'>;
|
||||
/** Live parse of a raw input string to a value (empty/unparseable → `null`). */
|
||||
parseInput: (raw: string) => number | null;
|
||||
/** Re-parse, snap, clamp, and reformat the raw input string. Used on commit. */
|
||||
applyInputValue: (raw: string) => void;
|
||||
/** `true` when a prospective raw value is a valid partial number for the locale. */
|
||||
validate: (raw: string) => boolean;
|
||||
/** `true` when the next increment would exceed `max`. */
|
||||
isIncrementDisabled: ComputedRef<boolean>;
|
||||
/** `true` when the next decrement would drop below `min`. */
|
||||
isDecrementDisabled: ComputedRef<boolean>;
|
||||
/** Suppress wheel-driven stepping. */
|
||||
disableWheelChange: Ref<boolean>;
|
||||
/** Invert the direction of wheel-driven stepping. */
|
||||
invertWheelChange: Ref<boolean>;
|
||||
/** Return focus to the input after a stepper button changes the value. */
|
||||
focusOnChange: Ref<boolean>;
|
||||
/** Track the live input element so wheel / focus-on-change can target it. */
|
||||
inputEl: Ref<HTMLInputElement | undefined>;
|
||||
onInputElement: (el: HTMLInputElement | undefined) => void;
|
||||
}
|
||||
|
||||
const ctx = useContextFactory<NumberFieldContext>('NumberFieldContext');
|
||||
|
||||
export const provideNumberFieldContext = ctx.provide;
|
||||
export const useNumberFieldContext = ctx.inject;
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot } from '@robonen/primitives';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const UNIT_PRICE = 12;
|
||||
const MAX = 10;
|
||||
|
||||
const quantity = ref<number | null>(2);
|
||||
|
||||
const total = computed(() => (quantity.value ?? 0) * UNIT_PRICE);
|
||||
const atMax = computed(() => quantity.value !== null && quantity.value >= MAX);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 p-6 max-w-xs bg-bg text-fg border border-border rounded-xl">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold">Espresso blend</span>
|
||||
<span class="text-xs text-fg-subtle">${{ UNIT_PRICE }}.00 per bag</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium text-fg-muted">Quantity</span>
|
||||
|
||||
<NumberFieldRoot
|
||||
v-model="quantity"
|
||||
:min="1"
|
||||
:max="MAX"
|
||||
:step="1"
|
||||
class="inline-flex items-center w-fit rounded-lg border border-border bg-bg-inset overflow-hidden focus-within:ring-2 focus-within:ring-ring data-[disabled]:opacity-50"
|
||||
>
|
||||
<NumberFieldDecrement
|
||||
class="grid place-items-center w-9 h-9 text-fg-muted hover:bg-bg-subtle hover:text-fg transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M3 7h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</NumberFieldDecrement>
|
||||
|
||||
<NumberFieldInput
|
||||
name="quantity"
|
||||
placeholder="0"
|
||||
class="w-12 h-9 text-center text-sm font-medium bg-transparent text-fg outline-none tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
|
||||
<NumberFieldIncrement
|
||||
class="grid place-items-center w-9 h-9 text-fg-muted hover:bg-bg-subtle hover:text-fg transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</NumberFieldIncrement>
|
||||
</NumberFieldRoot>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-border">
|
||||
<span class="text-sm text-fg-muted">Total</span>
|
||||
<span class="text-sm font-semibold tabular-nums">${{ total }}.00</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-xs"
|
||||
:class="atMax ? 'text-red-600 dark:text-red-400' : 'text-fg-subtle'"
|
||||
>
|
||||
{{ atMax ? `Maximum of ${MAX} bags per order` : 'Use the arrow keys or buttons to adjust' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue';
|
||||
export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue';
|
||||
export { default as NumberFieldInput } from './NumberFieldInput.vue';
|
||||
export { default as NumberFieldRoot } from './NumberFieldRoot.vue';
|
||||
export type { NumberFieldDecrementProps } from './NumberFieldDecrement.vue';
|
||||
export type { NumberFieldIncrementProps } from './NumberFieldIncrement.vue';
|
||||
export type { NumberFieldInputProps } from './NumberFieldInput.vue';
|
||||
export type { NumberFieldRootEmits, NumberFieldRootProps } from './NumberFieldRoot.vue';
|
||||
export {
|
||||
provideNumberFieldContext,
|
||||
useNumberFieldContext,
|
||||
type NumberFieldContext,
|
||||
} from './context';
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { computed, onScopeDispose, ref, toValue } from 'vue';
|
||||
import { createEventHook, useEventListener } from '@robonen/vue';
|
||||
|
||||
/**
|
||||
* Decimal-safe add/subtract. Floating point makes `0.1 + 0.2 === 0.30000000000000004`,
|
||||
* which is wrong for a stepper. We scale both operands to integers by the larger
|
||||
* of their decimal lengths, operate, then scale back.
|
||||
*/
|
||||
export function handleDecimalOperation(operator: '+' | '-', a: number, b: number): number {
|
||||
let result = operator === '+' ? a + b : a - b;
|
||||
|
||||
if (a % 1 !== 0 || b % 1 !== 0) {
|
||||
const aDecimals = a.toString().split('.')[1]?.length ?? 0;
|
||||
const bDecimals = b.toString().split('.')[1]?.length ?? 0;
|
||||
const multiplier = 10 ** Math.max(aDecimals, bDecimals);
|
||||
|
||||
const aInt = Math.round(a * multiplier);
|
||||
const bInt = Math.round(b * multiplier);
|
||||
|
||||
result = (operator === '+' ? aInt + bInt : aInt - bInt) / multiplier;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a value to the decimal precision implied by `step` (e.g. step `0.5` →
|
||||
* one decimal place), correcting floating-point drift after snapping.
|
||||
*/
|
||||
export function roundToStepPrecision(value: number, step: number): number {
|
||||
const stepString = step.toString();
|
||||
const pointIndex = stepString.indexOf('.');
|
||||
const precision = pointIndex >= 0 ? stepString.length - pointIndex - 1 : 0;
|
||||
|
||||
if (precision > 0) {
|
||||
const pow = 10 ** precision;
|
||||
return Math.round(value * pow) / pow;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap `value` to the nearest multiple of `step` within `[min, max]`. Mirrors the
|
||||
* spinbutton semantics: when the value lands beyond a bound the snapped result is
|
||||
* pulled back to the last reachable on-grid value inside the range.
|
||||
*/
|
||||
export function snapValueToStep(value: number, min: number | undefined, max: number | undefined, step: number): number {
|
||||
const lo = min ?? Number.NaN;
|
||||
const hi = max ?? Number.NaN;
|
||||
|
||||
const remainder = (value - (Number.isNaN(lo) ? 0 : lo)) % step;
|
||||
let snapped = roundToStepPrecision(
|
||||
Math.abs(remainder) * 2 >= step
|
||||
? value + Math.sign(remainder) * (step - Math.abs(remainder))
|
||||
: value - remainder,
|
||||
step,
|
||||
);
|
||||
|
||||
if (!Number.isNaN(lo)) {
|
||||
if (snapped < lo)
|
||||
snapped = lo;
|
||||
else if (!Number.isNaN(hi) && snapped > hi)
|
||||
snapped = lo + Math.floor(roundToStepPrecision((hi - lo) / step, step)) * step;
|
||||
}
|
||||
else if (!Number.isNaN(hi) && snapped > hi) {
|
||||
snapped = Math.floor(roundToStepPrecision(hi / step, step)) * step;
|
||||
}
|
||||
|
||||
return roundToStepPrecision(snapped, step);
|
||||
}
|
||||
|
||||
export interface UsePressedHoldOptions {
|
||||
/** Element the press starts on (pointer must go down on it). */
|
||||
target: Ref<HTMLElement | undefined>;
|
||||
/** When `true`, presses are ignored and any in-flight repeat stops. */
|
||||
disabled: MaybeRefOrGetter<boolean>;
|
||||
}
|
||||
|
||||
export interface UsePressedHoldReturn {
|
||||
/** `true` while the pointer is held down on the target. */
|
||||
isPressed: Ref<boolean>;
|
||||
/** Register a callback fired once on press and then repeatedly while held. */
|
||||
onTrigger: (fn: () => void) => void;
|
||||
/**
|
||||
* `true` for one `click` immediately after a real pointer press already fired
|
||||
* the trigger. Lets a `@click` fallback (for programmatic / keyboard-activated
|
||||
* clicks that emit no `pointerdown`) avoid double-firing.
|
||||
*/
|
||||
consumeClick: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press-and-hold auto-repeat for stepper buttons: fires immediately on
|
||||
* pointer-down, then repeats every 60ms after an initial 400ms delay until the
|
||||
* pointer is released, cancelled, or leaves. Built on the toolkit's
|
||||
* `useEventListener` (auto-cleaned on scope dispose) and `createEventHook`.
|
||||
*/
|
||||
export function usePressedHold(options: UsePressedHoldOptions): UsePressedHoldReturn {
|
||||
const { target, disabled } = options;
|
||||
const isPressed = ref(false);
|
||||
const triggerHook = createEventHook<void>();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Set when a pointer press fired the trigger; the synthetic `click` that the
|
||||
// browser dispatches afterwards must be swallowed by the `@click` fallback.
|
||||
let pressDidTrigger = false;
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function repeat(delay: number): void {
|
||||
clearTimer();
|
||||
if (toValue(disabled))
|
||||
return;
|
||||
|
||||
triggerHook.trigger();
|
||||
timer = setTimeout(() => repeat(60), delay);
|
||||
}
|
||||
|
||||
function onPressStart(event: PointerEvent): void {
|
||||
// Only the primary (left) button, and ignore re-entrant presses.
|
||||
if (event.button !== 0 || isPressed.value || toValue(disabled))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
isPressed.value = true;
|
||||
pressDidTrigger = true;
|
||||
repeat(400);
|
||||
}
|
||||
|
||||
function onPressEnd(): void {
|
||||
isPressed.value = false;
|
||||
clearTimer();
|
||||
}
|
||||
|
||||
function consumeClick(): boolean {
|
||||
if (pressDidTrigger) {
|
||||
pressDidTrigger = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
useEventListener(target, 'pointerdown', onPressStart);
|
||||
// Release/cancel are watched on `window` so lifting the pointer outside the
|
||||
// button still ends the hold; leaving the button also ends it.
|
||||
useEventListener(globalThis.window, 'pointerup', onPressEnd);
|
||||
useEventListener(globalThis.window, 'pointercancel', onPressEnd);
|
||||
useEventListener(target, 'pointerleave', onPressEnd);
|
||||
|
||||
// Clear any in-flight repeat when the owning component unmounts.
|
||||
onScopeDispose(clearTimer);
|
||||
|
||||
return {
|
||||
isPressed,
|
||||
onTrigger: triggerHook.on,
|
||||
consumeClick,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, locale-aware number formatting/parsing on top of native
|
||||
* `Intl.NumberFormat` (no external i18n dependency). `format` renders the value
|
||||
* with grouping/currency/percent per `options`; `parse` strips locale-specific
|
||||
* group and decimal separators (and currency/percent symbols) back to a plain
|
||||
* `number`, returning `NaN` for unparseable input.
|
||||
*/
|
||||
export function createNumberFormat(locale: MaybeRefOrGetter<string>, options: MaybeRefOrGetter<Intl.NumberFormatOptions | undefined>) {
|
||||
const formatter = computed(() => new Intl.NumberFormat(toValue(locale), toValue(options)));
|
||||
|
||||
const separators = computed(() => {
|
||||
const parts = formatter.value.formatToParts(12345.6);
|
||||
const group = parts.find(p => p.type === 'group')?.value ?? ',';
|
||||
const decimal = parts.find(p => p.type === 'decimal')?.value ?? '.';
|
||||
return { group, decimal };
|
||||
});
|
||||
|
||||
const resolved = computed(() => formatter.value.resolvedOptions());
|
||||
|
||||
// The partial-validation pattern depends solely on the locale decimal
|
||||
// separator, so rebuild it only when that separator changes rather than on
|
||||
// every keystroke (isValidPartial runs from @beforeinput per character).
|
||||
const partialRe = computed(
|
||||
() => new RegExp(`^[+-]?[\\d${escapeRegExp(separators.value.decimal)}.,\\s\\u00A0%$€£¥]*$`),
|
||||
);
|
||||
|
||||
function format(value: number): string {
|
||||
return formatter.value.format(value);
|
||||
}
|
||||
|
||||
function parse(raw: string): number {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '')
|
||||
return Number.NaN;
|
||||
|
||||
const { group, decimal } = separators.value;
|
||||
// Strip everything except digits, sign, and the locale decimal separator,
|
||||
// then normalise the decimal separator to `.` for `Number`.
|
||||
const cleaned = trimmed
|
||||
.split(group).join('')
|
||||
.split(decimal).join('.')
|
||||
.replaceAll(/[^\d.\-+e]/gi, '');
|
||||
|
||||
// No digits at all → not a number (`Number('')` would coerce to 0).
|
||||
if (!/\d/.test(cleaned))
|
||||
return Number.NaN;
|
||||
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : Number.NaN;
|
||||
}
|
||||
|
||||
function isValidPartial(raw: string): boolean {
|
||||
if (raw === '' || raw === '-' || raw === '+')
|
||||
return true;
|
||||
|
||||
return partialRe.value.test(raw);
|
||||
}
|
||||
|
||||
return { format, parse, isValidPartial, resolved };
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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';
|
||||