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,92 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { ToggleGroupValue } from './context';
/**
* A single toggle button within a `ToggleGroupRoot`, rendered as a native
* `<button>`. Clicking or pressing Space toggles its `value` on or off; it
* reflects its pressed state via `data-state` (`on`/`off`) and participates in
* the group's roving tab order. Must be used inside a `ToggleGroupRoot`, whose
* `type` determines whether selecting it deselects its siblings. The `value`
* may be any structural value (string, number, bigint, `null`, or a plain
* object), compared with deep equality.
*/
export interface ToggleGroupItemProps extends PrimitiveProps {
value: ToggleGroupValue;
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCollectionInjector } from '../../utilities/collection';
import { useForwardExpose } from '@robonen/vue';
import { useToggleGroupContext } from './context';
const { value, disabled = false, as = 'button' } = defineProps<ToggleGroupItemProps>();
const ctx = useToggleGroupContext();
const { CollectionItem } = useCollectionInjector();
const { forwardRef, currentElement } = useForwardExpose();
const isDisabled = computed(() => ctx.disabled.value || disabled);
const isPressed = computed(() => ctx.isPressed(value));
// Roving focus: only one enabled item is the tabstop (first pressed, else first
// enabled). The tab-stop element is computed once in the Root from reactive
// pressed state and shared via context, so each item only does an O(1) identity
// check here instead of re-scanning the whole list and reading DOM attributes.
const isTabStop = computed(() => {
if (!ctx.rovingFocus.value || isDisabled.value) return !ctx.rovingFocus.value && !isDisabled.value;
return currentElement.value === ctx.tabStopElement.value;
});
function onClick(): void {
if (isDisabled.value) return;
ctx.toggle(value);
}
function onKeyDown(event: KeyboardEvent): void {
if (!currentElement.value) return;
// A native <button> activates on Space/Enter itself; synthesize activation
// for non-button hosts so they still toggle from the keyboard.
if (as !== 'button' && (event.key === ' ' || event.key === 'Enter')) {
event.preventDefault();
onClick();
return;
}
ctx.onItemKeyDown(event, currentElement.value);
}
// Safari does not focus buttons on click; focus on mousedown so the roving
// tab-stop and :focus-visible styling stay consistent across browsers.
function onMouseDown(event: MouseEvent): void {
if (isDisabled.value) {
event.preventDefault();
return;
}
currentElement.value?.focus();
}
</script>
<template>
<CollectionItem :value="value">
<Primitive
:as="as"
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
:role="ctx.type.value === 'single' ? 'radio' : undefined"
:aria-pressed="ctx.type.value === 'multiple' ? isPressed : undefined"
:aria-checked="ctx.type.value === 'single' ? isPressed : undefined"
:aria-disabled="isDisabled || undefined"
:data-state="isPressed ? 'on' : 'off'"
:data-disabled="isDisabled ? '' : undefined"
:tabindex="isDisabled ? -1 : (ctx.rovingFocus.value ? (isTabStop ? 0 : -1) : 0)"
:disabled="isDisabled || undefined"
@click="onClick"
@keydown="onKeyDown"
@mousedown="onMouseDown"
>
<slot :pressed="isPressed" />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,260 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
import type { RovingDirection } from '../../internal/utils/roving-focus';
import type { ToggleGroupType, ToggleGroupValue } from './context';
/**
* A set of two-state toggle buttons that behave as one control, with full
* keyboard roving focus (arrow keys move, Home/End jump to ends, PageUp/PageDown
* jump to first/last). Set `type` to `'single'` for mutually exclusive options
* (like a segmented control) or `'multiple'` to let several be pressed at once
* (like a text-formatting bar). When `type` is omitted it is inferred from the
* value shape: an array value implies `'multiple'`, otherwise `'single'`.
* This is the container and state owner: it tracks the pressed value(s)
* (controlled via `v-model` or uncontrolled via `defaultValue`) and provides
* context to each `ToggleGroupItem`. With a `name`, the selected value(s) are
* also bridged into native form submission. Reach for it to group related
* toggles such as text alignment, view modes, or formatting options.
*/
export interface ToggleGroupRootProps extends PrimitiveProps {
/**
* Whether one (`'single'`) or several (`'multiple'`) items can be pressed.
* When omitted, inferred from the value shape (array → `'multiple'`).
*/
type?: ToggleGroupType;
defaultValue?: ToggleGroupValue | ToggleGroupValue[];
disabled?: boolean;
orientation?: 'horizontal' | 'vertical';
/**
* Reading direction. When omitted, inherits from a `ConfigProvider` (or LTR).
*/
dir?: RovingDirection;
loop?: boolean;
rovingFocus?: boolean;
/** Native input name for form submission. When set, a hidden input mirrors the value. */
name?: string;
/** Mark the field as required for native form validation. */
required?: boolean;
}
export interface ToggleGroupRootEmits {
valueChange: [value: ToggleGroupValue | ToggleGroupValue[]];
}
</script>
<script setup lang="ts">
import { computed, toRef, watchEffect } from 'vue';
import { isEqual } from '@robonen/stdlib';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { useCollectionProvider } from '../../utilities/collection';
import { useForwardExpose } from '@robonen/vue';
import { useDirection } from '../../utilities/config-provider';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
import { Primitive } from '../../internal/primitive';
import { provideToggleGroupContext } from './context';
const {
type: explicitType,
disabled = false,
orientation = 'horizontal',
dir,
loop = true,
rovingFocus = true,
defaultValue,
name,
required = false,
as = 'div',
} = defineProps<ToggleGroupRootProps>();
const { forwardRef, currentElement } = useForwardExpose();
const emit = defineEmits<ToggleGroupRootEmits>();
const model = defineModel<ToggleGroupValue | ToggleGroupValue[] | undefined>({ default: undefined });
// Resolve the reading direction, inheriting from a ConfigProvider when `dir` is
// omitted (per-component prop always wins).
const direction = useDirection(() => dir);
// Infer single/multiple from the value shape when `type` is not explicit:
// an array value (model or default) implies `'multiple'`, otherwise `'single'`.
// An explicit `type` always wins.
const resolvedType = computed<ToggleGroupType>(() => {
if (explicitType) return explicitType;
const sample = model.value !== undefined ? model.value : defaultValue;
return Array.isArray(sample) ? 'multiple' : 'single';
});
// Dev-only coherence check: an explicit `type` that disagrees with the value
// shape is surfaced as a warning (the explicit `type` is still honored).
if (__DEV__) {
watchEffect(() => {
const sample = model.value !== undefined ? model.value : defaultValue;
if (explicitType === undefined || sample === undefined)
return;
const inferred: ToggleGroupType = Array.isArray(sample) ? 'multiple' : 'single';
if (explicitType !== inferred) {
console.warn(
`[ToggleGroup] "type" is "${explicitType}" but the provided value is ${
Array.isArray(sample) ? 'an array' : 'not an array'
}. Following the explicit "type"; pass a ${
explicitType === 'single' ? 'scalar' : 'array'
} value to silence this warning.`,
);
}
});
}
function normalize(v: ToggleGroupValue | ToggleGroupValue[] | undefined): ToggleGroupValue[] {
if (v === undefined) return [];
if (Array.isArray(v)) return v.slice();
return [v];
}
// Seed the uncontrolled default once; defineModel owns state thereafter.
if (model.value === undefined && defaultValue !== undefined)
model.value = defaultValue;
// Normalized array view of the public model (value | value[] | undefined).
const localValue = computed<ToggleGroupValue[]>(() => normalize(model.value));
function emitValue(next: ToggleGroupValue[]): void {
if (resolvedType.value === 'single') {
const v = next.length > 0 ? next[0]! : undefined;
model.value = v;
emit('valueChange', v ?? '');
}
else {
model.value = next;
emit('valueChange', next);
}
}
function toggle(v: ToggleGroupValue): void {
if (disabled) return;
if (resolvedType.value === 'single') {
if (localValue.value.some(x => isEqual(x, v))) emitValue([]);
else emitValue([v]);
}
else if (localValue.value.some(x => isEqual(x, v))) {
emitValue(localValue.value.filter(x => !isEqual(x, v)));
}
else {
emitValue([...localValue.value, v]);
}
}
function isPressed(v: ToggleGroupValue): boolean {
return localValue.value.some(x => isEqual(x, v));
}
// DOM-order items via Collection primitive — survives v-for reorders.
const { getItems, CollectionSlot } = useCollectionProvider<ToggleGroupValue>();
const items = computed(() => getItems(true).map(i => i.ref));
// The single roving tab stop, computed once per items/value change in the Root
// (first pressed enabled item, else first enabled item) so each item derives
// `isTabStop` via an O(1) identity check instead of independently scanning and
// reading DOM attributes across the whole list (O(N²) on every settle).
const tabStopElement = computed<HTMLElement | undefined>(() => {
const enabled = getItems(false);
if (enabled.length === 0) return undefined;
for (const item of enabled) {
if (item.value !== undefined && isPressed(item.value)) return item.ref;
}
return enabled[0]!.ref;
});
function onItemKeyDown(event: KeyboardEvent, el: HTMLElement): void {
if (!rovingFocus) return;
// Don't hijack focus for modifier-key chords (Ctrl/Meta/Alt navigation, etc.).
if (event.ctrlKey || event.metaKey || event.altKey) return;
const enabled = items.value.filter(x => !x.hasAttribute('data-disabled'));
if (enabled.length === 0) return;
const current = enabled.indexOf(el);
// PageUp/PageDown jump to first/last (handled inline; the shared util covers
// arrows + Home/End only).
if (event.key === 'PageUp') {
event.preventDefault();
enabled[0]!.focus();
return;
}
if (event.key === 'PageDown') {
event.preventDefault();
enabled[enabled.length - 1]!.focus();
return;
}
const action = rovingKeyToAction(event, { orientation, dir: direction.value, loop });
if (!action) return;
event.preventDefault();
if (action.absolute === 'home') {
enabled[0]!.focus();
return;
}
if (action.absolute === 'end') {
enabled[enabled.length - 1]!.focus();
return;
}
const nextIdx = resolveNextIndex(current === -1 ? 0 : current, action.delta, enabled.length, loop);
enabled[nextIdx]!.focus();
}
// Whether the group lives inside a real <form>; SSR defaults to bridging so the
// value still submits server-side. Mirrors the package-wide form-control check.
const isFormControl = computed(() => {
const el = currentElement.value;
return typeof document === 'undefined' ? true : (!!el && !!el.closest('form'));
});
// The value submitted with the form: a scalar in single mode, an array in
// multiple mode (so VisuallyHiddenInput encodes name[0], name[1], …).
const submittedValue = computed<ToggleGroupValue | ToggleGroupValue[]>(() =>
resolvedType.value === 'single'
? (localValue.value.length > 0 ? localValue.value[0]! : '')
: localValue.value,
);
provideToggleGroupContext({
type: resolvedType,
value: localValue,
toggle,
isPressed,
orientation: toRef(() => orientation),
direction,
loop: toRef(() => loop),
disabled: toRef(() => disabled),
rovingFocus: toRef(() => rovingFocus),
items,
tabStopElement,
onItemKeyDown,
});
</script>
<template>
<CollectionSlot>
<Primitive
:ref="forwardRef"
:as="as"
:role="resolvedType === 'single' ? 'radiogroup' : 'group'"
:aria-orientation="orientation"
:aria-disabled="disabled || undefined"
:dir="direction"
:data-orientation="orientation"
:data-disabled="disabled ? '' : undefined"
>
<slot :value="localValue" :model-value="model" />
<VisuallyHiddenInput
v-if="isFormControl && name"
:name="name"
:value="submittedValue"
:required="required"
:disabled="disabled"
/>
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,104 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { ToggleGroupItem, ToggleGroupRoot } from '../index';
function mountGroup(opts: Record<string, unknown> = {}) {
const model = ref<string | string[] | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ToggleGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: string | string[] | undefined) => { model.value = v; },
...opts,
}, {
default: () => [
h(ToggleGroupItem, { value: 'a', id: 'a' }, { default: () => 'A' }),
h(ToggleGroupItem, { value: 'b', id: 'b' }, { default: () => 'B' }),
h(ToggleGroupItem, { value: 'c', id: 'c', disabled: true }, { default: () => 'C' }),
],
}),
});
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('ToggleGroup (single)', () => {
it('role="radiogroup" and items role="radio"', () => {
const { wrapper } = mountGroup();
expect(wrapper.element.getAttribute('role')).toBe('radiogroup');
expect(document.querySelectorAll('[role="radio"]')).toHaveLength(3);
wrapper.unmount();
});
it('click selects; clicking selected deselects', 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');
a.click();
await nextTick();
expect(model.value).toBeUndefined();
wrapper.unmount();
});
it('selecting another replaces', async () => {
const { wrapper, model } = mountGroup({ defaultValue: 'a' });
await nextTick();
const b = document.querySelector<HTMLButtonElement>('#b')!;
b.click();
await nextTick();
expect(model.value).toBe('b');
wrapper.unmount();
});
it('ArrowRight cycles focus (roving)', async () => {
const { wrapper } = mountGroup();
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
const b = document.querySelector<HTMLButtonElement>('#b')!;
a.focus();
press(a, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(b);
wrapper.unmount();
});
it('disabled item aria-disabled=true, not toggleable', 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();
});
});
describe('ToggleGroup (multiple)', () => {
it('role="group" and items with aria-pressed', async () => {
const { wrapper, model } = mountGroup({ type: 'multiple' });
await nextTick();
expect(wrapper.element.getAttribute('role')).toBe('group');
const a = document.querySelector<HTMLButtonElement>('#a')!;
const b = document.querySelector<HTMLButtonElement>('#b')!;
a.click();
await nextTick();
expect(a.getAttribute('aria-pressed')).toBe('true');
expect(model.value).toEqual(['a']);
b.click();
await nextTick();
expect(model.value).toEqual(['a', 'b']);
// Toggle off a:
a.click();
await nextTick();
expect(model.value).toEqual(['b']);
wrapper.unmount();
});
});
@@ -0,0 +1,327 @@
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 { ToggleGroupItem, ToggleGroupRoot } 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?.();
},
});
type AnyVal = unknown;
function press(el: Element, key: string, init: KeyboardEventInit = {}): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}
const mounted: Array<{ unmount: () => void }> = [];
afterEach(() => {
while (mounted.length) mounted.pop()!.unmount();
});
function mountGroup(opts: Record<string, unknown> = {}, items?: Array<Record<string, unknown>>) {
const { modelValue: initial, ...rootProps } = opts;
const model = ref<AnyVal>(initial);
const list = items ?? [
{ value: 'a', id: 'a' },
{ value: 'b', id: 'b' },
{ value: 'c', id: 'c', disabled: true },
{ value: 'd', id: 'd' },
];
const Harness = defineComponent({
setup: () => () => h(ToggleGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: AnyVal) => { model.value = v; },
...rootProps,
}, {
default: () => list.map(p => h(ToggleGroupItem, p, { default: () => String(p.value) })),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
mounted.push(wrapper);
return { wrapper, model };
}
describe('ToggleGroup — type inference', () => {
it('infers multiple when defaultValue is an array', async () => {
const { wrapper } = mountGroup({ defaultValue: ['a'] });
await nextTick();
expect(wrapper.element.getAttribute('role')).toBe('group');
expect(document.querySelector('#a')!.getAttribute('aria-pressed')).toBe('true');
expect(document.querySelector('#a')!.getAttribute('role')).toBeNull();
});
it('infers single when defaultValue is a scalar', async () => {
const { wrapper } = mountGroup({ defaultValue: 'a' });
await nextTick();
expect(wrapper.element.getAttribute('role')).toBe('radiogroup');
expect(document.querySelector('#a')!.getAttribute('role')).toBe('radio');
});
it('infers multiple from an array modelValue', async () => {
const { wrapper } = mountGroup({ modelValue: ['a', 'b'] });
await nextTick();
expect(wrapper.element.getAttribute('role')).toBe('group');
});
it('explicit type wins and warns on conflict (dev)', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { wrapper } = mountGroup({ type: 'single', defaultValue: ['a'] });
await nextTick();
// Explicit type honored despite array value.
expect(wrapper.element.getAttribute('role')).toBe('radiogroup');
expect(spy).toHaveBeenCalled();
expect(spy.mock.calls[0]![0]).toContain('ToggleGroup');
spy.mockRestore();
});
});
describe('ToggleGroup — deep equality values', () => {
it('toggles number values via deep equality', async () => {
const { model } = mountGroup({ type: 'single' }, [
{ value: 1, id: 'n1' },
{ value: 2, id: 'n2' },
]);
await nextTick();
const n1 = document.querySelector<HTMLButtonElement>('#n1')!;
n1.click();
await nextTick();
expect(model.value).toBe(1);
expect(n1.getAttribute('aria-checked')).toBe('true');
n1.click();
await nextTick();
expect(model.value).toBeUndefined();
});
it('toggles object values structurally (multiple)', async () => {
const v1 = { k: 1 };
const v2 = { k: 2 };
const { model } = mountGroup({ type: 'multiple', modelValue: [] }, [
{ value: v1, id: 'o1' },
{ value: v2, id: 'o2' },
]);
await nextTick();
const o1 = document.querySelector<HTMLButtonElement>('#o1')!;
o1.click();
await nextTick();
expect(model.value).toEqual([{ k: 1 }]);
expect(o1.getAttribute('aria-pressed')).toBe('true');
// Toggling off the same structural value removes it.
o1.click();
await nextTick();
expect(model.value).toEqual([]);
});
});
describe('ToggleGroup — controlled reset', () => {
it('external reset to undefined clears the pressed value', async () => {
const { wrapper, model } = mountGroup({ type: 'single', modelValue: 'a' });
await nextTick();
expect(document.querySelector('#a')!.getAttribute('aria-checked')).toBe('true');
model.value = undefined;
await wrapper.vm.$nextTick();
await nextTick();
expect(document.querySelector('#a')!.getAttribute('aria-checked')).toBe('false');
});
});
describe('ToggleGroup — keyboard extensions', () => {
it('PageUp jumps to first enabled, PageDown to last enabled', async () => {
mountGroup();
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
const b = document.querySelector<HTMLButtonElement>('#b')!;
const d = document.querySelector<HTMLButtonElement>('#d')!;
b.focus();
press(b, 'PageUp');
await nextTick();
expect(document.activeElement).toBe(a);
press(a, 'PageDown');
await nextTick();
expect(document.activeElement).toBe(d);
});
it('modifier-key chords do not move focus', async () => {
mountGroup();
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
a.focus();
press(a, 'ArrowRight', { ctrlKey: true });
await nextTick();
expect(document.activeElement).toBe(a);
press(a, 'ArrowRight', { metaKey: true });
await nextTick();
expect(document.activeElement).toBe(a);
press(a, 'ArrowRight', { altKey: true });
await nextTick();
expect(document.activeElement).toBe(a);
});
it('rovingFocus=false disables arrow navigation', async () => {
mountGroup({ rovingFocus: false });
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
a.focus();
press(a, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(a);
});
});
describe('ToggleGroup — non-button host activation', () => {
it('Space/Enter toggle a non-button item', async () => {
const { model } = mountGroup({ type: 'single' }, [
{ value: 'a', id: 'a', as: 'div' },
{ value: 'b', id: 'b', as: 'div' },
]);
await nextTick();
const a = document.querySelector<HTMLElement>('#a')!;
a.focus();
press(a, ' ');
await nextTick();
expect(model.value).toBe('a');
const b = document.querySelector<HTMLElement>('#b')!;
b.focus();
press(b, 'Enter');
await nextTick();
expect(model.value).toBe('b');
});
});
describe('ToggleGroup — mousedown focus', () => {
it('focuses the item on mousedown', async () => {
mountGroup();
await nextTick();
const b = document.querySelector<HTMLButtonElement>('#b')!;
b.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await nextTick();
expect(document.activeElement).toBe(b);
});
it('does not focus a disabled item on mousedown', async () => {
mountGroup();
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
a.focus();
const c = document.querySelector<HTMLButtonElement>('#c')!;
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
c.dispatchEvent(event);
await nextTick();
expect(document.activeElement).not.toBe(c);
expect(event.defaultPrevented).toBe(true);
});
});
describe('ToggleGroup — direction inheritance', () => {
it('inherits rtl from ConfigProvider (ArrowRight goes backwards)', async () => {
const model = ref<AnyVal>(undefined);
const Harness = defineComponent({
setup: () => () => h(ConfigProvider, { dir: 'rtl' }, {
default: () => h(ToggleGroupRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: AnyVal) => { model.value = v; },
}, {
default: () => [
h(ToggleGroupItem, { value: 'a', id: 'a' }, { default: () => 'A' }),
h(ToggleGroupItem, { value: 'b', id: 'b' }, { default: () => 'B' }),
],
}),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
mounted.push(wrapper);
await nextTick();
const a = document.querySelector<HTMLButtonElement>('#a')!;
const b = document.querySelector<HTMLButtonElement>('#b')!;
expect(wrapper.find('[role="radiogroup"]').attributes('dir')).toBe('rtl');
b.focus();
// In rtl, ArrowRight moves toward the start (b -> a).
press(b, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(a);
});
it('per-component dir prop overrides ConfigProvider', async () => {
const model = ref<AnyVal>(undefined);
const Harness = defineComponent({
setup: () => () => h(ConfigProvider, { dir: 'rtl' }, {
default: () => h(ToggleGroupRoot, {
dir: 'ltr',
modelValue: model.value,
'onUpdate:modelValue': (v: AnyVal) => { model.value = v; },
}, {
default: () => [h(ToggleGroupItem, { value: 'a', id: 'a' }, { default: () => 'A' })],
}),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
mounted.push(wrapper);
await nextTick();
expect(wrapper.find('[role="radiogroup"]').attributes('dir')).toBe('ltr');
});
});
describe('ToggleGroup — form integration', () => {
it('renders a hidden input inside a form (single)', async () => {
const model = ref<AnyVal>('a');
const Harness = defineComponent({
setup: () => () => h('form', {}, [
h(ToggleGroupRoot, {
type: 'single',
name: 'align',
modelValue: model.value,
'onUpdate:modelValue': (v: AnyVal) => { model.value = v; },
}, {
default: () => [
h(ToggleGroupItem, { value: 'a', id: 'a' }, { default: () => 'A' }),
h(ToggleGroupItem, { value: 'b', id: 'b' }, { default: () => 'B' }),
],
}),
]),
});
const wrapper = mount(Harness, { attachTo: document.body });
mounted.push(wrapper);
await nextTick();
const hidden = document.querySelector<HTMLInputElement>('form input[name="align"]');
expect(hidden).not.toBeNull();
expect(hidden!.value).toBe('a');
});
it('encodes array names for multiple', async () => {
const model = ref<AnyVal>(['a', 'b']);
const Harness = defineComponent({
setup: () => () => h('form', {}, [
h(ToggleGroupRoot, {
type: 'multiple',
name: 'marks',
modelValue: model.value,
'onUpdate:modelValue': (v: AnyVal) => { model.value = v; },
}, {
default: () => [
h(ToggleGroupItem, { value: 'a', id: 'a' }, { default: () => 'A' }),
h(ToggleGroupItem, { value: 'b', id: 'b' }, { default: () => 'B' }),
],
}),
]),
});
const wrapper = mount(Harness, { attachTo: document.body });
mounted.push(wrapper);
await nextTick();
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('form input[name^="marks"]'));
const names = inputs.map(i => i.getAttribute('name')).sort();
expect(names).toEqual(['marks[0]', 'marks[1]']);
expect(inputs.map(i => i.value).sort()).toEqual(['a', 'b']);
});
it('does not render a hidden input without a name', async () => {
mountGroup({ type: 'single', defaultValue: 'a' });
await nextTick();
expect(document.querySelector('input[type="hidden"]')).toBeNull();
});
});
@@ -0,0 +1,39 @@
import type { ComputedRef, Ref } from 'vue';
import type { RovingDirection, RovingOrientation } from '../../internal/utils/roving-focus';
import { useContextFactory } from '@robonen/vue';
export type ToggleGroupType = 'single' | 'multiple';
/**
* An individual item value. Beyond plain strings, the group accepts numbers,
* bigints, `null`, and plain objects, comparing them with structural deep
* equality so object/number values toggle correctly. `boolean` is intentionally
* excluded so the `modelValue` prop is not coerced by Vue's boolean-prop casting.
*/
export type ToggleGroupValue = string | number | bigint | null | Record<string, unknown>;
export interface ToggleGroupContext {
type: Ref<ToggleGroupType>;
value: Ref<ToggleGroupValue[]>;
toggle: (v: ToggleGroupValue) => void;
isPressed: (v: ToggleGroupValue) => boolean;
orientation: Ref<RovingOrientation>;
direction: Ref<RovingDirection>;
loop: Ref<boolean>;
disabled: Ref<boolean>;
rovingFocus: Ref<boolean>;
/** DOM-ordered items, sourced from the internal Collection. */
items: ComputedRef<HTMLElement[]>;
/**
* The single enabled item that holds the roving tab stop (first pressed, else
* first enabled), computed once in the Root from reactive pressed state. Each
* item compares its own element against this to derive `isTabStop` in O(1).
*/
tabStopElement: ComputedRef<HTMLElement | undefined>;
onItemKeyDown: (event: KeyboardEvent, el: HTMLElement) => void;
}
const ctx = useContextFactory<ToggleGroupContext>('ToggleGroupContext');
export const provideToggleGroupContext = ctx.provide;
export const useToggleGroupContext = ctx.inject;
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ToggleGroupItem, ToggleGroupRoot } from '@robonen/primitives';
import { ref } from 'vue';
// `type="multiple"` → v-model is a string[] of every pressed value.
const formatting = ref<string[]>(['bold']);
const marks = [
{ value: 'bold', label: 'B', title: 'Bold', class: 'font-bold' },
{ value: 'italic', label: 'I', title: 'Italic', class: 'italic' },
{ value: 'underline', label: 'U', title: 'Underline', class: 'underline' },
];
// `type="single"` → v-model is a single string (empty when nothing is pressed).
const align = ref('left');
const alignments = [
{ value: 'left', title: 'Align left', label: 'L' },
{ value: 'center', title: 'Align center', label: 'C' },
{ value: 'right', title: 'Align right', label: 'R' },
];
</script>
<template>
<div class="flex flex-col gap-5 p-6 max-w-md bg-bg text-fg border border-border rounded-xl">
<div class="flex flex-col gap-2">
<span class="text-xs font-medium text-fg-muted">Text formatting (multiple)</span>
<ToggleGroupRoot
v-model="formatting"
type="multiple"
class="inline-flex w-fit gap-1 p-1 rounded-lg bg-bg-inset border border-border"
>
<ToggleGroupItem
v-for="m in marks"
:key="m.value"
:value="m.value"
:title="m.title"
class="grid place-items-center w-8 h-8 rounded-md text-sm select-none outline-none transition-colors text-fg-muted hover:bg-bg-subtle data-[state=on]:bg-accent data-[state=on]:text-accent-fg focus-visible:ring-2 focus-visible:ring-ring"
:class="m.class"
>
{{ m.label }}
</ToggleGroupItem>
</ToggleGroupRoot>
</div>
<div class="flex flex-col gap-2">
<span class="text-xs font-medium text-fg-muted">Alignment (single)</span>
<ToggleGroupRoot
v-model="align"
type="single"
class="inline-flex w-fit gap-1 p-1 rounded-lg bg-bg-inset border border-border"
>
<ToggleGroupItem
v-for="a in alignments"
:key="a.value"
:value="a.value"
:title="a.title"
class="grid place-items-center w-8 h-8 rounded-md text-sm font-medium select-none outline-none transition-colors text-fg-muted hover:bg-bg-subtle data-[state=on]:bg-accent data-[state=on]:text-accent-fg focus-visible:ring-2 focus-visible:ring-ring"
>
{{ a.label }}
</ToggleGroupItem>
</ToggleGroupRoot>
</div>
<p
class="px-3 py-6 rounded-lg bg-bg-subtle border border-border text-sm"
:class="[
{ 'font-bold': formatting.includes('bold') },
{ 'italic': formatting.includes('italic') },
{ 'underline': formatting.includes('underline') },
align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left',
]"
>
The quick brown fox.
</p>
</div>
</template>
@@ -0,0 +1,5 @@
export { default as ToggleGroupItem } from './ToggleGroupItem.vue';
export { default as ToggleGroupRoot } from './ToggleGroupRoot.vue';
export type { ToggleGroupType, ToggleGroupValue } from './context';
export type { ToggleGroupItemProps } from './ToggleGroupItem.vue';
export type { ToggleGroupRootEmits, ToggleGroupRootProps } from './ToggleGroupRoot.vue';