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,135 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/** Canonical `data-state` value reflected on the host element. */
|
||||
export type ToggleState = 'on' | 'off';
|
||||
|
||||
/** Events emitted by `Toggle`. */
|
||||
export interface ToggleEmits {
|
||||
/** Fired when the pressed state changes. Backs `v-model:pressed`. */
|
||||
'update:pressed': [pressed: boolean];
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-state button that can be pressed on or off, like a bold or italic
|
||||
* control in a text editor toolbar. Renders a native `<button>` by default
|
||||
* (handling Space/Enter and the `disabled` attribute for you), exposes
|
||||
* `aria-pressed`, `data-state` (`on`/`off`), and `data-disabled` for styling,
|
||||
* and works uncontrolled (`defaultPressed`) or controlled via `v-model:pressed`.
|
||||
* When rendered as a non-button element it synthesizes keyboard activation and
|
||||
* the appropriate `tabindex`/`aria-disabled`. Provide `name` to mirror the
|
||||
* pressed state into a hidden checkbox so it participates in native form
|
||||
* submission (suppressed automatically when nested in a `ToggleGroup`, which
|
||||
* owns the submitted value). Use it for a single standalone toggle; for a set
|
||||
* of mutually related toggles use `ToggleGroup` instead.
|
||||
*/
|
||||
export interface ToggleProps extends PrimitiveProps {
|
||||
|
||||
/** Uncontrolled initial pressed state. */
|
||||
defaultPressed?: boolean;
|
||||
/** Disables the toggle. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Name for the hidden form input. When provided (and the toggle lives inside
|
||||
* a `<form>` and is not nested in a `ToggleGroup`), a hidden checkbox mirrors
|
||||
* the pressed state so it is submitted with the owning form.
|
||||
*/
|
||||
name?: string;
|
||||
/** Marks the control as required for form submission (native validation). */
|
||||
required?: boolean;
|
||||
/** Value submitted by the hidden form input when pressed. Defaults to `'on'`. */
|
||||
value?: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
|
||||
import { useToggleGroupContext } from '../toggle-group/context';
|
||||
|
||||
const {
|
||||
defaultPressed = false,
|
||||
disabled = false,
|
||||
as = 'button',
|
||||
name,
|
||||
required = false,
|
||||
value = 'on',
|
||||
} = defineProps<ToggleProps>();
|
||||
|
||||
defineEmits<ToggleEmits>();
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
// A standalone Toggle nested inside a ToggleGroup must not also submit its own
|
||||
// value — the group owns the form value. `null` fallback keeps this optional.
|
||||
const toggleGroupContext = useToggleGroupContext(null);
|
||||
|
||||
const localPressed = ref<boolean>(defaultPressed);
|
||||
|
||||
const pressed = defineModel<boolean>('pressed', {
|
||||
default: undefined,
|
||||
get: v => v ?? localPressed.value,
|
||||
set: (v) => {
|
||||
localPressed.value = v;
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
const dataState = computed<ToggleState>(() => (pressed.value ? 'on' : 'off'));
|
||||
|
||||
// Whether the toggle lives inside a real <form>; SSR defaults to bridging so
|
||||
// the value still submits server-side. Mirrors the package-wide check.
|
||||
const isFormControl = computed(() => {
|
||||
const el = currentElement.value;
|
||||
return typeof document === 'undefined' ? true : (!!el && !!el.closest('form'));
|
||||
});
|
||||
|
||||
const renderHiddenInput = computed(() =>
|
||||
!!name && !toggleGroupContext && isFormControl.value);
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
pressed.value = !pressed.value;
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
toggle();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
// <button> handles Space/Enter natively; synthesize only for non-button hosts.
|
||||
if (as === 'button') return;
|
||||
if (event.key !== ' ' && event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
:tabindex="as === 'button' ? undefined : (disabled ? -1 : 0)"
|
||||
:aria-pressed="pressed"
|
||||
:aria-disabled="as === 'button' ? undefined : (disabled ? true : undefined)"
|
||||
:data-state="dataState"
|
||||
:data-disabled="disabled ? '' : undefined"
|
||||
:disabled="as === 'button' ? disabled : undefined"
|
||||
@click="onClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<slot :pressed="pressed" :disabled="disabled" :state="dataState" />
|
||||
|
||||
<VisuallyHiddenInput
|
||||
v-if="renderHiddenInput"
|
||||
:name="name!"
|
||||
:value="pressed ? value : ''"
|
||||
:checked="pressed"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,237 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computed, defineComponent, h, nextTick, ref, shallowRef } from 'vue';
|
||||
import { Toggle } from '../index';
|
||||
import { provideToggleGroupContext } from '../../toggle-group/context';
|
||||
|
||||
describe('Toggle', () => {
|
||||
it('defaults to unpressed and toggles on click', async () => {
|
||||
const wrapper = mount(Toggle);
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('false');
|
||||
expect(wrapper.attributes('data-state')).toBe('off');
|
||||
await wrapper.trigger('click');
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('true');
|
||||
expect(wrapper.attributes('data-state')).toBe('on');
|
||||
});
|
||||
|
||||
it('respects defaultPressed', () => {
|
||||
const wrapper = mount(Toggle, { props: { defaultPressed: true } });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('syncs with v-model', async () => {
|
||||
const pressed = ref(false);
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { pressed: pressed.value, 'onUpdate:pressed': (v: boolean) => { pressed.value = v; } },
|
||||
});
|
||||
await wrapper.trigger('click');
|
||||
expect(pressed.value).toBe(true);
|
||||
});
|
||||
|
||||
it('does not toggle when disabled', async () => {
|
||||
const wrapper = mount(Toggle, { props: { disabled: true } });
|
||||
await wrapper.trigger('click');
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('false');
|
||||
expect(wrapper.attributes('data-disabled')).toBe('');
|
||||
});
|
||||
|
||||
it('sets type="button" on button element', () => {
|
||||
const wrapper = mount(Toggle);
|
||||
expect(wrapper.attributes('type')).toBe('button');
|
||||
});
|
||||
|
||||
describe('keyboard activation', () => {
|
||||
it('toggles on Space when as is not a button', async () => {
|
||||
const wrapper = mount(Toggle, { props: { as: 'div' } });
|
||||
await wrapper.trigger('keydown', { key: ' ' });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('true');
|
||||
await wrapper.trigger('keydown', { key: ' ' });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('toggles on Enter when as is not a button', async () => {
|
||||
const wrapper = mount(Toggle, { props: { as: 'div' } });
|
||||
await wrapper.trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('ignores other keys', async () => {
|
||||
const wrapper = mount(Toggle, { props: { as: 'div' } });
|
||||
await wrapper.trigger('keydown', { key: 'a' });
|
||||
await wrapper.trigger('keydown', { key: 'Tab' });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('does not synthesize keyboard toggle on native button', async () => {
|
||||
const wrapper = mount(Toggle);
|
||||
await wrapper.trigger('keydown', { key: ' ' });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('does not toggle on keyboard when disabled (as=div)', async () => {
|
||||
const wrapper = mount(Toggle, { props: { as: 'div', disabled: true } });
|
||||
await wrapper.trigger('keydown', { key: ' ' });
|
||||
await wrapper.trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.attributes('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-button host (as="div")', () => {
|
||||
it('sets aria-disabled when disabled', () => {
|
||||
const wrapper = mount(Toggle, { props: { as: 'div', disabled: true } });
|
||||
expect(wrapper.attributes('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not set aria-disabled when enabled', () => {
|
||||
const wrapper = mount(Toggle, { props: { as: 'div' } });
|
||||
expect(wrapper.attributes('aria-disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('has tabindex=0 when enabled, -1 when disabled', () => {
|
||||
const enabled = mount(Toggle, { props: { as: 'div' } });
|
||||
expect(enabled.attributes('tabindex')).toBe('0');
|
||||
const disabled = mount(Toggle, { props: { as: 'div', disabled: true } });
|
||||
expect(disabled.attributes('tabindex')).toBe('-1');
|
||||
});
|
||||
|
||||
it('button host has no synthesized tabindex', () => {
|
||||
const wrapper = mount(Toggle);
|
||||
expect(wrapper.attributes('tabindex')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot scope', () => {
|
||||
it('exposes pressed, disabled and state', async () => {
|
||||
const seen: Array<{ pressed: boolean; disabled: boolean; state: string }> = [];
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { disabled: false },
|
||||
slots: {
|
||||
default: (scope: { pressed: boolean; disabled: boolean; state: string }) => {
|
||||
seen.push({ pressed: scope.pressed, disabled: scope.disabled, state: scope.state });
|
||||
return scope.state;
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(seen.at(-1)).toEqual({ pressed: false, disabled: false, state: 'off' });
|
||||
await wrapper.trigger('click');
|
||||
expect(seen.at(-1)).toEqual({ pressed: true, disabled: false, state: 'on' });
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('exposes disabled=true in scope when disabled', () => {
|
||||
let scopeDisabled: boolean | undefined;
|
||||
const wrapper = mount(Toggle, {
|
||||
props: { disabled: true },
|
||||
slots: {
|
||||
default: (scope: { disabled: boolean }) => {
|
||||
scopeDisabled = scope.disabled;
|
||||
return '';
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(scopeDisabled).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('form integration', () => {
|
||||
function mountInForm(props: Record<string, unknown>) {
|
||||
const Host = defineComponent({
|
||||
props: ['toggleProps'],
|
||||
setup(p) {
|
||||
return () => h('form', {}, [h(Toggle, p.toggleProps as Record<string, unknown>)]);
|
||||
},
|
||||
});
|
||||
return mount(Host, { props: { toggleProps: props }, attachTo: document.body });
|
||||
}
|
||||
|
||||
it('renders a hidden checkbox input inside a form when name is set', async () => {
|
||||
const wrapper = mountInForm({ name: 'bold', defaultPressed: true });
|
||||
await nextTick();
|
||||
const input = wrapper.find('input[type="checkbox"]');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect(input.attributes('name')).toBe('bold');
|
||||
expect((input.element as HTMLInputElement).checked).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not render a hidden input without a name', () => {
|
||||
const wrapper = mountInForm({ defaultPressed: true });
|
||||
expect(wrapper.find('input').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not render a hidden input outside a form', () => {
|
||||
const wrapper = mount(Toggle, { props: { name: 'bold' }, attachTo: document.body });
|
||||
expect(wrapper.find('input').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('hidden input reflects unchecked state and required flag', async () => {
|
||||
const wrapper = mountInForm({ name: 'bold', required: true });
|
||||
await nextTick();
|
||||
const input = wrapper.find('input[type="checkbox"]');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect((input.element as HTMLInputElement).checked).toBe(false);
|
||||
expect(input.attributes('required')).toBeDefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('submits a custom value when pressed', async () => {
|
||||
const wrapper = mountInForm({ name: 'bold', value: 'enabled' });
|
||||
await nextTick();
|
||||
const toggle = wrapper.findComponent(Toggle);
|
||||
await toggle.trigger('click');
|
||||
const input = wrapper.find('input[type="checkbox"]');
|
||||
expect(input.attributes('value')).toBe('enabled');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle-group awareness', () => {
|
||||
it('suppresses its own hidden input when a ToggleGroup context is present', async () => {
|
||||
// A minimal provider stands in for ToggleGroupRoot: Toggle only checks for
|
||||
// the presence of the context to decide whether to suppress its own input.
|
||||
const GroupProvider = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
provideToggleGroupContext({
|
||||
type: ref('single'),
|
||||
value: shallowRef([]),
|
||||
toggle: () => {},
|
||||
isPressed: () => false,
|
||||
orientation: ref('horizontal'),
|
||||
direction: ref('ltr'),
|
||||
loop: ref(true),
|
||||
disabled: ref(false),
|
||||
rovingFocus: ref(true),
|
||||
items: computed(() => []),
|
||||
onItemKeyDown: () => {},
|
||||
});
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h('form', {}, [
|
||||
h(GroupProvider, {}, {
|
||||
default: () => h(Toggle, { name: 'bold', defaultPressed: true }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const wrapper = mount(Host, { attachTo: document.body });
|
||||
await nextTick();
|
||||
// The nested Toggle must not render its own form input — the group owns it.
|
||||
const namedBold = wrapper.findAll('input').filter(i => i.attributes('name') === 'bold');
|
||||
expect(namedBold.length).toBe(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('exports an emits-typed update:pressed event', async () => {
|
||||
const wrapper = mount(Toggle);
|
||||
await wrapper.trigger('click');
|
||||
expect(wrapper.emitted('update:pressed')).toEqual([[true]]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle } from '@robonen/primitives';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const bold = ref(true);
|
||||
const italic = ref(false);
|
||||
const underline = ref(false);
|
||||
|
||||
const sampleClass = computed(() => [
|
||||
bold.value && 'font-bold',
|
||||
italic.value && 'italic',
|
||||
underline.value && 'underline',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-card flex w-full max-w-sm flex-col gap-3 p-5 text-fg">
|
||||
<div class="flex items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
|
||||
<Toggle
|
||||
v-model:pressed="bold"
|
||||
aria-label="Bold"
|
||||
class="inline-flex size-8 items-center justify-center rounded-md border-0 bg-transparent font-bold text-fg-muted outline-none transition-colors hover:bg-bg-inset data-[state=on]:bg-accent data-[state=on]:text-accent-fg focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
B
|
||||
</Toggle>
|
||||
<Toggle
|
||||
v-model:pressed="italic"
|
||||
aria-label="Italic"
|
||||
class="inline-flex size-8 items-center justify-center rounded-md border-0 bg-transparent italic text-fg-muted outline-none transition-colors hover:bg-bg-inset data-[state=on]:bg-accent data-[state=on]:text-accent-fg focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
i
|
||||
</Toggle>
|
||||
<Toggle
|
||||
v-model:pressed="underline"
|
||||
aria-label="Underline"
|
||||
class="inline-flex size-8 items-center justify-center rounded-md border-0 bg-transparent underline text-fg-muted outline-none transition-colors hover:bg-bg-inset data-[state=on]:bg-accent data-[state=on]:text-accent-fg focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
U
|
||||
</Toggle>
|
||||
|
||||
<div class="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<Toggle
|
||||
disabled
|
||||
aria-label="Strikethrough (unavailable)"
|
||||
class="inline-flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-fg-muted line-through outline-none transition-colors hover:bg-bg-inset data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40"
|
||||
>
|
||||
S
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="min-h-16 rounded-lg bg-bg-subtle px-3 py-2 text-sm text-fg"
|
||||
:class="sampleClass"
|
||||
>
|
||||
The quick brown fox jumps over the lazy dog.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Toggle } from './Toggle.vue';
|
||||
export type { ToggleEmits, ToggleProps, ToggleState } from './Toggle.vue';
|
||||
Reference in New Issue
Block a user