feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,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';
|
||||
}
|
||||
Reference in New Issue
Block a user