feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,151 @@
<script lang="ts">
import type { Direction } from '../../utilities/config-provider';
import type { Orientation } from '../../utilities/roving-focus';
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue } from './context';
/**
* Coordinates a set of related checkboxes behind a single array model. It owns
* the list of selected `value`s (`v-model` or uncontrolled `defaultValue`),
* applies a group-level `disabled`, optionally wires arrow-key roving focus
* across the children, and — when `name` is set inside a `<form>` — submits the
* selection through hidden inputs. Each nested `CheckboxRoot` derives its
* checked state from membership in this model and toggling adds/removes its
* `value`. Reach for it whenever several checkboxes share one logical answer
* (a multi-select question, a filter set, a permissions matrix).
*/
export interface CheckboxGroupRootProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
/** Uncontrolled initial selection. */
defaultValue?: T[];
/** Controlled selection. Bind with `v-model`. */
modelValue?: T[];
/** Disable every checkbox in the group. */
disabled?: boolean;
/** Mark the submitted group input as required. */
required?: boolean;
/** Hidden input name; serializes the selection for form submission. */
name?: string;
/**
* Enable arrow-key roving focus across the checkboxes.
* @default true
*/
rovingFocus?: boolean;
/** Navigation orientation when `rovingFocus` is on. */
orientation?: Orientation;
/** Writing direction (RTL-aware navigation). Falls back to config `dir`. */
dir?: Direction;
/**
* Wrap focus around the ends.
* @default false
*/
loop?: boolean;
}
export interface CheckboxGroupRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T[]];
valueChange: [value: T[]];
}
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
import type { Ref } from 'vue';
import { computed, ref, toRef, watch } from 'vue';
import { isEqual } from '@robonen/stdlib';
import { Primitive } from '../../internal/primitive';
import { RovingFocusGroup } from '../../utilities/roving-focus';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
import { useForwardExpose } from '@robonen/vue';
import { provideCheckboxGroupContext } from './context';
const {
defaultValue,
disabled = false,
required = false,
name,
rovingFocus = true,
orientation,
dir,
loop = false,
as = 'div',
} = defineProps<CheckboxGroupRootProps<T>>();
const emit = defineEmits<CheckboxGroupRootEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose();
// `modelValue` is an array replaced wholesale on every toggle, so `shallowRef`
// avoids deep-tracking each member.
const localValue = ref<T[]>(defaultValue ?? []) as Ref<T[]>;
const model = defineModel<T[] | undefined>({
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = (v ?? []) as T[];
return v;
},
});
const currentValue = computed<T[]>(() => model.value ?? localValue.value);
function isChecked(value: AcceptableValue): boolean {
for (const v of currentValue.value) {
if (isEqual(v, value)) return true;
}
return false;
}
function toggle(value: AcceptableValue): void {
if (disabled) return;
const next = [...currentValue.value];
const index = next.findIndex(v => isEqual(v, value));
if (index === -1) next.push(value as T);
else next.splice(index, 1);
model.value = next;
emit('valueChange', next);
}
const rovingFocusProps = computed(() =>
rovingFocus ? { loop, dir, orientation } : {});
// Only submit through the form when inside one; SSR renders so the field
// submits without JS.
const isFormControl = computed<boolean>(() => {
if (globalThis.document === undefined) return true;
const el = currentElement.value;
return !!el && !!el.closest('form');
});
watch(model, (v) => {
if (v !== undefined && v !== localValue.value) localValue.value = v;
});
provideCheckboxGroupContext({
modelValue: currentValue as Ref<AcceptableValue[]>,
disabled: toRef(() => disabled),
rovingFocus: toRef(() => rovingFocus),
toggle,
isChecked,
});
</script>
<template>
<component
:is="rovingFocus ? RovingFocusGroup : Primitive"
:ref="forwardRef"
:as="as"
role="group"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
v-bind="rovingFocusProps"
>
<slot :model-value="currentValue" />
<VisuallyHiddenInput
v-if="isFormControl && name"
:name="name"
:value="currentValue"
:required="required"
:disabled="disabled"
/>
</component>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Renders its content only when the parent `CheckboxRoot` is checked or
* indeterminate, mirroring that state via `data-state`. Place the check/dash
* icon inside it; use `forceMount` to keep it mounted for CSS exit animations.
*/
export interface CheckboxIndicatorProps extends PrimitiveProps {
/** Keep mounted even when unchecked (for CSS exit animations). */
forceMount?: boolean;
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { Presence } from '../../utilities/presence';
import { useCheckboxContext } from './context';
import { getState, isIndeterminate } from './utils';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span', forceMount = false } = defineProps<CheckboxIndicatorProps>();
const ctx = useCheckboxContext();
defineOptions({ inheritAttrs: false });
const { forwardRef } = useForwardExpose();
</script>
<template>
<Presence
:present="forceMount || isIndeterminate(ctx.checked.value) || ctx.checked.value === true"
>
<Primitive
:ref="forwardRef"
:as="as"
v-bind="$attrs"
:data-state="getState(ctx.checked.value)"
:data-disabled="ctx.disabled.value ? '' : undefined"
style="pointer-events: none;"
>
<slot :checked="ctx.checked.value" />
</Primitive>
</Presence>
</template>
@@ -0,0 +1,188 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { AcceptableValue, CheckedState } from './context';
/**
* A toggleable control with checked, unchecked, and `'indeterminate'` states,
* built on a native `<button role="checkbox">`. The interactive root: it owns
* the checked state (controlled via `v-model:checked` or uncontrolled via
* `defaultChecked`), handles toggling, exposes a hidden form input when `name`
* is set, and provides context to `CheckboxIndicator`. Use it whenever you need
* a styled checkbox that integrates with forms or supports a mixed/partial state.
*
* The checked value is generic: with the default `trueValue`/`falseValue`
* (`true`/`false`) it behaves as a boolean checkbox, but those props let the
* model carry arbitrary values (`'yes'`/`'no'`, objects, …) compared by deep
* equality. Nesting the root inside a `CheckboxGroupRoot` switches it to group
* mode: its checked state derives from membership in the group's array model
* and toggling adds/removes its `value`.
*/
export interface CheckboxRootProps<T = boolean> extends PrimitiveProps {
/** Uncontrolled initial checked state. */
defaultChecked?: T | 'indeterminate';
/** Disable interaction. */
disabled?: boolean;
/** Mark associated hidden input as required. */
required?: boolean;
/** Hidden input name attribute. */
name?: string;
/**
* Value submitted with the form (hidden input) and used for membership when
* inside a `CheckboxGroupRoot`.
* @default 'on'
*/
value?: AcceptableValue;
/** Id of the root element; anchors `<label for>` and aria-label derivation. */
id?: string;
/**
* Value the model holds when checked.
* @default true
*/
trueValue?: T;
/**
* Value the model holds when unchecked.
* @default false
*/
falseValue?: T;
}
export interface CheckboxRootEmits<T = boolean> {
checkedChange: [value: T | 'indeterminate'];
}
</script>
<script setup lang="ts" generic="T = boolean">
import type { Ref } from 'vue';
import { Primitive } from '../../internal/primitive';
import { computed, ref } from 'vue';
import { isEqual } from '@robonen/stdlib';
import { provideCheckboxContext, useCheckboxGroupContext } from './context';
import { getState, isIndeterminate } from './utils';
import { useForwardExpose } from '@robonen/vue';
import { RovingFocusItem } from '../../utilities/roving-focus';
import { VisuallyHiddenInputBubble } from '../../utilities/visually-hidden';
defineOptions({ inheritAttrs: false });
const {
disabled: disabledProp = false,
required = false,
value = 'on',
defaultChecked,
name,
id,
trueValue = true as unknown as T,
falseValue = false as unknown as T,
as = 'button',
} = defineProps<CheckboxRootProps<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const emit = defineEmits<CheckboxRootEmits<T>>();
// Group mode: when an ancestor `CheckboxGroupRoot` is present the checked state
// is derived from membership in the group's array and toggling mutates it.
const group = useCheckboxGroupContext(null);
const localChecked = ref<T | 'indeterminate'>(defaultChecked ?? (falseValue as T)) as Ref<T | 'indeterminate'>;
// `defineModel` handles both controlled (parent `v-model:checked`) and
// uncontrolled modes; `localChecked` backs the uncontrolled state seeded from
// `defaultChecked`. `checkedChange` is a separate public emit, so it stays.
const checked = defineModel<T | 'indeterminate' | undefined>('checked', {
default: undefined,
get: v => v ?? localChecked.value,
set: (v) => {
localChecked.value = v as T | 'indeterminate';
return v;
},
});
const disabled = computed<boolean>(() => (group?.disabled.value ?? false) || disabledProp);
// Canonical `CheckedState` for ARIA / `data-state` / the indicator. In group
// mode it is pure membership; standalone it compares the model to `trueValue`.
const checkedState = computed<CheckedState>(() => {
if (group) return group.isChecked(value);
const v = checked.value;
if (isIndeterminate(v)) return 'indeterminate';
return isEqual(v, trueValue);
});
function setChecked(v: T | 'indeterminate'): void {
checked.value = v;
emit('checkedChange', v);
}
function toggle(): void {
if (disabled.value) return;
if (group) {
group.toggle(value);
return;
}
// From indeterminate or unchecked → trueValue; from checked → falseValue.
const next = checkedState.value === true ? falseValue : trueValue;
setChecked(next);
}
function onKeyDown(event: KeyboardEvent): void {
// Per WAI-ARIA a checkbox does not activate on Enter; block the implicit
// form submit too.
if (event.key === 'Enter') event.preventDefault();
// <button> handles Space natively; synthesize toggle only for non-button hosts.
if (as !== 'button' && event.key === ' ') {
event.preventDefault();
toggle();
}
}
// Derive an accessible name from an associated `<label for=id>` when no explicit
// `aria-label` is supplied. Guarded for SSR (no `document`).
const ariaLabel = computed<string | undefined>(() => {
if (!id || !currentElement.value || globalThis.document === undefined) return undefined;
const label = globalThis.document.querySelector(`[for="${id}"]`) as HTMLElement | null;
return label?.innerText || undefined;
});
// A standalone checkbox renders a hidden form input whenever `name` is set; a
// grouped checkbox never does (the group owns the submitted value).
const hasHiddenInput = computed<boolean>(() => !group && !!name);
provideCheckboxContext({
checked: checkedState,
disabled,
});
</script>
<template>
<component
:is="group?.rovingFocus.value ? RovingFocusItem : Primitive"
:ref="forwardRef"
v-bind="$attrs"
:id="id"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:tabindex="(as === 'button' || group?.rovingFocus.value) ? undefined : (disabled ? -1 : 0)"
:focusable="group?.rovingFocus.value ? !disabled : undefined"
role="checkbox"
:aria-checked="isIndeterminate(checkedState) ? 'mixed' : checkedState"
:aria-required="required || undefined"
:aria-disabled="disabled || undefined"
:aria-label="($attrs['aria-label'] as string) || ariaLabel"
:data-state="getState(checkedState)"
:data-disabled="disabled ? '' : undefined"
:disabled="disabled || undefined"
@click="toggle"
@keydown="onKeyDown"
>
<slot :checked="checkedState" :model-value="checked" :state="checkedState" />
<VisuallyHiddenInputBubble
v-if="hasHiddenInput"
:name="name!"
:value="value"
:checked="checkedState === true"
:required="required"
:disabled="disabled"
/>
</component>
</template>
@@ -0,0 +1,109 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { CheckboxIndicator, CheckboxRoot } from '../index';
function mountCheckbox(props: Record<string, unknown> = {}) {
return mount(CheckboxRoot, {
attachTo: document.body,
props,
slots: {
default: () => h(CheckboxIndicator, null, { default: () => '✓' }),
},
});
}
describe('Checkbox', () => {
it('renders role="checkbox" with aria-checked="false" initially', () => {
const w = mountCheckbox();
const el = w.element;
expect(el.getAttribute('role')).toBe('checkbox');
expect(el.getAttribute('aria-checked')).toBe('false');
expect(el.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('toggles on click', async () => {
const w = mountCheckbox();
const el = w.element as HTMLElement;
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('true');
expect(el.getAttribute('data-state')).toBe('checked');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('honours defaultChecked', () => {
const w = mountCheckbox({ defaultChecked: true });
expect(w.element.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('supports indeterminate state with aria-checked="mixed"', async () => {
const checked = ref<boolean | 'indeterminate'>('indeterminate');
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: checked.value,
'onUpdate:checked': (v: boolean | 'indeterminate' | undefined) => { checked.value = v!; },
}, { default: () => h(CheckboxIndicator) }),
});
const w = mount(Harness, { attachTo: document.body });
expect(w.element.getAttribute('aria-checked')).toBe('mixed');
(w.element as HTMLElement).click();
await nextTick();
// Click from indeterminate → true
expect(checked.value).toBe(true);
w.unmount();
});
it('disabled: no toggle on click, aria-disabled set', async () => {
const w = mountCheckbox({ disabled: true });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-disabled')).toBe('true');
el.click();
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('emits checkedChange', async () => {
const w = mountCheckbox();
(w.element as HTMLElement).click();
await nextTick();
expect(w.emitted('checkedChange')).toEqual([[true]]);
w.unmount();
});
it('renders hidden input when name is set', async () => {
const w = mountCheckbox({ name: 'agree', value: 'yes', defaultChecked: true });
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.value).toBe('yes');
expect(input.checked).toBe(true);
w.unmount();
});
it('CheckboxIndicator only renders when checked (or forceMount)', async () => {
const w = mountCheckbox();
expect(w.element.querySelector('span')).toBeNull();
(w.element as HTMLElement).click();
await nextTick();
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
it('CheckboxIndicator forceMount stays mounted when unchecked', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, { forceMount: true }, { default: () => '✓' }),
},
});
expect(w.element.querySelector('span')).toBeTruthy();
w.unmount();
});
});
@@ -0,0 +1,328 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { CheckboxGroupRoot, CheckboxIndicator, CheckboxRoot } from '../index';
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('CheckboxRoot — generic value (trueValue/falseValue)', () => {
it('models arbitrary string values via trueValue/falseValue', async () => {
const model = ref<string>('no');
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: model.value,
trueValue: 'yes',
falseValue: 'no',
'onUpdate:checked': (v: unknown) => { model.value = v as string; },
}),
});
const w = mount(Harness, { attachTo: document.body });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-checked')).toBe('false');
expect(el.getAttribute('data-state')).toBe('unchecked');
el.click();
await nextTick();
expect(model.value).toBe('yes');
expect(el.getAttribute('aria-checked')).toBe('true');
el.click();
await nextTick();
expect(model.value).toBe('no');
w.unmount();
});
it('compares object values by deep equality', async () => {
const trueVal = { id: 1 };
const model = ref<unknown>({ id: 0 });
const Harness = defineComponent({
setup: () => () => h(CheckboxRoot, {
checked: model.value,
trueValue: trueVal,
falseValue: { id: 0 },
'onUpdate:checked': (v: unknown) => { model.value = v; },
}),
});
const w = mount(Harness, { attachTo: document.body });
const el = w.element as HTMLElement;
expect(el.getAttribute('aria-checked')).toBe('false');
el.click();
await nextTick();
// Deeply equal to trueValue → checked.
expect(el.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('uncontrolled defaultChecked seeds the generic model', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { defaultChecked: 'yes', trueValue: 'yes', falseValue: 'no' },
});
expect(w.element.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
});
describe('CheckboxRoot — slot contract', () => {
it('exposes checked, modelValue and state to the default slot', async () => {
let captured: Record<string, unknown> = {};
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { defaultChecked: true },
slots: {
default: (scope: Record<string, unknown>) => {
captured = scope;
return '';
},
},
});
await nextTick();
expect(captured.checked).toBe(true);
expect(captured.state).toBe(true);
expect('modelValue' in captured).toBe(true);
w.unmount();
});
});
describe('CheckboxRoot — aria-label from associated label', () => {
it('derives aria-label from a <label for> when id is set', async () => {
const label = document.createElement('label');
label.setAttribute('for', 'cb-1');
label.textContent = 'Accept terms';
document.body.appendChild(label);
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { id: 'cb-1' },
});
await nextTick();
expect(w.element.getAttribute('aria-label')).toBe('Accept terms');
w.unmount();
label.remove();
});
it('an explicit aria-label wins over the derived one', async () => {
const label = document.createElement('label');
label.setAttribute('for', 'cb-2');
label.textContent = 'Derived';
document.body.appendChild(label);
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { id: 'cb-2' },
attrs: { 'aria-label': 'Explicit' },
});
await nextTick();
expect(w.element.getAttribute('aria-label')).toBe('Explicit');
w.unmount();
label.remove();
});
});
describe('CheckboxRoot — hidden form input', () => {
it('renders a native hidden checkbox input mirroring state', async () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
props: { name: 'agree', value: 'on', defaultChecked: true },
});
const input = w.element.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.name).toBe('agree');
expect(input.checked).toBe(true);
w.unmount();
});
it('does not render a hidden input without a name', () => {
const w = mount(CheckboxRoot, { attachTo: document.body });
expect(w.element.querySelector('input[type="checkbox"]')).toBeNull();
w.unmount();
});
});
describe('CheckboxRoot — keyboard', () => {
it('Enter does not toggle (WAI-ARIA) and is prevented', async () => {
const w = mount(CheckboxRoot, { attachTo: document.body });
const el = w.element as HTMLElement;
const ev = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
el.dispatchEvent(ev);
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('false');
expect(ev.defaultPrevented).toBe(true);
w.unmount();
});
it('Space toggles on a non-button host', async () => {
const w = mount(CheckboxRoot, { attachTo: document.body, props: { as: 'div' } });
const el = w.element as HTMLElement;
expect(el.getAttribute('tabindex')).toBe('0');
press(el, ' ');
await nextTick();
expect(el.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
});
describe('CheckboxIndicator — Presence', () => {
it('forceMount keeps it mounted with data-state unchecked', () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, { forceMount: true }, { default: () => '✓' }),
},
});
const span = w.element.querySelector('span') as HTMLElement;
expect(span).toBeTruthy();
expect(span.getAttribute('data-state')).toBe('unchecked');
w.unmount();
});
it('mounts on check and unmounts on uncheck (no animation)', async () => {
const w = mount(CheckboxRoot, {
attachTo: document.body,
slots: {
default: () => h(CheckboxIndicator, null, { default: () => '✓' }),
},
});
const el = w.element as HTMLElement;
expect(el.querySelector('span')).toBeNull();
el.click();
await nextTick();
expect(el.querySelector('span')).toBeTruthy();
el.click();
await nextTick();
expect(el.querySelector('span')).toBeNull();
w.unmount();
});
});
describe('CheckboxGroupRoot', () => {
function mountGroup(props: Record<string, unknown> = {}, values = ['a', 'b', 'c']) {
const Harness = defineComponent({
props: { groupProps: { type: Object, default: () => ({}) } },
setup: p => () => h('div', [
h(CheckboxGroupRoot, p.groupProps, {
default: () => values.map(v => h(CheckboxRoot, { key: v, value: v })),
}),
]),
});
return mount(Harness, { attachTo: document.body, props: { groupProps: props } });
}
it('renders role="group" and checks members present in the model', async () => {
const w = mountGroup({ defaultValue: ['b'] });
const group = w.element.querySelector('[role="group"]') as HTMLElement;
expect(group).toBeTruthy();
const boxes = w.element.querySelectorAll('[role="checkbox"]');
expect(boxes.length).toBe(3);
expect(boxes[0]!.getAttribute('aria-checked')).toBe('false');
expect(boxes[1]!.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
it('toggling a member adds/removes its value in the group model', async () => {
const model = ref<string[]>([]);
const Harness = defineComponent({
setup: () => () => h(CheckboxGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string[]) => { model.value = v; },
}, {
default: () => ['a', 'b'].map(v => h(CheckboxRoot, { key: v, value: v })),
}),
});
const w = mount(Harness, { attachTo: document.body });
const boxes = w.element.querySelectorAll('[role="checkbox"]');
(boxes[0] as HTMLElement).click();
await nextTick();
expect(model.value).toEqual(['a']);
(boxes[1] as HTMLElement).click();
await nextTick();
expect(model.value).toEqual(['a', 'b']);
(boxes[0] as HTMLElement).click();
await nextTick();
expect(model.value).toEqual(['b']);
w.unmount();
});
it('emits valueChange alongside update:modelValue', async () => {
const Harness = defineComponent({
emits: ['valueChange'],
setup: (_, { emit }) => () => h(CheckboxGroupRoot, {
onValueChange: (v: string[]) => emit('valueChange', v),
}, {
default: () => [h(CheckboxRoot, { value: 'x' })],
}),
});
const w = mount(Harness, { attachTo: document.body });
(w.element.querySelector('[role="checkbox"]') as HTMLElement).click();
await nextTick();
expect(w.emitted('valueChange')).toEqual([[['x']]]);
w.unmount();
});
it('group-level disabled blocks toggling and reflects on members', async () => {
const w = mountGroup({ disabled: true, defaultValue: [] });
const box = w.element.querySelector('[role="checkbox"]') as HTMLElement;
expect(box.getAttribute('aria-disabled')).toBe('true');
box.click();
await nextTick();
expect(box.getAttribute('aria-checked')).toBe('false');
w.unmount();
});
it('renders hidden inputs for the selection inside a form', async () => {
const Harness = defineComponent({
setup: () => () => h('form', [
h(CheckboxGroupRoot, { name: 'fruits', defaultValue: ['a', 'c'] }, {
default: () => ['a', 'b', 'c'].map(v => h(CheckboxRoot, { key: v, value: v })),
}),
]),
});
const w = mount(Harness, { attachTo: document.body });
await nextTick();
const inputs = Array.from(w.element.querySelectorAll('input[name^="fruits"]')) as HTMLInputElement[];
expect(inputs.length).toBe(2);
expect(inputs.map(i => i.value)).toEqual(['a', 'c']);
w.unmount();
});
it('grouped members do not render their own hidden form input', async () => {
const Harness = defineComponent({
setup: () => () => h('form', [
h(CheckboxGroupRoot, { defaultValue: ['a'] }, {
default: () => [h(CheckboxRoot, { value: 'a', name: 'should-be-ignored' })],
}),
]),
});
const w = mount(Harness, { attachTo: document.body });
await nextTick();
expect(w.element.querySelector('input[name="should-be-ignored"]')).toBeNull();
w.unmount();
});
it('roving focus moves focus across members with arrow keys', async () => {
const w = mountGroup({ rovingFocus: true, orientation: 'horizontal' });
const boxes = Array.from(w.element.querySelectorAll('[role="checkbox"]')) as HTMLElement[];
boxes[0]!.focus();
await nextTick();
press(boxes[0]!, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(boxes[1]);
w.unmount();
});
it('without rovingFocus, no RovingFocusGroup container is rendered', async () => {
const w = mountGroup({ rovingFocus: false });
// Members are still functional checkboxes.
const boxes = w.element.querySelectorAll('[role="checkbox"]');
expect(boxes.length).toBe(3);
(boxes[0] as HTMLElement).click();
await nextTick();
expect(boxes[0]!.getAttribute('aria-checked')).toBe('true');
w.unmount();
});
});
@@ -0,0 +1,39 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type CheckedState = boolean | 'indeterminate';
/**
* Values a checkbox can carry through a group model or a hidden form input. A
* plain boolean checkbox uses `boolean`, but `trueValue`/`falseValue` and group
* membership accept arbitrary primitives or plain objects.
*/
export type AcceptableValue = string | number | boolean | Record<string, unknown> | null;
export interface CheckboxContext {
checked: Ref<CheckedState>;
disabled: Ref<boolean>;
}
const ctx = useContextFactory<CheckboxContext>('CheckboxContext');
export const provideCheckboxContext = ctx.provide;
export const useCheckboxContext = ctx.inject;
/**
* Context published by `CheckboxGroupRoot`. A `CheckboxRoot` injects it with a
* `null` fallback; when present it switches to group mode — its checked state
* comes from membership in `modelValue` and toggling pushes/splices its `value`.
*/
export interface CheckboxGroupContext {
modelValue: Ref<AcceptableValue[]>;
disabled: Ref<boolean>;
rovingFocus: Ref<boolean>;
toggle: (value: AcceptableValue) => void;
isChecked: (value: AcceptableValue) => boolean;
}
const groupCtx = useContextFactory<CheckboxGroupContext>('CheckboxGroupContext');
export const provideCheckboxGroupContext = groupCtx.provide;
export const useCheckboxGroupContext = groupCtx.inject;
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { CheckedState } from '@robonen/primitives';
import { CheckboxIndicator, CheckboxRoot } from '@robonen/primitives';
import { computed, ref } from 'vue';
const ingredients = [
{ id: 'cheese', label: 'Extra cheese' },
{ id: 'mushrooms', label: 'Mushrooms' },
{ id: 'olives', label: 'Olives' },
];
const selected = ref<Record<string, boolean>>({
cheese: true,
mushrooms: false,
olives: false,
});
const checkedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
// Parent reflects the children: checked when all, unchecked when none, else indeterminate.
const allChecked = computed<CheckedState>(() => {
if (checkedCount.value === 0) return false;
if (checkedCount.value === ingredients.length) return true;
return 'indeterminate';
});
function toggleAll(next: CheckedState) {
const value = next === true;
for (const item of ingredients) selected.value[item.id] = value;
}
const acceptedTerms = ref(false);
</script>
<template>
<div class="flex flex-col gap-6 p-6 max-w-sm bg-bg text-fg border border-border rounded-xl">
<fieldset class="flex flex-col gap-3 m-0 p-0 border-0">
<legend class="text-sm font-semibold text-fg">
Toppings
</legend>
<label class="flex items-center gap-3 cursor-pointer select-none">
<CheckboxRoot
:checked="allChecked"
class="grid place-items-center w-5 h-5 rounded-md border border-border bg-bg-inset outline-none transition-colors data-[state=checked]:bg-accent data-[state=indeterminate]:bg-accent data-[state=checked]:border-accent data-[state=indeterminate]:border-accent focus-visible:ring-2 focus-visible:ring-ring"
@checked-change="toggleAll"
>
<CheckboxIndicator v-slot="{ checked }" class="text-accent-fg">
<svg v-if="checked === 'indeterminate'" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6h7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm font-medium">Select all</span>
<span class="ml-auto text-xs text-fg-subtle">{{ checkedCount }}/{{ ingredients.length }}</span>
</label>
<div class="flex flex-col gap-2 pl-2 border-l border-border">
<label v-for="item in ingredients" :key="item.id" class="flex items-center gap-3 cursor-pointer select-none">
<CheckboxRoot
v-model:checked="selected[item.id]"
class="grid place-items-center w-5 h-5 rounded-md border border-border bg-bg-inset outline-none transition-colors data-[state=checked]:bg-accent data-[state=checked]:border-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<CheckboxIndicator class="text-accent-fg">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm text-fg">{{ item.label }}</span>
</label>
</div>
</fieldset>
<label class="flex items-start gap-3 cursor-pointer select-none">
<CheckboxRoot
v-model:checked="acceptedTerms"
required
class="grid place-items-center w-5 h-5 mt-0.5 rounded-md border border-border bg-bg-inset outline-none transition-colors data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500 dark:data-[state=checked]:bg-emerald-400 dark:data-[state=checked]:border-emerald-400 focus-visible:ring-2 focus-visible:ring-ring"
>
<CheckboxIndicator class="text-white dark:text-bg">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</CheckboxIndicator>
</CheckboxRoot>
<span class="text-sm text-fg-muted">I accept the terms and conditions</span>
</label>
<p
class="text-xs"
:class="acceptedTerms ? 'text-emerald-600 dark:text-emerald-400' : 'text-fg-subtle'"
>
{{ acceptedTerms ? 'Ready to submit' : 'Please accept the terms to continue' }}
</p>
</div>
</template>
@@ -0,0 +1,15 @@
export { default as CheckboxGroupRoot } from './CheckboxGroupRoot.vue';
export { default as CheckboxIndicator } from './CheckboxIndicator.vue';
export { default as CheckboxRoot } from './CheckboxRoot.vue';
export type { AcceptableValue, CheckedState } from './context';
export {
provideCheckboxContext,
provideCheckboxGroupContext,
useCheckboxContext,
useCheckboxGroupContext,
} from './context';
export type { CheckboxContext, CheckboxGroupContext } from './context';
export type { CheckboxIndicatorProps } from './CheckboxIndicator.vue';
export type { CheckboxGroupRootEmits, CheckboxGroupRootProps } from './CheckboxGroupRoot.vue';
export type { CheckboxRootEmits, CheckboxRootProps } from './CheckboxRoot.vue';
export { getState, isIndeterminate } from './utils';
@@ -0,0 +1,18 @@
import type { CheckedState } from './context';
/**
* Shared checkbox state helpers, used by both `CheckboxRoot` and
* `CheckboxIndicator` so the indeterminate/`data-state` mapping cannot drift
* between the two parts.
*/
/** Narrows a {@link CheckedState} to the `'indeterminate'` literal. */
export function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
return checked === 'indeterminate';
}
/** Canonical `data-state` value for a {@link CheckedState}. */
export function getState(checked: CheckedState): 'checked' | 'unchecked' | 'indeterminate' {
if (isIndeterminate(checked)) return 'indeterminate';
return checked ? 'checked' : 'unchecked';
}