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,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';
|
||||
Reference in New Issue
Block a user