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,59 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
|
||||
/**
|
||||
* Visually hides its content while keeping it available to assistive
|
||||
* technology. The element is removed from the visual layout but stays in the
|
||||
* accessibility tree (and remains focusable) so screen readers can still
|
||||
* announce it. Use it for accessible labels, status text, or skip links that
|
||||
* should be heard but not seen — for example a hidden heading, an icon-only
|
||||
* button's name, or extra context for a control.
|
||||
*/
|
||||
export interface VisuallyHiddenProps extends PrimitiveProps {
|
||||
/**
|
||||
* How the content participates: `'focusable'` keeps it in the accessibility
|
||||
* tree and focusable (visually hidden only — e.g. skip links); `'hidden'`
|
||||
* additionally hides it from layout and focus.
|
||||
* @default 'focusable'
|
||||
*/
|
||||
feature?: 'focusable' | 'hidden';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
const { as = 'span', feature = 'focusable' } = defineProps<VisuallyHiddenProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '-1px',
|
||||
left: '-1px',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: '0',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
whiteSpace: 'nowrap',
|
||||
wordWrap: 'normal',
|
||||
border: '0',
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:style="style"
|
||||
:tabindex="feature === 'hidden' ? -1 : undefined"
|
||||
:aria-hidden="feature === 'hidden' ? true : undefined"
|
||||
:data-visually-hidden="feature"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import type { VisuallyHiddenInputBubbleProps } from './VisuallyHiddenInputBubble.vue';
|
||||
|
||||
/**
|
||||
* Bridges a custom control's value into native form submission. It serializes
|
||||
* the bound `value` into one visually-hidden native `<input>` per leaf so the
|
||||
* data is submitted with the owning `<form>` and participates in native
|
||||
* constraint validation:
|
||||
*
|
||||
* - primitives (`string | number | boolean | null | undefined`) → a single
|
||||
* input named `name`;
|
||||
* - arrays of primitives → `name[index]`;
|
||||
* - arrays of objects → `name[index][key]`;
|
||||
* - plain objects → `name[key]`.
|
||||
*
|
||||
* A `required` field bound to an empty array still renders one input, so native
|
||||
* `required` validation fires on empty multi-selects.
|
||||
*/
|
||||
export interface VisuallyHiddenInputProps<T = unknown> extends Omit<VisuallyHiddenInputBubbleProps<T>, 'value'> {
|
||||
/** The value to serialize and submit. */
|
||||
value: T;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T = unknown">
|
||||
import { computed } from 'vue';
|
||||
import { isArray, isObject } from '@vue/shared';
|
||||
import VisuallyHiddenInputBubble from './VisuallyHiddenInputBubble.vue';
|
||||
|
||||
const props = withDefaults(defineProps<VisuallyHiddenInputProps<T>>(), {
|
||||
feature: 'hidden',
|
||||
checked: undefined,
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
// Keep a single input for a `required` empty multi-select so the browser's
|
||||
// native validation still blocks submission.
|
||||
const requiresEmptyArrayInput = computed(() =>
|
||||
isArray(props.value) && props.value.length === 0 && props.required);
|
||||
|
||||
interface SerializedLeaf {
|
||||
name: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
const leaves = computed<SerializedLeaf[]>(() => {
|
||||
const value = props.value;
|
||||
const name = props.name;
|
||||
|
||||
// Primitive (or nullish) value → one input.
|
||||
if (!isObject(value))
|
||||
return [{ name, value }];
|
||||
|
||||
// Array value → `name[index]` for primitives, `name[index][key]` for objects.
|
||||
if (isArray(value)) {
|
||||
return value.flatMap((item, index) => {
|
||||
if (isObject(item) && !isArray(item))
|
||||
return Object.entries(item).map(([key, v]) => ({ name: `${name}[${index}][${key}]`, value: v }));
|
||||
|
||||
return { name: `${name}[${index}]`, value: item };
|
||||
});
|
||||
}
|
||||
|
||||
// Plain object value → `name[key]`.
|
||||
return Object.entries(value).map(([key, v]) => ({ name: `${name}[${key}]`, value: v }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisuallyHiddenInputBubble
|
||||
v-if="requiresEmptyArrayInput"
|
||||
:key="name"
|
||||
v-bind="$attrs"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:checked="checked"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:feature="feature"
|
||||
/>
|
||||
|
||||
<VisuallyHiddenInputBubble
|
||||
v-for="leaf in leaves"
|
||||
v-else
|
||||
:key="leaf.name"
|
||||
v-bind="$attrs"
|
||||
:name="leaf.name"
|
||||
:value="leaf.value"
|
||||
:checked="checked"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:feature="feature"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { VisuallyHiddenProps } from './VisuallyHidden.vue';
|
||||
|
||||
/**
|
||||
* A single native, visually-hidden `<input>` that mirrors a custom control's
|
||||
* value into native form submission. It keeps the input out of the visual
|
||||
* layout (and the accessibility tree) while staying part of the owning
|
||||
* `<form>`, so the value is submitted and native constraint validation
|
||||
* (`required`) still fires.
|
||||
*
|
||||
* When `value`/`checked` change programmatically, it writes through the native
|
||||
* `HTMLInputElement` property setter and dispatches bubbling `input` and
|
||||
* `change` events, so third-party form libraries and listeners observe the
|
||||
* change exactly as they would for direct user input.
|
||||
*/
|
||||
// Module-scope cache for the native `value`/`checked` property setters.
|
||||
// Resolving `Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, ...)`
|
||||
// is constant for the whole page lifetime, so we resolve once (lazily, behind
|
||||
// the caller's `window` guard for SSR-safety) and reuse a stable monomorphic
|
||||
// setter reference for every programmatic value/checked change.
|
||||
type InputSetter = (this: HTMLInputElement, v: unknown) => void;
|
||||
let valueSetter: InputSetter | undefined;
|
||||
let checkedSetter: InputSetter | undefined;
|
||||
let nativeSettersResolved = false;
|
||||
|
||||
function resolveNativeSetters(): void {
|
||||
if (nativeSettersResolved) return;
|
||||
nativeSettersResolved = true;
|
||||
const proto = globalThis.HTMLInputElement.prototype;
|
||||
valueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set as InputSetter | undefined;
|
||||
checkedSetter = Object.getOwnPropertyDescriptor(proto, 'checked')?.set as InputSetter | undefined;
|
||||
}
|
||||
|
||||
export interface VisuallyHiddenInputBubbleProps<T = unknown> {
|
||||
/** Name submitted with the owning form. */
|
||||
name: string;
|
||||
/** Value submitted with the owning form. */
|
||||
value: T;
|
||||
/**
|
||||
* Checked state for checkbox/radio-style submission. When provided it is the
|
||||
* source of truth driven through the native `checked` setter; otherwise
|
||||
* `value` is driven through the native `value` setter.
|
||||
*/
|
||||
checked?: boolean;
|
||||
/** Mirror the `required` constraint so native validation fires. */
|
||||
required?: boolean;
|
||||
/** Mirror the `disabled` state so the field is excluded from submission. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Visual-hiding strategy passed through to `VisuallyHidden`.
|
||||
* @default 'hidden'
|
||||
*/
|
||||
feature?: VisuallyHiddenProps['feature'];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T = unknown">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
import VisuallyHidden from './VisuallyHidden.vue';
|
||||
|
||||
const props = withDefaults(defineProps<VisuallyHiddenInputBubbleProps<T>>(), {
|
||||
feature: 'hidden',
|
||||
checked: undefined,
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const { forwardRef, currentElement } = useForwardExpose();
|
||||
|
||||
// `checked` (when provided) drives a checkbox-style input via the native
|
||||
// `checked` setter; otherwise `value` drives a text-style input via `value`.
|
||||
const isCheckbox = computed(() => props.checked !== undefined);
|
||||
|
||||
// Single reactive source describing what the native input should reflect.
|
||||
const driven = computed(() => (isCheckbox.value ? props.checked : props.value));
|
||||
|
||||
watch(
|
||||
driven,
|
||||
(next, prev) => syncNativeInput(next, prev),
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
function syncNativeInput(next: unknown, prev: unknown): void {
|
||||
if (next === prev) return;
|
||||
|
||||
const input = currentElement.value as HTMLInputElement | undefined;
|
||||
if (!input || globalThis.window === undefined) return;
|
||||
|
||||
// Write through the native property setter so frameworks that monkey-patch
|
||||
// the input's value/checked tracker (e.g. synthetic event systems) observe
|
||||
// the programmatic change, then emit the events a real edit would produce.
|
||||
resolveNativeSetters();
|
||||
const setter = isCheckbox.value ? checkedSetter : valueSetter;
|
||||
setter?.call(input, next);
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VisuallyHidden
|
||||
:ref="forwardRef"
|
||||
as="input"
|
||||
:type="isCheckbox ? 'checkbox' : 'text'"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:checked="checked"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:feature="feature"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VisuallyHidden from '../VisuallyHidden.vue';
|
||||
|
||||
describe('VisuallyHidden', () => {
|
||||
it('renders a span with sr-only style by default', () => {
|
||||
const w = mount(VisuallyHidden, { slots: { default: 'Screen reader only' } });
|
||||
|
||||
const el = w.element as HTMLElement;
|
||||
expect(el.tagName).toBe('SPAN');
|
||||
expect(el.style.position).toBe('absolute');
|
||||
expect(el.style.width).toBe('1px');
|
||||
expect(el.style.height).toBe('1px');
|
||||
expect(el.style.overflow).toBe('hidden');
|
||||
expect(w.text()).toBe('Screen reader only');
|
||||
});
|
||||
|
||||
it('does not set aria-hidden by default (content is announced)', () => {
|
||||
const w = mount(VisuallyHidden, { slots: { default: 'x' } });
|
||||
expect(w.element.getAttribute('aria-hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('sets aria-hidden when feature="hidden"', () => {
|
||||
const w = mount(VisuallyHidden, {
|
||||
props: { feature: 'hidden' },
|
||||
slots: { default: 'x' },
|
||||
});
|
||||
|
||||
expect(w.element.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('exposes a data attribute describing the feature', () => {
|
||||
const w = mount(VisuallyHidden, { slots: { default: 'x' } });
|
||||
expect(w.element.getAttribute('data-visually-hidden')).toBe('focusable');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import VisuallyHiddenInput from '../VisuallyHiddenInput.vue';
|
||||
import VisuallyHiddenInputBubble from '../VisuallyHiddenInputBubble.vue';
|
||||
|
||||
function inputs(el: Element): HTMLInputElement[] {
|
||||
return Array.from(el.querySelectorAll('input')) as HTMLInputElement[];
|
||||
}
|
||||
|
||||
describe('VisuallyHiddenInput', () => {
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
});
|
||||
|
||||
it('renders a single hidden input for a primitive value', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'color', value: 'red' },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]!.name).toBe('color');
|
||||
expect(all[0]!.value).toBe('red');
|
||||
});
|
||||
|
||||
it('hides the input from layout and the accessibility tree by default', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'color', value: 'red' },
|
||||
});
|
||||
|
||||
const input = inputs(wrapper.element.parentElement!)[0]!;
|
||||
expect(input.style.position).toBe('absolute');
|
||||
expect(input.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(input.getAttribute('tabindex')).toBe('-1');
|
||||
expect(input.getAttribute('data-visually-hidden')).toBe('hidden');
|
||||
});
|
||||
|
||||
it('serializes an array of primitives to name[index]', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: ['a', 'b', 'c'] },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual(['tags[0]', 'tags[1]', 'tags[2]']);
|
||||
expect(all.map(i => i.value)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('serializes an array of objects to name[index][key]', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'items', value: [{ id: 1, label: 'x' }, { id: 2, label: 'y' }] },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual([
|
||||
'items[0][id]',
|
||||
'items[0][label]',
|
||||
'items[1][id]',
|
||||
'items[1][label]',
|
||||
]);
|
||||
expect(all.map(i => i.value)).toEqual(['1', 'x', '2', 'y']);
|
||||
});
|
||||
|
||||
it('serializes a plain object to name[key]', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'coords', value: { x: 10, y: 20 } },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual(['coords[x]', 'coords[y]']);
|
||||
expect(all.map(i => i.value)).toEqual(['10', '20']);
|
||||
});
|
||||
|
||||
it('renders nothing for an empty, non-required array', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: [] },
|
||||
});
|
||||
|
||||
expect(inputs(wrapper.element.parentElement!)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders one required input for an empty required array (native validation fires)', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: [], required: true },
|
||||
});
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]!.name).toBe('tags');
|
||||
expect(all[0]!.required).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards required and disabled to every leaf input', () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: ['a', 'b'], required: true, disabled: true },
|
||||
});
|
||||
|
||||
for (const input of inputs(wrapper.element.parentElement!)) {
|
||||
expect(input.required).toBe(true);
|
||||
expect(input.disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates the rendered inputs when the array value changes', async () => {
|
||||
wrapper = mount(VisuallyHiddenInput, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'tags', value: ['a'] },
|
||||
});
|
||||
|
||||
expect(inputs(wrapper.element.parentElement!)).toHaveLength(1);
|
||||
|
||||
await wrapper.setProps({ value: ['a', 'b'] });
|
||||
await nextTick();
|
||||
|
||||
const all = inputs(wrapper.element.parentElement!);
|
||||
expect(all.map(i => i.name)).toEqual(['tags[0]', 'tags[1]']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VisuallyHiddenInputBubble', () => {
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
});
|
||||
|
||||
it('renders a hidden text input mirroring the value', () => {
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'q', value: 'hello' },
|
||||
});
|
||||
|
||||
const input = wrapper.element as HTMLInputElement;
|
||||
expect(input.tagName).toBe('INPUT');
|
||||
expect(input.type).toBe('text');
|
||||
expect(input.name).toBe('q');
|
||||
expect(input.value).toBe('hello');
|
||||
});
|
||||
|
||||
it('renders a checkbox-style input when checked is provided', () => {
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'agree', value: 'on', checked: true },
|
||||
});
|
||||
|
||||
const input = wrapper.element as HTMLInputElement;
|
||||
expect(input.type).toBe('checkbox');
|
||||
expect(input.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('dispatches bubbling input and change events when the value changes programmatically', async () => {
|
||||
const onInput = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
document.body.addEventListener('input', onInput);
|
||||
document.body.addEventListener('change', onChange);
|
||||
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'q', value: 'a' },
|
||||
});
|
||||
|
||||
onInput.mockClear();
|
||||
onChange.mockClear();
|
||||
|
||||
await wrapper.setProps({ value: 'b' });
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.element as HTMLInputElement).value).toBe('b');
|
||||
expect(onInput).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeEventListener('input', onInput);
|
||||
document.body.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
it('dispatches change events when the checked state changes programmatically', async () => {
|
||||
const onChange = vi.fn();
|
||||
document.body.addEventListener('change', onChange);
|
||||
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'agree', value: 'on', checked: false },
|
||||
});
|
||||
|
||||
onChange.mockClear();
|
||||
|
||||
await wrapper.setProps({ checked: true });
|
||||
await nextTick();
|
||||
|
||||
expect((wrapper.element as HTMLInputElement).checked).toBe(true);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
it('does not dispatch when the value is unchanged', async () => {
|
||||
const onChange = vi.fn();
|
||||
document.body.addEventListener('change', onChange);
|
||||
|
||||
wrapper = mount(VisuallyHiddenInputBubble, {
|
||||
attachTo: document.body,
|
||||
props: { name: 'q', value: 'a' },
|
||||
});
|
||||
|
||||
onChange.mockClear();
|
||||
|
||||
await wrapper.setProps({ value: 'a' });
|
||||
await nextTick();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
document.body.removeEventListener('change', onChange);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { VisuallyHidden } from '@robonen/primitives';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const count = ref(0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="demo-card flex w-full max-w-sm flex-col gap-5 p-5 text-fg">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Icon-only buttons
|
||||
</h3>
|
||||
<p class="text-sm text-fg-muted">
|
||||
Each button shows only an icon, but exposes a real accessible name to screen readers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-9 items-center justify-center rounded-md border border-border bg-bg-subtle text-fg transition-colors hover:bg-bg-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="count -= 1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="text-base leading-none"
|
||||
>−</span>
|
||||
<VisuallyHidden>Decrease quantity</VisuallyHidden>
|
||||
</button>
|
||||
|
||||
<span class="min-w-8 text-center text-sm font-medium tabular-nums">
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-9 items-center justify-center rounded-md border border-border bg-bg-subtle text-fg transition-colors hover:bg-bg-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="count += 1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="text-base leading-none"
|
||||
>+</span>
|
||||
<VisuallyHidden>Increase quantity</VisuallyHidden>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-fg-subtle">
|
||||
Quantity is now
|
||||
<span class="font-medium text-fg">{{ count }}</span>.
|
||||
<VisuallyHidden
|
||||
as="span"
|
||||
aria-live="polite"
|
||||
>
|
||||
Quantity changed to {{ count }}.
|
||||
</VisuallyHidden>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as VisuallyHidden } from './VisuallyHidden.vue';
|
||||
export type { VisuallyHiddenProps } from './VisuallyHidden.vue';
|
||||
export { default as VisuallyHiddenInput } from './VisuallyHiddenInput.vue';
|
||||
export type { VisuallyHiddenInputProps } from './VisuallyHiddenInput.vue';
|
||||
export { default as VisuallyHiddenInputBubble } from './VisuallyHiddenInputBubble.vue';
|
||||
export type { VisuallyHiddenInputBubbleProps } from './VisuallyHiddenInputBubble.vue';
|
||||
Reference in New Issue
Block a user