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,138 @@
import type { VueWrapper } from '@vue/test-utils';
import type { CollectionContext } from '../useCollection';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { createCommentVNode, defineComponent, h, nextTick } from 'vue';
import { useCollectionInjector, useCollectionProvider } from '../useCollection';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function makeProvider(onCreated: (ctx: CollectionContext) => void, key?: string) {
return defineComponent({
setup(_, { slots }) {
const ctx = key === undefined ? useCollectionProvider() : useCollectionProvider(key);
onCreated(ctx);
return () => h(ctx.CollectionSlot, null, { default: () => h('div', null, slots.default?.()) });
},
});
}
function makeItem(id: string, key?: string) {
return defineComponent({
setup() {
const { CollectionItem } = key === undefined
? useCollectionInjector()
: useCollectionInjector(key);
return () => h(CollectionItem, null, { default: () => h('button', { id }) });
},
});
}
describe('useCollection — default key', () => {
it('registers items into the nearest provider and returns them in DOM order', async () => {
let ctx!: CollectionContext;
const Provider = makeProvider(c => (ctx = c));
const Harness = defineComponent({
setup() {
return () => h(Provider, null, {
default: () => [h(makeItem('one')), h(makeItem('two'))],
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(ctx.getItems().map(i => i.ref.id)).toEqual(['one', 'two']);
});
});
describe('useCollection — element resolution', () => {
it('registers the next element sibling when an item child exposes a comment node as $el', async () => {
// A multi-root child whose first root is a comment, so `$el` is a `#comment`
// node followed by the real element — mirrors conditional/async component roots.
const CommentRooted = defineComponent({
inheritAttrs: false,
setup() {
return () => [createCommentVNode('placeholder'), h('button', { id: 'real' })];
},
});
const Item = defineComponent({
setup() {
const { CollectionItem } = useCollectionInjector();
return () => h(CollectionItem, null, { default: () => h(CommentRooted) });
},
});
let ctx!: CollectionContext;
const Provider = makeProvider(c => (ctx = c));
const Harness = defineComponent({
setup() {
return () => h(Provider, null, { default: () => h(Item) });
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(ctx.getItems().map(i => i.ref.id)).toEqual(['real']);
expect(ctx.getItems()[0]!.ref).toBeInstanceOf(HTMLElement);
});
});
describe('useCollection — namespaced keys', () => {
it('a nested default-key provider does not shadow an outer namespaced provider', async () => {
let outer!: CollectionContext;
let inner!: CollectionContext;
const Outer = makeProvider(c => (outer = c), 'TestOuterCollection');
const Inner = makeProvider(c => (inner = c)); // default key, in between
const Harness = defineComponent({
setup() {
return () => h(Outer, null, {
default: () => h(Inner, null, {
default: () => [
// Registers into the *outer* collection despite the inner provider.
h(makeItem('outer-item', 'TestOuterCollection')),
h(makeItem('inner-item')),
],
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(outer.getItems().map(i => i.ref.id)).toEqual(['outer-item']);
expect(inner.getItems().map(i => i.ref.id)).toEqual(['inner-item']);
});
it('distinct keys keep fully independent registries', async () => {
let a!: CollectionContext;
let b!: CollectionContext;
const A = makeProvider(c => (a = c), 'TestKeyA');
const B = makeProvider(c => (b = c), 'TestKeyB');
const Harness = defineComponent({
setup() {
return () => h(A, null, {
default: () => h(B, null, {
default: () => [
h(makeItem('a-item', 'TestKeyA')),
h(makeItem('b-item', 'TestKeyB')),
],
}),
});
},
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
expect(a.getItems().map(i => i.ref.id)).toEqual(['a-item']);
expect(b.getItems().map(i => i.ref.id)).toEqual(['b-item']);
});
});
@@ -0,0 +1,6 @@
export {
useCollectionProvider,
useCollectionInjector,
type CollectionContext,
type CollectionItemData,
} from './useCollection';
@@ -0,0 +1,252 @@
import type { ComputedRef, DefineComponent, ShallowRef } from 'vue';
import {
computed,
defineComponent,
h,
markRaw,
shallowRef,
triggerRef,
watch,
} from 'vue';
import { unrefElement, useContextFactory } from '@robonen/vue';
import { Slot } from '../../internal/primitive';
/**
* Data attribute used to locate items inside a collection via `querySelectorAll`.
* Rendered automatically by `<CollectionItem>`.
*/
const ITEM_DATA_ATTR = 'data-collection-item';
/**
* Resolves the DOM element behind a `:ref` callback value.
*
* Multi-root / conditional components expose a `#comment` (or `#text`) node as
* their `$el`; in that case we fall back to the next real element sibling, so
* such children still register instead of being silently skipped. Mirrors the
* resolver used by the toolkit's forward-expose. The common case (a plain
* element) takes a single `instanceof` check, so there is no hot-path overhead.
*/
function resolveItemElement(el: unknown): HTMLElement | undefined {
const node = unrefElement(el as Parameters<typeof unrefElement>[0]) as
| Node
| null
| undefined;
if (node instanceof HTMLElement) return node;
if (node instanceof CharacterData) {
const sibling = node.nextElementSibling;
return sibling instanceof HTMLElement ? sibling : undefined;
}
return undefined;
}
export interface CollectionItemData<Value = unknown> {
/** DOM element that represents the item. */
ref: HTMLElement;
/** Arbitrary `value` associated with the item via `<CollectionItem :value>`. */
value?: Value;
}
export interface CollectionContext<Value = unknown> {
/** Root element of the collection (set by `<CollectionSlot>`). */
collectionRef: ShallowRef<HTMLElement | undefined>;
/** Raw element→data map. Mutated via `triggerRef` — do not rely on deep reactivity. */
itemMap: ShallowRef<Map<HTMLElement, CollectionItemData<Value>>>;
/**
* Returns items sorted by their DOM order. Items with `data-disabled` are
* skipped unless `includeDisabled` is `true`.
*
* The ordering comes from `collectionRef.querySelectorAll(...)`, which means
* it survives `<Teleport>`, `<Suspense>` and `v-for` reorders — unlike a
* mount-order based registry.
*/
getItems: (includeDisabled?: boolean) => Array<CollectionItemData<Value>>;
/** Reactive snapshot of all items (unsorted). Invalidated when `itemMap` changes. */
reactiveItems: ComputedRef<Array<CollectionItemData<Value>>>;
/** Reactive count of items. */
itemMapSize: ComputedRef<number>;
/** Root marker component — render at the collection's root. */
CollectionSlot: DefineComponent;
/** Item marker component — wrap each focusable/selectable child. */
CollectionItem: DefineComponent<{ value?: unknown }>;
}
function createCollectionState<Value = unknown>(): CollectionContext<Value> {
// `shallowRef` + manual `triggerRef` avoids wrapping the Map in a deep Proxy.
// For collections with many items (large lists, menus, listboxes) this is
// measurably cheaper than `ref(new Map())`.
const collectionRef = shallowRef<HTMLElement>();
const itemMap = shallowRef(
new Map<HTMLElement, CollectionItemData<Value>>(),
);
const getItems = (includeDisabled = false) => {
const collectionNode = collectionRef.value;
if (!collectionNode) return [];
const items = Array.from(itemMap.value.values());
// Sort by DOM order. Build a node→index lookup ONCE (O(n)) instead of calling
// `orderedNodes.indexOf()` inside the comparator — that was O(n) per call,
// i.e. O(n² log n) overall, and `getItems()` runs per keystroke / per
// pointer-move across the roving-focus / menu / listbox / tree family.
// 0 or 1 items need no DOM query and no sort.
if (items.length > 1) {
const orderedNodes = collectionNode.querySelectorAll(
`[${ITEM_DATA_ATTR}]`,
);
const orderByNode = new Map<Element, number>();
for (let i = 0; i < orderedNodes.length; i++) {
orderByNode.set(orderedNodes[i]!, i);
}
// Preserve prior semantics: nodes not present in the query (e.g. just
// registered, not yet in the DOM) sort to the front via index -1.
items.sort((a, b) => {
const ai = orderByNode.get(a.ref);
const bi = orderByNode.get(b.ref);
return (ai === undefined ? -1 : ai) - (bi === undefined ? -1 : bi);
});
}
return includeDisabled
? items
: items.filter(i => i.ref.dataset['disabled'] !== '');
};
const CollectionSlot = defineComponent({
name: 'CollectionSlot',
inheritAttrs: false,
setup(_, { slots, attrs }) {
return () =>
h(
Slot,
{
...attrs,
ref: (el: unknown) => {
const element = resolveItemElement(el);
if (element) {
collectionRef.value = element;
}
},
},
slots,
);
},
}) as DefineComponent;
const CollectionItem = defineComponent({
name: 'CollectionItem',
inheritAttrs: false,
props: {
value: {
// Accepts any value.
validator: () => true,
},
},
setup(props, { slots, attrs }) {
const currentElement = shallowRef<HTMLElement>();
watch(
[currentElement, () => props.value],
([el], _prev, onCleanup) => {
if (!el) return;
// `markRaw` keeps Vue from trying to make the element reactive —
// we only care about identity as a Map key.
const key = markRaw(el);
itemMap.value.set(key, { ref: el, value: props.value as Value });
triggerRef(itemMap);
onCleanup(() => {
itemMap.value.delete(key);
triggerRef(itemMap);
});
},
{ immediate: true },
);
return () =>
h(
Slot,
{
...attrs,
[ITEM_DATA_ATTR]: '',
ref: (el: unknown) => {
const element = resolveItemElement(el);
if (element) {
currentElement.value = element;
}
},
},
slots,
);
},
}) as DefineComponent<{ value?: unknown }>;
const reactiveItems = computed(() => Array.from(itemMap.value.values()));
const itemMapSize = computed(() => itemMap.value.size);
return {
collectionRef,
itemMap,
getItems,
reactiveItems,
itemMapSize,
CollectionSlot,
CollectionItem,
};
}
const DEFAULT_COLLECTION_KEY = 'CollectionContext';
// One context factory per namespace key (`useContextFactory` mints a unique
// Symbol per call). Without namespacing, a collection provider nested inside
// another (e.g. `RovingFocusGroup` between `NavigationMenuRoot` and
// `NavigationMenuTrigger`) shadows the outer collection for every descendant.
const collectionContextFactories = new Map<
string,
ReturnType<typeof useContextFactory<CollectionContext>>
>();
function getCollectionContextFactory(key: string) {
let factory = collectionContextFactories.get(key);
if (!factory) {
factory = useContextFactory<CollectionContext>(key);
collectionContextFactories.set(key, factory);
}
return factory;
}
/**
* Creates a new collection state and provides it to descendants.
* Call this in the parent (e.g. `RovingFocusGroup`, `ListboxRoot`).
*
* Pass a dedicated `key` when the component tree may nest another collection
* provider between this one and its injectors, so they don't shadow each other.
*
* @example
* ```ts
* const { getItems, CollectionSlot } = useCollectionProvider();
* ```
*/
export function useCollectionProvider<Value = unknown>(
key: string = DEFAULT_COLLECTION_KEY,
): CollectionContext<Value> {
const ctx = createCollectionState<Value>();
getCollectionContextFactory(key).provide(ctx as CollectionContext);
return ctx;
}
/**
* Injects the collection context from the nearest `useCollectionProvider()`
* called with the same `key`.
* Call this in children (e.g. `RovingFocusItem`, `ListboxItem`).
*
* @throws when used outside a provider.
*/
export function useCollectionInjector<Value = unknown>(
key: string = DEFAULT_COLLECTION_KEY,
): CollectionContext<Value> {
return getCollectionContextFactory(key).inject() as CollectionContext<Value>;
}
@@ -0,0 +1,625 @@
import { describe, expect, it } from 'vitest';
import { computed, defineComponent, h, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import {
nativeDateAdapter,
provideAppConfig,
provideConfig,
useConfig,
useDateAdapter,
useDirection,
useId,
useLocale,
useNonce,
} from '..';
// --- useConfig ---
describe('useConfig', () => {
it('returns default config when no provider exists', () => {
const wrapper = mount(
defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-dir': this.config.dir.value,
'data-target': this.config.teleportTarget.value,
});
},
}),
);
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
expect(wrapper.find('div').attributes('data-target')).toBe('body');
wrapper.unmount();
});
it('returns custom config from provideConfig', () => {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-dir': this.config.dir.value,
'data-target': this.config.teleportTarget.value,
});
},
});
const Parent = defineComponent({
setup() {
provideConfig({
dir: 'rtl',
teleportTarget: '#app',
});
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
wrapper.unmount();
});
it('exposes mutable refs for runtime updates', async () => {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', { 'data-dir': this.config.dir.value });
},
});
const Parent = defineComponent({
setup() {
const config = provideConfig({ dir: 'ltr' });
return { config };
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
wrapper.vm.config.dir.value = 'rtl';
await wrapper.vm.$nextTick();
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
wrapper.unmount();
});
});
// --- provideAppConfig ---
describe('provideAppConfig', () => {
it('provides config at app level', () => {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-dir': this.config.dir.value,
});
},
});
const wrapper = mount(Child, {
global: {
plugins: [
app => provideAppConfig(app, { dir: 'rtl' }),
],
},
});
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
wrapper.unmount();
});
});
// --- useId override ---
describe('useId (config override)', () => {
it('uses the toolkit fallback when no override is provided', () => {
const Child = defineComponent({
setup() {
const id = useId();
return { id };
},
render() {
return h('div', { 'data-id': this.id });
},
});
const wrapper = mount(Child);
expect(wrapper.find('div').attributes('data-id')).toMatch(/^robonen-/);
wrapper.unmount();
});
it('routes through a provided useId override', () => {
let count = 0;
const customUseId = (_deterministic?: unknown, prefix = 'x') => {
count += 1;
const n = count;
return computed(() => `${prefix}-${n}`);
};
const Child = defineComponent({
setup() {
const a = useId();
const b = useId(undefined, 'custom');
return { a, b };
},
render() {
return h('div', { 'data-a': this.a, 'data-b': this.b });
},
});
const wrapper = mount(Child, {
global: {
plugins: [app => provideAppConfig(app, { useId: customUseId })],
},
});
expect(wrapper.find('div').attributes('data-a')).toBe('x-1');
expect(wrapper.find('div').attributes('data-b')).toBe('custom-2');
wrapper.unmount();
});
it('respects deterministic id passed through the override', () => {
const Child = defineComponent({
setup() {
const id = useId(() => 'fixed-id');
return { id };
},
render() {
return h('div', { 'data-id': this.id });
},
});
const wrapper = mount(Child);
expect(wrapper.find('div').attributes('data-id')).toBe('fixed-id');
wrapper.unmount();
});
});
// --- new global fields: locale / nonce / scrollBody ---
describe('config fields (locale / nonce / scrollBody)', () => {
function renderConfig() {
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', {
'data-locale': this.config.locale.value,
'data-nonce': this.config.nonce.value ?? '',
'data-scroll': JSON.stringify(this.config.scrollBody.value),
});
},
});
return Child;
}
it('exposes documented defaults when no provider exists', () => {
const wrapper = mount(renderConfig());
const div = wrapper.find('div');
expect(div.attributes('data-locale')).toBe('en');
expect(div.attributes('data-nonce')).toBe('');
expect(div.attributes('data-scroll')).toBe('true');
wrapper.unmount();
});
it('inherits custom locale / nonce / scrollBody from provideConfig', () => {
const Child = renderConfig();
const Parent = defineComponent({
setup() {
provideConfig({
locale: 'fr',
nonce: 'abc123',
scrollBody: { padding: 20, margin: 0 },
});
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
const div = wrapper.find('div');
expect(div.attributes('data-locale')).toBe('fr');
expect(div.attributes('data-nonce')).toBe('abc123');
expect(div.attributes('data-scroll')).toBe('{"padding":20,"margin":0}');
wrapper.unmount();
});
it('supports scrollBody=false', () => {
const Child = renderConfig();
const Parent = defineComponent({
setup() {
provideConfig({ scrollBody: false });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-scroll')).toBe('false');
wrapper.unmount();
});
});
// --- reactivity preservation (the fixed defect) ---
describe('reactive source preservation', () => {
it('stays live when dir is provided as a ref', async () => {
const source = ref<'ltr' | 'rtl'>('ltr');
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', { 'data-dir': this.config.dir.value });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ dir: source });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-dir')).toBe('ltr');
source.value = 'rtl';
await nextTick();
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
wrapper.unmount();
});
it('stays live when locale is provided as a getter', async () => {
const source = ref('en');
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', { 'data-locale': this.config.locale.value });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ locale: () => source.value });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-locale')).toBe('en');
source.value = 'de';
await nextTick();
expect(wrapper.find('div').attributes('data-locale')).toBe('de');
wrapper.unmount();
});
it('teleportTarget getter stays live and resolves default', async () => {
const source = ref<string>('body');
const Child = defineComponent({
setup() {
const config = useConfig();
return { config };
},
render() {
return h('div', { 'data-target': this.config.teleportTarget.value });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ teleportTarget: () => source.value });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-target')).toBe('body');
source.value = '#app';
await nextTick();
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
wrapper.unmount();
});
});
// --- resolver composables ---
describe('useDirection / useLocale / useNonce', () => {
it('useDirection prefers local override over config and default', () => {
const A = defineComponent({
setup() {
const dir = useDirection();
return { dir };
},
render() {
return h('div', { 'data-dir': this.dir });
},
});
const B = defineComponent({
setup() {
const dir = useDirection('rtl');
return { dir };
},
render() {
return h('div', { 'data-dir': this.dir });
},
});
const noProvider = mount(A);
expect(noProvider.find('div').attributes('data-dir')).toBe('ltr');
noProvider.unmount();
const Parent = defineComponent({
setup() {
provideConfig({ dir: 'rtl' });
},
render() {
return h(A);
},
});
const inherited = mount(Parent);
expect(inherited.find('div').attributes('data-dir')).toBe('rtl');
inherited.unmount();
const override = mount(B);
expect(override.find('div').attributes('data-dir')).toBe('rtl');
override.unmount();
});
it('useLocale prefers local override, then config, then en', () => {
const Local = defineComponent({
setup() {
const locale = useLocale('it');
return { locale };
},
render() {
return h('div', { 'data-locale': this.locale });
},
});
const wrapper = mount(Local);
expect(wrapper.find('div').attributes('data-locale')).toBe('it');
wrapper.unmount();
const Inherited = defineComponent({
setup() {
const locale = useLocale();
return { locale };
},
render() {
return h('div', { 'data-locale': this.locale });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ locale: 'es' });
},
render() {
return h(Inherited);
},
});
const w2 = mount(Parent);
expect(w2.find('div').attributes('data-locale')).toBe('es');
w2.unmount();
});
it('useNonce prefers local override, then config', () => {
const Inherited = defineComponent({
setup() {
const nonce = useNonce();
return { nonce };
},
render() {
return h('div', { 'data-nonce': this.nonce ?? '' });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ nonce: 'cfg-nonce' });
},
render() {
return h(Inherited);
},
});
const w = mount(Parent);
expect(w.find('div').attributes('data-nonce')).toBe('cfg-nonce');
w.unmount();
const Local = defineComponent({
setup() {
const nonce = useNonce('local-nonce');
return { nonce };
},
render() {
return h('div', { 'data-nonce': this.nonce ?? '' });
},
});
const Parent2 = defineComponent({
setup() {
provideConfig({ nonce: 'cfg-nonce' });
},
render() {
return h(Local);
},
});
const w2 = mount(Parent2);
expect(w2.find('div').attributes('data-nonce')).toBe('local-nonce');
w2.unmount();
const NoConfig = defineComponent({
setup() {
const nonce = useNonce();
return { nonce };
},
render() {
return h('div', { 'data-nonce': this.nonce ?? 'none' });
},
});
const w3 = mount(NoConfig);
expect(w3.find('div').attributes('data-nonce')).toBe('none');
w3.unmount();
});
});
// --- dateAdapter config + useDateAdapter resolver ---
describe('dateAdapter / useDateAdapter', () => {
it('exposes the native date adapter by default', () => {
const Child = defineComponent({
setup() {
const config = useConfig();
const adapter = useDateAdapter();
return { config, adapter };
},
render() {
return h('div', {
'data-config-native': this.config.dateAdapter.value === nativeDateAdapter,
'data-resolved-native': this.adapter === nativeDateAdapter,
});
},
});
const wrapper = mount(Child);
expect(wrapper.find('div').attributes('data-config-native')).toBe('true');
expect(wrapper.find('div').attributes('data-resolved-native')).toBe('true');
wrapper.unmount();
});
it('inherits a custom adapter from provideConfig', () => {
const customAdapter = { ...nativeDateAdapter };
const Child = defineComponent({
setup() {
const adapter = useDateAdapter();
return { adapter };
},
render() {
return h('div', { 'data-custom': this.adapter === customAdapter });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ dateAdapter: customAdapter });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-custom')).toBe('true');
wrapper.unmount();
});
it('prefers a per-call override over config and the native default', () => {
const configAdapter = { ...nativeDateAdapter };
const overrideAdapter = { ...nativeDateAdapter };
const Child = defineComponent({
setup() {
const adapter = useDateAdapter(overrideAdapter);
return { adapter };
},
render() {
return h('div', { 'data-override': this.adapter === overrideAdapter });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ dateAdapter: configAdapter });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-override')).toBe('true');
wrapper.unmount();
});
it('stays live when the config adapter is a ref', async () => {
const a = { ...nativeDateAdapter };
const b = { ...nativeDateAdapter };
// shallowRef: an adapter is an opaque object — deep-reactivizing it (plain
// `ref`) would wrap it in a proxy and break identity comparison.
const source = shallowRef(a);
const Child = defineComponent({
setup() {
const adapter = useDateAdapter();
return { adapter };
},
render() {
return h('div', { 'data-which': this.adapter === a ? 'a' : 'b' });
},
});
const Parent = defineComponent({
setup() {
provideConfig({ dateAdapter: source });
},
render() {
return h(Child);
},
});
const wrapper = mount(Parent);
expect(wrapper.find('div').attributes('data-which')).toBe('a');
source.value = b;
await nextTick();
expect(wrapper.find('div').attributes('data-which')).toBe('b');
wrapper.unmount();
});
});
@@ -0,0 +1,179 @@
import type { App, ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue';
import type { AnyDateAdapter } from './date-adapter';
import { getCurrentScope, isRef, ref, shallowRef, toValue, watch } from 'vue';
import { useId as toolkitUseId, useInjectionStore } from '@robonen/vue';
import { nativeDateAdapter } from './date-adapter';
export type Direction = 'ltr' | 'rtl';
/**
* Fine-grained control over the body scroll-lock compensation applied while a
* modal layer (dialog, popover, menu, select) holds the page locked. Mirrors
* the structure consumed by the global body-scroll-lock composable:
*
* - `padding` — compensate the removed scrollbar with right padding.
* - `margin` — compensate the removed scrollbar with right margin.
*
* `true` reuses the measured scrollbar width; a `number`/`string` overrides it.
*/
export interface ScrollBodyOption {
padding?: boolean | number | string;
margin?: boolean | number | string;
}
export type UseIdFn = (
deterministic?: MaybeRefOrGetter<string | undefined>,
prefix?: string,
) => ComputedRef<string>;
export interface ConfigContext {
/** Global reading direction inherited by every primitive. */
dir: Ref<Direction>;
/** Global locale inherited by date/calendar primitives. */
locale: Ref<string>;
/** Global CSP `nonce` inherited by style-injecting primitives. */
nonce: Ref<string | undefined>;
/** Global body scroll-lock compensation behavior. */
scrollBody: Ref<boolean | ScrollBodyOption>;
/** Global teleport/portal destination. */
teleportTarget: ShallowRef<string | HTMLElement>;
/** Pluggable id factory, used to control hydration-safe id generation. */
useId: UseIdFn;
/**
* Pluggable date backend inherited by every date/calendar primitive. The
* adapter is type-erased here; resolve it with `useDateAdapter<TDate>()` to
* recover the concrete date type at the call site.
*/
dateAdapter: ShallowRef<AnyDateAdapter>;
}
export interface ConfigOptions {
/**
* Global reading direction of the application, inherited by all primitives.
* A per-component `dir` prop always overrides this.
* @default 'ltr'
*/
dir?: MaybeRefOrGetter<Direction | undefined>;
/**
* Global locale of the application, inherited by date/calendar primitives.
* A per-component `locale` prop always overrides this.
* @default 'en'
*/
locale?: MaybeRefOrGetter<string | undefined>;
/**
* Global CSP `nonce`, inherited by primitives that inject a `<style>` tag.
* A per-component `nonce` prop always overrides this.
*/
nonce?: MaybeRefOrGetter<string | undefined>;
/**
* Global body scroll-lock compensation behavior, inherited by modal
* primitives. `true` keeps the default padding compensation, `false`
* disables it, or pass `{ padding, margin }` for fine-grained control.
* @default true
*/
scrollBody?: MaybeRefOrGetter<boolean | ScrollBodyOption | undefined>;
/**
* Global teleport/portal destination consumed by `Teleport`.
* @default 'body'
*/
teleportTarget?: MaybeRefOrGetter<string | HTMLElement | undefined>;
/**
* Pluggable id factory, useful as a workaround for hydration mismatches.
* Signature matches the toolkit `useId`: `(deterministic?, prefix?)`.
*/
useId?: UseIdFn;
/**
* Pluggable date backend inherited by date/calendar primitives, letting them
* run on a custom calendar system or date library instead of the native
* `Date`. A per-component `dateAdapter` prop always overrides this.
* @default nativeDateAdapter
*/
dateAdapter?: MaybeRefOrGetter<AnyDateAdapter | undefined>;
}
const DEFAULT_CONFIG = {
dir: 'ltr' as Direction,
locale: 'en',
nonce: undefined as string | undefined,
scrollBody: true as boolean | ScrollBodyOption,
teleportTarget: 'body' as string | HTMLElement,
dateAdapter: nativeDateAdapter as AnyDateAdapter,
};
/**
* Builds a writable `Ref` from a `MaybeRefOrGetter` source while preserving its
* reactivity:
*
* - a `Ref` source is reused directly (live + writable, zero overhead),
* - a getter/value source becomes a writable ref that mirrors later source
* updates via a scoped watcher (no leak when no scope is active, e.g.
* app-level provisioning, where the snapshot is the documented behavior).
*
* This fixes the prior snapshot-into-a-fresh-ref behavior that silently
* disconnected a reactive `dir`/`teleportTarget`/`locale` source.
*/
function resolveConfigRef<T>(
source: MaybeRefOrGetter<T | undefined> | undefined,
fallback: T,
shallow = false,
): Ref<T> {
if (isRef(source))
return source as Ref<T>;
const initial = source === undefined ? fallback : (toValue(source) ?? fallback);
const target = (shallow ? shallowRef(initial) : ref(initial)) as Ref<T>;
// Only a getter (function) can carry live reactivity beyond the snapshot.
if (typeof source === 'function' && getCurrentScope()) {
watch(
() => toValue(source) ?? fallback,
(next) => { target.value = next; },
);
}
return target;
}
function resolveContext(options?: ConfigOptions): ConfigContext {
return {
dir: resolveConfigRef(options?.dir, DEFAULT_CONFIG.dir),
locale: resolveConfigRef(options?.locale, DEFAULT_CONFIG.locale),
nonce: resolveConfigRef(options?.nonce, DEFAULT_CONFIG.nonce),
scrollBody: resolveConfigRef(options?.scrollBody, DEFAULT_CONFIG.scrollBody),
teleportTarget: resolveConfigRef(
options?.teleportTarget,
DEFAULT_CONFIG.teleportTarget,
true,
) as ShallowRef<string | HTMLElement>,
useId: options?.useId ?? toolkitUseId,
dateAdapter: resolveConfigRef(
options?.dateAdapter,
DEFAULT_CONFIG.dateAdapter,
true,
) as ShallowRef<AnyDateAdapter>,
};
}
/**
* Global config store, backed by the toolkit's `useInjectionStore`. Each
* `provideConfig`/`provideAppConfig` call builds a fresh `ConfigContext` from
* the given options and provides it; `useConfig` injects it, falling back to a
* fully-resolved default context (all fields present, native date adapter) when
* no provider exists above the consumer.
*/
const ConfigStore = useInjectionStore(resolveContext, {
injectionName: 'ConfigContext',
defaultValue: resolveContext(),
});
export function provideConfig(options?: ConfigOptions): ConfigContext {
return ConfigStore.useProvidingState(options);
}
export function provideAppConfig(app: App, options?: ConfigOptions): ConfigContext {
return ConfigStore.useAppProvidingState(app)(options);
}
export function useConfig(): ConfigContext {
return ConfigStore.useInjectedState();
}
@@ -0,0 +1,203 @@
import type {
CalendarMonth,
CreateMonthsOptions,
WeekDayFormat,
} from '../../display/calendar/utils';
import {
addDays,
addMonths,
addYears,
clamp,
createMonths,
endOfMonth,
findFirstFocusableDate,
formatDate,
formatFullDate,
formatMonthYear,
formatWeekday,
getDaysInMonth,
getLocaleWeekStartsOn,
getWeekdayLabels,
getWeeks,
isAfter,
isBefore,
isDateUnavailable,
isSameDay,
isSameMonth,
startOfMonth,
startOfWeek,
toDateOnly,
toIsoDate,
} from '../../display/calendar/utils';
/** First day of the week, `0` (Sunday) through `6` (Saturday). */
export type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/**
* A calendar date broken into numeric fields. `month` is **1-based** (January
* is `1`) to mirror the segmented field's user-facing values; `fromParts`
* converts it back to the native representation. Time fields are optional and
* default to `0` when constructing.
*/
export interface DateParts {
year: number;
/** 1-based month (January = 1). */
month: number;
day: number;
hour?: number;
minute?: number;
second?: number;
}
/** `DateParts` with every field resolved (used as `getParts` output). */
export type ResolvedDateParts = Required<DateParts>;
/**
* Pluggable date backend abstracting every date operation the calendar and
* date-picker primitives need, generic over the date representation `TDate`.
* The default {@link nativeDateAdapter} operates on the JS `Date`; provide a
* custom adapter through `ConfigProvider`/`provideConfig({ dateAdapter })` (or a
* per-component `dateAdapter` prop) to swap in another calendar system or date
* library while keeping the primitives unchanged.
*
* The boundary type is `TDate` throughout: a primitive that resolves the
* adapter via `useDateAdapter<TDate>()` hands the adapter its own date values
* and receives `TDate` back, so a single adapter governs construction,
* arithmetic, comparison, formatting and grid building consistently.
*/
export interface DateAdapter<TDate = Date> {
// --- construction / identity ---
/** The current date-time ("now"/"today"). */
now: () => TDate;
/** An independent copy of `date`, preserving the full time-of-day. */
clone: (date: TDate) => TDate;
/** A copy of `date` with the time-of-day stripped to local midnight. */
toDateOnly: (date: TDate) => TDate;
/** Build a date from numeric fields (`month` is 1-based; time defaults to 0). */
fromParts: (parts: DateParts) => TDate;
/** Extract the numeric fields of `date` (`month` is 1-based). */
getParts: (date: TDate) => ResolvedDateParts;
/** Day of week, `0` (Sunday) through `6` (Saturday). */
getDay: (date: TDate) => number;
/** Parse free-form user text into a date, or `null` when unparseable. */
parse: (text: string) => TDate | null;
/** Full ISO-8601 string (UTC), used for hidden form-input serialization. */
toISO: (date: TDate) => string;
// --- arithmetic ---
addDays: (date: TDate, amount: number) => TDate;
addMonths: (date: TDate, amount: number) => TDate;
addYears: (date: TDate, amount: number) => TDate;
startOfMonth: (date: TDate) => TDate;
endOfMonth: (date: TDate) => TDate;
startOfWeek: (date: TDate, weekStartsOn: WeekDay) => TDate;
/** Number of days in `date`'s month. */
getDaysInMonth: (date: TDate) => number;
// --- comparison ---
isSameDay: (a: TDate, b: TDate) => boolean;
isSameMonth: (a: TDate, b: TDate) => boolean;
/** Date-only `a < b` (time-of-day ignored). */
isBefore: (a: TDate, b: TDate) => boolean;
/** Date-only `a > b` (time-of-day ignored). */
isAfter: (a: TDate, b: TDate) => boolean;
/** Full-timestamp ordering: negative if `a < b`, `0` if equal, positive if `a > b`. */
compare: (a: TDate, b: TDate) => number;
/** Clamp `date` (date-only) into the optional `[min, max]` range. */
clamp: (date: TDate, min?: TDate, max?: TDate) => TDate;
/** Whether `date` is out of `[min, max]` bounds or matched by `predicate`. */
isDateUnavailable: (
date: TDate,
predicate?: (date: TDate) => boolean,
min?: TDate,
max?: TDate,
) => boolean;
// --- locale / formatting ---
getLocaleWeekStartsOn: (locale: string) => WeekDay;
format: (date: TDate, options: Intl.DateTimeFormatOptions, locale: string) => string;
formatWeekday: (date: TDate, locale: string, width: WeekDayFormat) => string;
formatMonthYear: (date: TDate, locale: string) => string;
formatFullDate: (date: TDate, locale: string) => string;
/** Local `YYYY-MM-DD` (does not shift across the UTC boundary). */
toIsoDate: (date: TDate) => string;
// --- grid building ---
getWeeks: (month: TDate, weekStartsOn: WeekDay) => TDate[][];
createMonths: (options: CreateMonthsOptions<TDate>) => Array<CalendarMonth<TDate>>;
getWeekdayLabels: (weekStartsOn: WeekDay, locale: string, width: WeekDayFormat) => string[];
findFirstFocusableDate: (
months: Array<CalendarMonth<TDate>>,
isDisabled: (date: TDate) => boolean,
isUnavailable: (date: TDate) => boolean,
) => TDate | undefined;
}
/**
* A type-erased {@link DateAdapter}, used where the concrete date
* representation is not statically known (e.g. the global config context, which
* may hold an adapter for any date library). Recover the precise type at the
* consumer with `useDateAdapter<TDate>()`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional erasure across the inject boundary
export type AnyDateAdapter = DateAdapter<any>;
/**
* Default {@link DateAdapter} backed by the native JS `Date`. Delegates to the
* package's `Date`-based date utilities, so it is the zero-config behavior when
* no custom adapter is provided.
*/
export const nativeDateAdapter: DateAdapter<Date> = {
// --- construction / identity ---
now: () => new Date(),
clone: date => new Date(date.getTime()),
toDateOnly,
fromParts: ({ year, month, day, hour = 0, minute = 0, second = 0 }) =>
new Date(year, month - 1, day, hour, minute, second, 0),
getParts: date => ({
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
}),
getDay: date => date.getDay(),
parse: (text) => {
const parsed = new Date(text);
return Number.isNaN(parsed.getTime()) ? null : parsed;
},
toISO: date => date.toISOString(),
// --- arithmetic ---
addDays,
addMonths,
addYears,
startOfMonth,
endOfMonth,
startOfWeek,
getDaysInMonth,
// --- comparison ---
isSameDay,
isSameMonth,
isBefore,
isAfter,
compare: (a, b) => a.getTime() - b.getTime(),
clamp,
isDateUnavailable,
// --- locale / formatting ---
getLocaleWeekStartsOn,
format: formatDate,
formatWeekday,
formatMonthYear,
formatFullDate,
toIsoDate,
// --- grid building ---
getWeeks,
createMonths,
getWeekdayLabels,
findFirstFocusableDate,
};
@@ -0,0 +1,23 @@
export {
provideConfig,
provideAppConfig,
useConfig,
type ConfigContext,
type ConfigOptions,
type Direction,
type ScrollBodyOption,
type UseIdFn,
} from './context';
export { useId } from './useId';
export { useDirection } from './useDirection';
export { useLocale } from './useLocale';
export { useNonce } from './useNonce';
export {
nativeDateAdapter,
type AnyDateAdapter,
type DateAdapter,
type DateParts,
type ResolvedDateParts,
type WeekDay,
} from './date-adapter';
export { useDateAdapter } from './useDateAdapter';
@@ -0,0 +1,26 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import type { DateAdapter } from './date-adapter';
import { computed, toValue } from 'vue';
import { useConfig } from './context';
/**
* Resolves the effective {@link DateAdapter}: a per-component override wins over
* the active `ConfigProvider` `dateAdapter`, which falls back to the native
* `Date` adapter. Lets calendar/date-picker primitives route every date
* operation through a single, swappable backend instead of hard-coding `Date`.
*
* The type parameter `TDate` re-asserts the concrete date representation at the
* call site (the context stores a type-erased adapter), so a primitive built on
* a custom adapter gets correctly-typed dates back.
*
* @param override Optional per-component adapter override.
* @returns A computed `DateAdapter<TDate>` combining the override with config.
*/
export function useDateAdapter<TDate = Date>(
override?: MaybeRefOrGetter<DateAdapter<TDate> | undefined>,
): ComputedRef<DateAdapter<TDate>> {
const config = useConfig();
return computed(
() => (toValue(override) ?? config.dateAdapter.value) as DateAdapter<TDate>,
);
}
@@ -0,0 +1,21 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { computed, toValue } from 'vue';
import type { Direction } from './context';
import { useConfig } from './context';
/**
* Resolves the effective reading direction: a per-component override wins over
* the active `ConfigProvider` `dir`, falling back to `'ltr'`.
*
* Mirrors the `dir ?? config.dir.value` pattern used by primitive roots, as a
* reusable composable.
*
* @param dir Optional per-component direction override.
* @returns A computed `Direction` combining the override with the config.
*/
export function useDirection(
dir?: MaybeRefOrGetter<Direction | undefined>,
): ComputedRef<Direction> {
const config = useConfig();
return computed(() => toValue(dir) ?? config.dir.value ?? 'ltr');
}
@@ -0,0 +1,16 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { useConfig } from './context';
/**
* Primitives-local `useId` that routes through the active `ConfigContext`.
* Falls back to the toolkit's default implementation when no override is
* configured via `provideConfig({ useId })`.
*
* Signature matches `@robonen/vue`'s `useId`: `(deterministic?, prefix?)`.
*/
export function useId(
deterministic?: MaybeRefOrGetter<string | undefined>,
prefix?: string,
): ComputedRef<string> {
return useConfig().useId(deterministic, prefix);
}
@@ -0,0 +1,19 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { computed, toValue } from 'vue';
import { useConfig } from './context';
/**
* Resolves the effective locale: a per-component override wins over the active
* `ConfigProvider` `locale`, falling back to `'en'`. Lets date/calendar
* primitives inherit a single app-wide locale instead of repeating it per
* instance.
*
* @param locale Optional per-component locale override.
* @returns A computed locale string combining the override with the config.
*/
export function useLocale(
locale?: MaybeRefOrGetter<string | undefined>,
): ComputedRef<string> {
const config = useConfig();
return computed(() => toValue(locale) || config.locale.value || 'en');
}
@@ -0,0 +1,18 @@
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { computed, toValue } from 'vue';
import { useConfig } from './context';
/**
* Resolves the effective CSP `nonce`: a per-component override wins over the
* active `ConfigProvider` `nonce`. Lets style-injecting primitives inherit a
* single app-wide nonce instead of being passed one manually.
*
* @param nonce Optional per-component nonce override.
* @returns A computed nonce (or `undefined`) combining the override with config.
*/
export function useNonce(
nonce?: MaybeRefOrGetter<string | undefined>,
): ComputedRef<string | undefined> {
const config = useConfig();
return computed(() => toValue(nonce) || config.nonce.value);
}
@@ -0,0 +1,172 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A low-level building block that detects when the user interacts away from its
* content — pressing Escape, clicking/pointing outside, or moving focus out — and
* emits a `dismiss` event so the consumer can close the layer. Layers are tracked
* in a global stack so only the topmost one responds, letting dialogs, popovers,
* menus, and tooltips nest correctly. Use it to wrap any transient overlay whose
* lifecycle you want driven by outside-interaction; it renders no UI of its own.
*/
export interface DismissableLayerProps extends PrimitiveProps {
/**
* When enabled, outside pointer events are blocked — the rest of the
* document becomes `pointer-events: none`, and the layer gains
* `pointer-events: auto` so it is still interactive.
* @default false
*/
disableOutsidePointerEvents?: boolean;
}
export interface DismissableLayerEmits {
/** Escape key pressed while this layer is topmost. Call `event.preventDefault()` to suppress dismiss. */
escapeKeyDown: [event: KeyboardEvent];
/** Pointer down outside this layer. Preventable. */
pointerDownOutside: [event: PointerEvent | MouseEvent];
/** Focus moved outside this layer. Preventable. */
focusOutside: [event: FocusEvent];
/** Either pointer-outside or focus-outside. Preventable. */
interactOutside: [event: PointerEvent | MouseEvent | FocusEvent];
/** Fired after a non-prevented outside interaction or escape. */
dismiss: [];
}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { computed, onBeforeUnmount, onMounted, onWatcherCleanup, watch, watchPostEffect } from 'vue';
import { dismissableLayerStack, dismissableLayerVersion } from './stack';
import { useClickOutside, useEscapeKey, useEventListener, useForwardExpose } from '@robonen/vue';
const { disableOutsidePointerEvents = false, as = 'div' } = defineProps<DismissableLayerProps>();
const emit = defineEmits<DismissableLayerEmits>();
const { forwardRef, currentElement: nodeRef } = useForwardExpose();
const layer = { el: null as unknown as HTMLElement, disableOutsidePointerEvents: false };
// Resolve the document that actually owns the layer node so iframe / multi-window
// scenarios attach the focus listener and toggle the body style on the correct
// document, falling back to the global one before the node is resolved.
function ownerDocument(): Document {
return nodeRef.value?.ownerDocument ?? document;
}
// Use an explicit-source `watch` (not `watchEffect`) so the in-callback
// `touch()` write does not establish a dependency on the version ref and loop.
watch(() => disableOutsidePointerEvents, (value) => {
layer.disableOutsidePointerEvents = value;
// Re-derive every layer's pointer-events when this flag toggles.
dismissableLayerStack.touch();
}, { immediate: true });
onMounted(() => {
if (!nodeRef.value) return;
layer.el = nodeRef.value;
dismissableLayerStack.push(layer);
});
onBeforeUnmount(() => {
dismissableLayerStack.remove(layer);
});
// Per-layer `pointer-events`: while any layer blocks outside pointer events the
// body is `none`, so a layer at or above the highest blocking layer must stay
// interactive (`auto`), and a layer below it must be made non-interactive
// (`none`). Reading the reactive version keeps this in sync with stack mutations.
const pointerEvents = computed<'auto' | 'none' | undefined>(() => {
// Track stack mutations so the per-layer value re-derives when layers change.
void dismissableLayerVersion.value;
return dismissableLayerStack.pointerEventsFor(layer);
});
const layerStyle = computed(() =>
pointerEvents.value ? { pointerEvents: pointerEvents.value } : undefined,
);
// `focusin` is non-cancelable (and synthetic pointer events may be too), so the
// native `defaultPrevented` flag can never flip — track prevention via a patched
// `preventDefault` instead, keeping the "Preventable." emit contract honest.
function emitPreventable<E extends PointerEvent | MouseEvent | FocusEvent>(
event: E,
emitEvent: (event: E) => void,
): boolean {
let prevented = false;
const original = event.preventDefault;
event.preventDefault = () => {
prevented = true;
original.call(event);
};
emitEvent(event);
event.preventDefault = original;
return prevented || event.defaultPrevented;
}
useEscapeKey((event) => {
if (!dismissableLayerStack.isTopmost(layer)) return;
emit('escapeKeyDown', event);
if (!event.defaultPrevented) emit('dismiss');
});
useClickOutside(nodeRef, (event) => {
if (!dismissableLayerStack.isTopmost(layer)) return;
// Emit `interactOutside` first so consumers can cancel before the specific event fires.
if (emitPreventable(event, e => emit('interactOutside', e))) return;
if (emitPreventable(event, e => emit('pointerDownOutside', e))) return;
emit('dismiss');
}, {
// Interactions inside a registered branch (portaled trigger, anchor, toast
// viewport, …) are semantically *inside* this layer and must not dismiss it.
ignore: () => dismissableLayerStack.getBranches(),
});
// Focus outside detection — fires when focus leaves this layer to an element
// outside it. We use the `focusin` event at the owning-document level.
useEventListener(() => ownerDocument(), 'focusin', (event: FocusEvent) => {
const el = nodeRef.value;
const target = event.target as Node | null;
if (!el || !target) return;
if (el === target || el.contains(target)) return;
if (dismissableLayerStack.isInBranch(target)) return;
if (!dismissableLayerStack.isTopmost(layer)) return;
if (emitPreventable(event, e => emit('interactOutside', e))) return;
if (emitPreventable(event, e => emit('focusOutside', e))) return;
emit('dismiss');
});
// When this layer disables outside pointer events, the body gets a data
// attribute so consumers can style `[data-dismissable-blocking] *:not([data-dismissable-layer]) { pointer-events: none }`.
// `disableOutsidePointerEvents` is a reactive prop destructure — reading it
// inside `watchPostEffect` already registers the dependency, no need for a
// computed wrapper.
watchPostEffect(() => {
if (!disableOutsidePointerEvents) return;
if (typeof document === 'undefined') return;
const doc = ownerDocument();
const original = doc.body.style.pointerEvents;
doc.body.style.pointerEvents = 'none';
doc.body.dataset['dismissableBlocking'] = 'true';
onWatcherCleanup(() => {
// Only clear if no other disabling layer remains
if (!dismissableLayerStack.anyDisabling()) {
doc.body.style.pointerEvents = original;
delete doc.body.dataset['dismissableBlocking'];
}
});
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-dismissable-layer="true"
:style="layerStyle"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* Marks a subtree as belonging to a sibling DismissableLayer even though it is
* rendered elsewhere in the DOM (e.g. a portaled trigger, an anchor, or a toast
* viewport). Pointer-down and focus interactions that originate inside a branch
* are treated as *inside* the layer, so they will not trigger a dismiss. Renders
* no UI of its own beyond the element you ask for via `as`.
*/
export interface DismissableLayerBranchProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { onScopeDispose, watch } from 'vue';
import { dismissableLayerStack } from './stack';
import { useForwardExpose } from '@robonen/vue';
const { as = 'div' } = defineProps<DismissableLayerBranchProps>();
const { forwardRef, currentElement } = useForwardExpose();
// Register/unregister against the shared branch set as the resolved element
// changes (covers `as="template"` and conditional rendering). `onScopeDispose`
// guarantees teardown when the branch's effect scope is torn down.
watch(currentElement, (el, prev) => {
if (prev) dismissableLayerStack.removeBranch(prev);
if (el) dismissableLayerStack.addBranch(el);
});
onScopeDispose(() => {
if (currentElement.value) dismissableLayerStack.removeBranch(currentElement.value);
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,378 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import DismissableLayer from '../DismissableLayer.vue';
import DismissableLayerBranch from '../DismissableLayerBranch.vue';
describe('DismissableLayer', () => {
afterEach(() => {
document.body.innerHTML = '';
document.body.style.pointerEvents = '';
});
it('emits escapeKeyDown and dismiss on Escape when topmost', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>inside</button>' },
});
await nextTick();
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(w.emitted('escapeKeyDown')).toBeTruthy();
expect(w.emitted('dismiss')).toBeTruthy();
w.unmount();
});
it('does not dismiss when escapeKeyDown.preventDefault() is called', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>inside</button>' },
props: {
onEscapeKeyDown: (e: Event) => e.preventDefault(),
},
});
await nextTick();
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
expect(w.emitted('escapeKeyDown')).toBeTruthy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('emits pointerDownOutside on outside pointerdown', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button data-testid="inside">in</button>' },
});
await nextTick();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(w.emitted('pointerDownOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeTruthy();
w.unmount();
});
it('does not emit pointerDownOutside on inside pointerdown', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button data-testid="inside">in</button>' },
});
await nextTick();
const inside = w.find('[data-testid=inside]').element as HTMLElement;
inside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(w.emitted('pointerDownOutside')).toBeFalsy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('does not dismiss when pointerDownOutside.preventDefault() is called on a non-cancelable event', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>in</button>' },
props: {
onPointerDownOutside: (e: Event) => e.preventDefault(),
},
});
await nextTick();
// PointerEvent constructor defaults to cancelable: false — native
// defaultPrevented can never flip, prevention must be tracked separately.
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(w.emitted('pointerDownOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('emits focusOutside and dismiss when focus moves outside', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button data-testid="inside">in</button>' },
});
await nextTick();
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('focusOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeTruthy();
w.unmount();
});
it('does not emit focusOutside when focus moves inside', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button data-testid="inside">in</button>' },
});
await nextTick();
const inside = w.find('[data-testid=inside]').element as HTMLElement;
inside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('focusOutside')).toBeFalsy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('does not dismiss when focusOutside.preventDefault() is called (focusin is non-cancelable)', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>in</button>' },
props: {
onFocusOutside: (e: Event) => e.preventDefault(),
},
});
await nextTick();
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('focusOutside')).toBeTruthy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('does not dismiss nor emit the specific event when interactOutside.preventDefault() is called', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>in</button>' },
props: {
onInteractOutside: (e: Event) => e.preventDefault(),
},
});
await nextTick();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
outside.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(w.emitted('interactOutside')).toBeTruthy();
expect(w.emitted('pointerDownOutside')).toBeFalsy();
expect(w.emitted('focusOutside')).toBeFalsy();
expect(w.emitted('dismiss')).toBeFalsy();
w.unmount();
});
it('sets body pointer-events: none when disableOutsidePointerEvents is true', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
props: { disableOutsidePointerEvents: true },
slots: { default: '<button>x</button>' },
});
await nextTick();
expect(document.body.style.pointerEvents).toBe('none');
expect(document.body.dataset['dismissableBlocking']).toBe('true');
w.unmount();
expect(document.body.style.pointerEvents).toBe('');
expect(document.body.dataset['dismissableBlocking']).toBeUndefined();
});
it('only topmost layer handles dismiss when nested', async () => {
const onDismissBottom = vi.fn();
const onDismissTop = vi.fn();
const bottom = mount(DismissableLayer, {
attachTo: document.body,
props: { onDismiss: onDismissBottom },
slots: { default: '<button>bottom</button>' },
});
await nextTick();
const top = mount(DismissableLayer, {
attachTo: document.body,
props: { onDismiss: onDismissTop },
slots: { default: '<button>top</button>' },
});
await nextTick();
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onDismissTop).toHaveBeenCalledTimes(1);
expect(onDismissBottom).not.toHaveBeenCalled();
top.unmount();
globalThis.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onDismissBottom).toHaveBeenCalledTimes(1);
bottom.unmount();
});
describe('branches', () => {
function mountWithBranch(handlers: Record<string, unknown>) {
const Harness = defineComponent({
props: ['handlers'],
setup(props) {
return () =>
h('div', [
h(DismissableLayer, props.handlers, { default: () => h('button', 'inside') }),
h(DismissableLayerBranch, null, {
default: () => h('button', { 'data-testid': 'branch' }, 'related'),
}),
]);
},
});
return mount(Harness, { attachTo: document.body, props: { handlers } });
}
it('does not dismiss on pointerdown inside a registered branch', async () => {
const onDismiss = vi.fn();
const w = mountWithBranch({ onDismiss });
await nextTick();
await nextTick();
const branchBtn = w.find('[data-testid=branch]').element as HTMLElement;
branchBtn.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(onDismiss).not.toHaveBeenCalled();
w.unmount();
});
it('does not dismiss on focusin inside a registered branch', async () => {
const onDismiss = vi.fn();
const onFocusOutside = vi.fn();
const w = mountWithBranch({ onDismiss, onFocusOutside });
await nextTick();
await nextTick();
const branchBtn = w.find('[data-testid=branch]').element as HTMLElement;
branchBtn.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(onFocusOutside).not.toHaveBeenCalled();
expect(onDismiss).not.toHaveBeenCalled();
w.unmount();
});
it('still dismisses on pointerdown outside both layer and branch', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const onDismiss = vi.fn();
const w = mountWithBranch({ onDismiss });
await nextTick();
await nextTick();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(onDismiss).toHaveBeenCalledTimes(1);
w.unmount();
outside.remove();
});
it('unregisters the branch on unmount so it no longer shields outside clicks', async () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
const Harness = defineComponent({
components: { DismissableLayer, DismissableLayerBranch },
props: ['onDismiss', 'showBranch'],
setup(props) {
return () =>
h('div', [
h(DismissableLayer, { onDismiss: props.onDismiss }, { default: () => h('button', 'inside') }),
props.showBranch
? h(DismissableLayerBranch, null, { default: () => h('button', { 'data-testid': 'branch' }, 'related') })
: null,
]);
},
});
const onDismiss = vi.fn();
const w = mount(Harness, { attachTo: document.body, props: { onDismiss, showBranch: true } });
await nextTick();
await nextTick();
// Branch present: clicking it does not dismiss.
const branchBtn = w.find('[data-testid=branch]').element as HTMLElement;
branchBtn.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(onDismiss).not.toHaveBeenCalled();
// Remove the branch; a fresh outside pointerdown now dismisses.
await w.setProps({ showBranch: false });
await nextTick();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
expect(onDismiss).toHaveBeenCalledTimes(1);
w.unmount();
outside.remove();
});
});
describe('per-layer pointer-events', () => {
it('does not set a pointer-events override when no layer disables outside events', async () => {
const w = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>x</button>' },
});
await nextTick();
const el = w.find('[data-dismissable-layer]').element as HTMLElement;
expect(el.style.pointerEvents).toBe('');
w.unmount();
});
it('keeps the disabling layer interactive and makes a lower non-disabling layer inert', async () => {
const bottom = mount(DismissableLayer, {
attachTo: document.body,
slots: { default: '<button>bottom</button>' },
});
await nextTick();
const top = mount(DismissableLayer, {
attachTo: document.body,
props: { disableOutsidePointerEvents: true },
slots: { default: '<button>top</button>' },
});
await nextTick();
await nextTick();
const bottomEl = bottom.find('[data-dismissable-layer]').element as HTMLElement;
const topEl = top.find('[data-dismissable-layer]').element as HTMLElement;
// The blocking layer (top) stays clickable; the layer beneath it is sealed off.
expect(topEl.style.pointerEvents).toBe('auto');
expect(bottomEl.style.pointerEvents).toBe('none');
top.unmount();
await nextTick();
// Once the blocking layer is gone, the override is cleared everywhere.
expect(bottomEl.style.pointerEvents).toBe('');
bottom.unmount();
});
});
describe('DismissableLayerBranch', () => {
it('renders its slot through the polymorphic element and forwards as', async () => {
const w = mount(DismissableLayerBranch, {
attachTo: document.body,
props: { as: 'section' },
slots: { default: '<span data-testid="child">x</span>' },
});
await nextTick();
expect(w.element.tagName).toBe('SECTION');
expect(w.find('[data-testid=child]').exists()).toBe(true);
w.unmount();
});
});
});
@@ -0,0 +1,12 @@
export { default as DismissableLayer } from './DismissableLayer.vue';
export { default as DismissableLayerBranch } from './DismissableLayerBranch.vue';
export { dismissableLayerStack } from './stack';
export type {
DismissableLayerEmits,
DismissableLayerProps,
} from './DismissableLayer.vue';
export type {
DismissableLayerBranchProps,
} from './DismissableLayerBranch.vue';
@@ -0,0 +1,121 @@
import { shallowRef } from 'vue';
export interface DismissableLayerElement {
el: HTMLElement;
disableOutsidePointerEvents: boolean;
}
const layers: DismissableLayerElement[] = [];
// Subtrees that are physically rendered outside a layer's DOM node but belong to
// it semantically (e.g. a portaled trigger, an anchor, a toast viewport). Pointer
// and focus interactions originating inside a registered branch are treated as
// *inside* — they must never dismiss the layer.
const branches = new Set<HTMLElement>();
/**
* Reactive revision counter. Bumped whenever the stack or branch set mutates so
* that `computed`s reading derived stack state (e.g. per-layer pointer-events)
* re-evaluate. The stack itself stays a plain array for cheap, allocation-free
* topmost/index lookups on hot paths.
*/
export const dismissableLayerVersion = shallowRef(0);
function bump() {
dismissableLayerVersion.value++;
}
/**
* Module-level stack of active DismissableLayers. The most recently-pushed
* non-disabled layer is considered the topmost and is the only one that
* should dispatch dismiss-style events (escape, pointer-outside, focus-outside).
*/
export const dismissableLayerStack = {
push(layer: DismissableLayerElement) {
layers.push(layer);
bump();
},
remove(layer: DismissableLayerElement) {
const i = layers.indexOf(layer);
if (i !== -1) {
layers.splice(i, 1);
bump();
}
},
isTopmost(layer: DismissableLayerElement): boolean {
return layers.at(-1) === layer;
},
indexOf(layer: DismissableLayerElement): number {
return layers.indexOf(layer);
},
hasDisablingLayerAbove(layer: DismissableLayerElement): boolean {
const i = layers.indexOf(layer);
if (i === -1) return false;
return layers.slice(i + 1).some(l => l.disableOutsidePointerEvents);
},
any(): boolean {
return layers.length > 0;
},
anyDisabling(): boolean {
return layers.some(l => l.disableOutsidePointerEvents);
},
/** Notify derived `computed`s that stack-affecting layer state changed in place. */
touch() {
bump();
},
/**
* Resolves the `pointer-events` value a given layer should carry, mirroring the
* nested-modal contract: while any layer disables outside pointer events the
* document body is `pointer-events: none`. A layer at or above the highest
* disabling layer stays interactive (`auto`); a layer below it is made
* non-interactive (`none`). When nothing is blocking, no override is applied.
*/
pointerEventsFor(layer: DismissableLayerElement): 'auto' | 'none' | undefined {
let highestDisablingIndex = -1;
for (let i = layers.length - 1; i >= 0; i--) {
if (layers[i]!.disableOutsidePointerEvents) {
highestDisablingIndex = i;
break;
}
}
if (highestDisablingIndex === -1) return undefined;
const index = layers.indexOf(layer);
return index >= highestDisablingIndex ? 'auto' : 'none';
},
// Branch registry -------------------------------------------------------
addBranch(el: HTMLElement) {
branches.add(el);
bump();
},
removeBranch(el: HTMLElement) {
branches.delete(el);
bump();
},
/** Live snapshot of registered branch elements (for outside-detection ignore lists). */
getBranches(): HTMLElement[] {
return Array.from(branches);
},
/** Whether `target` lives inside any registered branch subtree. */
isInBranch(target: Node | null): boolean {
if (!target) return false;
for (const branch of branches) {
if (branch === target || branch.contains(target)) return true;
}
return false;
},
};
@@ -0,0 +1,101 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
export interface FocusScopeEmits {
/** Автофокус при монтировании. Можно предотвратить через `event.preventDefault()`. */
mountAutoFocus: [event: Event];
/** Автофокус при размонтировании. Можно предотвратить через `event.preventDefault()`. */
unmountAutoFocus: [event: Event];
}
/**
* A low-level building block that manages keyboard focus for its contents. On
* mount it can move focus inside (autofocus), on unmount it restores focus to
* the previously focused element, and while active it can loop Tab/Shift+Tab at
* the edges and/or trap focus so it cannot leave the container. Scopes are
* tracked in a global stack so nested scopes (e.g. a dialog opening over a
* popover) hand focus management back and forth correctly. Use it to wrap any
* overlay or modal surface that needs accessible focus containment; it renders
* no UI of its own. Emits `mountAutoFocus`/`unmountAutoFocus` so the consumer
* can override the default focus target.
*/
export interface FocusScopeProps extends PrimitiveProps {
/**
* Зациклить Tab/Shift+Tab: с последнего элемента — на первый и наоборот.
* @default false
*/
loop?: boolean;
/**
* Удерживать фокус внутри scope — фокус не может покинуть контейнер
* через клавиатуру, указатель или программный вызов.
* @default false
*/
trapped?: boolean;
}
</script>
<script setup lang="ts">
import type { FocusScopeAPI } from './stack';
import { Primitive } from '../../internal/primitive';
import { focus, getActiveElement, getTabbableEdges } from '@robonen/platform/browsers';
import { useAutoFocus } from './useAutoFocus';
import { useFocusTrap } from './useFocusTrap';
import { useForwardExpose } from '@robonen/vue';
const { loop = false, trapped = false, as } = defineProps<FocusScopeProps>();
const emit = defineEmits<FocusScopeEmits>();
const { forwardRef, currentElement: containerRef } = useForwardExpose();
const focusScope: FocusScopeAPI = {
paused: false,
pause() { this.paused = true; },
resume() { this.paused = false; },
};
useFocusTrap(containerRef, focusScope, () => trapped);
useAutoFocus(
containerRef,
focusScope,
ev => emit('mountAutoFocus', ev),
ev => emit('unmountAutoFocus', ev),
);
function handleKeyDown(event: KeyboardEvent) {
if (!loop && !trapped) return;
if (focusScope.paused) return;
const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
const focusedElement = getActiveElement();
if (!isTabKey || !focusedElement) return;
const container = event.currentTarget as HTMLElement;
const { first, last } = getTabbableEdges(container);
if (!first || !last) {
if (focusedElement === container) event.preventDefault();
}
else if (!event.shiftKey && focusedElement === last) {
event.preventDefault();
if (loop) focus(first, { select: true });
}
else if (event.shiftKey && focusedElement === first) {
event.preventDefault();
if (loop) focus(last, { select: true });
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
tabindex="-1"
:as="as"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,589 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { userEvent } from 'vitest/browser';
import { defineComponent, h, nextTick, ref } from 'vue';
import { render } from 'vitest-browser-vue';
import FocusScope from '../FocusScope.vue';
import { mount } from '@vue/test-utils';
function createFocusScope(props: Record<string, unknown> = {}, slots?: Record<string, () => any>) {
return mount(
defineComponent({
setup() {
return () =>
h(
FocusScope,
props,
slots ?? {
default: () => [
h('input', { type: 'text', 'data-testid': 'first' }),
h('input', { type: 'text', 'data-testid': 'second' }),
h('input', { type: 'text', 'data-testid': 'third' }),
],
},
);
},
}),
{ attachTo: document.body },
);
}
describe('FocusScope', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.body.focus();
});
it('renders slot content inside a div with tabindex="-1"', () => {
const wrapper = createFocusScope();
expect(wrapper.find('[tabindex="-1"]').exists()).toBe(true);
expect(wrapper.findAll('input').length).toBe(3);
wrapper.unmount();
});
it('renders with custom element via as prop', () => {
const wrapper = createFocusScope({ as: 'section' });
expect(wrapper.find('section').exists()).toBe(true);
expect(wrapper.find('section').attributes('tabindex')).toBe('-1');
wrapper.unmount();
});
it('auto-focuses first tabbable element on mount', async () => {
const wrapper = createFocusScope();
await nextTick();
await nextTick();
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
expect(document.activeElement).toBe(firstInput);
wrapper.unmount();
});
it('emits mountAutoFocus on mount', async () => {
const onMountAutoFocus = vi.fn();
const wrapper = createFocusScope({ onMountAutoFocus });
await nextTick();
await nextTick();
expect(onMountAutoFocus).toHaveBeenCalled();
wrapper.unmount();
});
it('emits unmountAutoFocus on unmount', async () => {
const onUnmountAutoFocus = vi.fn();
const wrapper = createFocusScope({ onUnmountAutoFocus });
await nextTick();
await nextTick();
wrapper.unmount();
expect(onUnmountAutoFocus).toHaveBeenCalled();
});
it('focuses container when no tabbable elements exist', async () => {
const wrapper = createFocusScope({}, {
default: () => h('span', 'no focusable elements'),
});
await nextTick();
await nextTick();
const container = wrapper.find('[tabindex="-1"]').element;
expect(document.activeElement).toBe(container);
wrapper.unmount();
});
});
describe('FocusScope loop', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.body.focus();
});
it('wraps focus from last to first on Tab when loop=true', async () => {
const wrapper = createFocusScope({ loop: true });
await nextTick();
await nextTick();
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
lastInput.focus();
await nextTick();
const container = wrapper.find('[tabindex="-1"]');
await container.trigger('keydown', { key: 'Tab' });
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
expect(document.activeElement).toBe(firstInput);
wrapper.unmount();
});
it('wraps focus from first to last on Shift+Tab when loop=true', async () => {
const wrapper = createFocusScope({ loop: true });
await nextTick();
await nextTick();
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
firstInput.focus();
await nextTick();
const container = wrapper.find('[tabindex="-1"]');
await container.trigger('keydown', { key: 'Tab', shiftKey: true });
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
expect(document.activeElement).toBe(lastInput);
wrapper.unmount();
});
it('does not wrap focus when loop=false', async () => {
const wrapper = createFocusScope({ loop: false });
await nextTick();
await nextTick();
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
lastInput.focus();
await nextTick();
const container = wrapper.find('[tabindex="-1"]');
await container.trigger('keydown', { key: 'Tab' });
// Focus should remain on the last element (no wrapping)
expect(document.activeElement).toBe(lastInput);
wrapper.unmount();
});
it('ignores non-Tab keys', async () => {
const wrapper = createFocusScope({ loop: true });
await nextTick();
await nextTick();
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
lastInput.focus();
await nextTick();
const container = wrapper.find('[tabindex="-1"]');
await container.trigger('keydown', { key: 'Enter' });
expect(document.activeElement).toBe(lastInput);
wrapper.unmount();
});
it('ignores Tab with modifier keys', async () => {
const wrapper = createFocusScope({ loop: true });
await nextTick();
await nextTick();
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
lastInput.focus();
await nextTick();
const container = wrapper.find('[tabindex="-1"]');
await container.trigger('keydown', { key: 'Tab', ctrlKey: true });
expect(document.activeElement).toBe(lastInput);
wrapper.unmount();
});
});
describe('FocusScope trapped', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.body.focus();
});
it('returns focus to last focused element when focus leaves', async () => {
const wrapper = mount(
defineComponent({
setup() {
return () => [
h('button', { id: 'outside' }, 'outside'),
h(FocusScope, { trapped: true }, {
default: () => [
h('input', { type: 'text', 'data-testid': 'inside' }),
],
}),
];
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextTick();
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
expect(document.activeElement).toBe(insideInput);
// Simulate focus moving outside
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
outsideButton.focus();
// The focusin event handler should bring focus back
await nextTick();
expect(document.activeElement).toBe(insideInput);
wrapper.unmount();
});
it('activates trap when trapped changes from false to true', async () => {
const trapped = ref(false);
const wrapper = mount(
defineComponent({
setup() {
return () => [
h('button', { id: 'outside' }, 'outside'),
h(FocusScope, { trapped: trapped.value }, {
default: () => [
h('input', { type: 'text', 'data-testid': 'inside' }),
],
}),
];
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextTick();
// Not trapped yet — focus can leave
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
outsideButton.focus();
await nextTick();
expect(document.activeElement).toBe(outsideButton);
// Enable trap
trapped.value = true;
await nextTick();
await nextTick();
// Focus inside first
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
insideInput.focus();
await nextTick();
// Try to leave — should be pulled back
outsideButton.focus();
await nextTick();
expect(document.activeElement).toBe(insideInput);
wrapper.unmount();
});
it('deactivates trap when trapped changes from true to false', async () => {
const trapped = ref(true);
const wrapper = mount(
defineComponent({
setup() {
return () => [
h('button', { id: 'outside' }, 'outside'),
h(FocusScope, { trapped: trapped.value }, {
default: () => [
h('input', { type: 'text', 'data-testid': 'inside' }),
],
}),
];
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextTick();
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
expect(document.activeElement).toBe(insideInput);
// Disable trap
trapped.value = false;
await nextTick();
await nextTick();
// Focus can now leave
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
outsideButton.focus();
await nextTick();
expect(document.activeElement).toBe(outsideButton);
wrapper.unmount();
});
it('refocuses container when focused element is removed from DOM', async () => {
const showChild = ref(true);
const wrapper = mount(
defineComponent({
setup() {
return () =>
h(FocusScope, { trapped: true }, {
default: () =>
showChild.value
? [h('input', { type: 'text', 'data-testid': 'removable' })]
: [h('span', 'empty')],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextTick();
const input = wrapper.find('[data-testid="removable"]').element as HTMLInputElement;
expect(document.activeElement).toBe(input);
// Remove the focused element
showChild.value = false;
await nextTick();
await nextTick();
// MutationObserver should refocus the container
const container = wrapper.find('[tabindex="-1"]').element;
await vi.waitFor(() => {
expect(document.activeElement).toBe(container);
});
wrapper.unmount();
});
});
describe('FocusScope preventAutoFocus', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.body.focus();
});
it('prevents auto-focus on mount via event.preventDefault()', async () => {
const wrapper = createFocusScope({
onMountAutoFocus: (e: Event) => e.preventDefault(),
});
await nextTick();
await nextTick();
const firstInput = wrapper.find('[data-testid="first"]').element;
// Focus should not have been moved to the first input
expect(document.activeElement).not.toBe(firstInput);
wrapper.unmount();
});
it('prevents focus restore on unmount via event.preventDefault()', async () => {
const wrapper = createFocusScope({
onUnmountAutoFocus: (e: Event) => e.preventDefault(),
});
await nextTick();
await nextTick();
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
expect(document.activeElement).toBe(firstInput);
wrapper.unmount();
// Focus should NOT have been restored to body
expect(document.activeElement).not.toBe(firstInput);
});
});
describe('FocusScope nested stacks', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.body.focus();
});
it('pauses outer scope when inner scope mounts, resumes on inner unmount', async () => {
const showInner = ref(false);
const wrapper = mount(
defineComponent({
setup() {
return () =>
h(FocusScope, { trapped: true }, {
default: () => [
h('input', { type: 'text', 'data-testid': 'outer-input' }),
showInner.value
? h(FocusScope, { trapped: true }, {
default: () => [
h('input', { type: 'text', 'data-testid': 'inner-input' }),
],
})
: null,
],
});
},
}),
{ attachTo: document.body },
);
await nextTick();
await nextTick();
// Outer scope auto-focused
const outerInput = wrapper.find('[data-testid="outer-input"]').element as HTMLInputElement;
expect(document.activeElement).toBe(outerInput);
// Mount inner scope
showInner.value = true;
await nextTick();
await nextTick();
// Inner scope should auto-focus its content
const innerInput = wrapper.find('[data-testid="inner-input"]').element as HTMLInputElement;
expect(document.activeElement).toBe(innerInput);
// Unmount inner scope
showInner.value = false;
await nextTick();
await nextTick();
// Focus should return to outer scope's previously focused element
await vi.waitFor(() => {
expect(document.activeElement).toBe(outerInput);
});
wrapper.unmount();
});
});
function mountInDom<T>(component: T) {
const host = document.createElement('div');
document.body.appendChild(host);
return render(component, { container: host });
}
describe('focusScope (browser)', () => {
it('autofocuses first tabbable element on mount', async () => {
const Wrapper = defineComponent({
setup() {
const open = ref(false);
return { open };
},
render() {
return h('div', [
h('button', { id: 'outside', onClick: () => { this.open = true; } }, 'Open'),
this.open
? h(FocusScope, { trapped: true }, {
default: () => [
h('button', { id: 'inside-1' }, 'Inside 1'),
h('button', { id: 'inside-2' }, 'Inside 2'),
],
})
: null,
]);
},
});
const { container } = mountInDom(Wrapper);
const outside = container.querySelector<HTMLButtonElement>('#outside')!;
outside.focus();
expect(document.activeElement).toBe(outside);
await userEvent.click(outside);
await nextTick();
await new Promise((r) => {
requestAnimationFrame(() => r(null));
});
const inside1 = container.querySelector<HTMLButtonElement>('#inside-1')!;
expect(document.activeElement).toBe(inside1);
});
it('traps Tab within scope (loop)', async () => {
const Wrapper = defineComponent({
render() {
return h(FocusScope, { trapped: true, loop: true }, {
default: () => [
h('button', { id: 'a' }, 'A'),
h('button', { id: 'b' }, 'B'),
h('button', { id: 'c' }, 'C'),
],
});
},
});
const { container } = mountInDom(Wrapper);
await new Promise((r) => {
requestAnimationFrame(() => requestAnimationFrame(() => r(null)));
});
await nextTick();
const a = container.querySelector<HTMLButtonElement>('#a')!;
const b = container.querySelector<HTMLButtonElement>('#b')!;
const c = container.querySelector<HTMLButtonElement>('#c')!;
if (document.activeElement !== a) a.focus();
expect(document.activeElement).toBe(a);
await userEvent.keyboard('{Tab}');
expect(document.activeElement).toBe(b);
await userEvent.keyboard('{Tab}');
expect(document.activeElement).toBe(c);
await userEvent.keyboard('{Tab}');
expect(document.activeElement).toBe(a);
await userEvent.keyboard('{Shift>}{Tab}{/Shift}');
expect(document.activeElement).toBe(c);
});
it('returns focus to previously focused element on unmount', async () => {
const Wrapper = defineComponent({
setup() {
const open = ref(false);
return { open };
},
render() {
return h('div', [
h('button', {
id: 'trigger',
onClick: () => { this.open = !this.open; },
}, 'Toggle'),
this.open
? h(FocusScope, { trapped: true }, {
default: () => [h('button', { id: 'inside' }, 'Inside')],
})
: null,
]);
},
});
const { container } = mountInDom(Wrapper);
const trigger = container.querySelector<HTMLButtonElement>('#trigger')!;
trigger.focus();
await userEvent.click(trigger);
await new Promise((r) => {
requestAnimationFrame(() => r(null));
});
expect(document.activeElement?.id).toBe('inside');
await userEvent.click(trigger);
await new Promise((r) => {
requestAnimationFrame(() => r(null));
});
expect(document.activeElement).toBe(trigger);
});
it('honors preventDefault on mountAutoFocus', async () => {
const Wrapper = defineComponent({
render() {
return h(FocusScope, {
trapped: true,
onMountAutoFocus: (e: Event) => e.preventDefault(),
}, {
default: () => [h('button', { id: 'x' }, 'X')],
});
},
});
mountInDom(Wrapper);
await new Promise((r) => {
requestAnimationFrame(() => r(null));
});
expect(document.activeElement?.tagName).not.toBe('BUTTON');
});
});
@@ -0,0 +1,67 @@
import { defineComponent, h, nextTick } from 'vue';
import { describe, expect, it } from 'vitest';
import FocusScope from '../FocusScope.vue';
import axe from 'axe-core';
import { mount } from '@vue/test-utils';
async function checkA11y(element: Element) {
const results = await axe.run(element);
return results.violations;
}
function createFocusScope(props: Record<string, unknown> = {}) {
return mount(
defineComponent({
setup() {
return () =>
h(
FocusScope,
props,
{
default: () => [
h('button', { type: 'button' }, 'First'),
h('button', { type: 'button' }, 'Second'),
h('button', { type: 'button' }, 'Third'),
],
},
);
},
}),
{ attachTo: document.body },
);
}
describe('FocusScope a11y', () => {
it('has no axe violations with default props', async () => {
const wrapper = createFocusScope();
await nextTick();
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
it('has no axe violations with loop enabled', async () => {
const wrapper = createFocusScope({ loop: true });
await nextTick();
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
it('has no axe violations with trapped enabled', async () => {
const wrapper = createFocusScope({ trapped: true });
await nextTick();
await nextTick();
const violations = await checkA11y(wrapper.element);
expect(violations).toEqual([]);
wrapper.unmount();
});
});
@@ -0,0 +1,3 @@
export { default as FocusScope } from './FocusScope.vue';
export type { FocusScopeEmits, FocusScopeProps } from './FocusScope.vue';
@@ -0,0 +1,29 @@
export interface FocusScopeAPI {
paused: boolean;
pause: () => void;
resume: () => void;
}
const stack: FocusScopeAPI[] = [];
export function createFocusScopesStack() {
return {
add(focusScope: FocusScopeAPI) {
const current = stack.at(-1);
if (focusScope !== current) current?.pause();
// Remove if already in stack (deduplicate), then push to top
const index = stack.indexOf(focusScope);
if (index !== -1) stack.splice(index, 1);
stack.push(focusScope);
},
remove(focusScope: FocusScopeAPI) {
const index = stack.indexOf(focusScope);
if (index !== -1) stack.splice(index, 1);
stack.at(-1)?.resume();
},
};
}
@@ -0,0 +1,63 @@
import {
AUTOFOCUS_ON_MOUNT,
AUTOFOCUS_ON_UNMOUNT,
EVENT_OPTIONS,
focus,
focusFirst,
getActiveElement,
getTabbableCandidates,
} from '@robonen/platform/browsers';
import type { FocusScopeAPI } from './stack';
import type { Ref } from 'vue';
import { createFocusScopesStack } from './stack';
import { onWatcherCleanup, watchPostEffect } from 'vue';
function dispatchCancelableEvent(
container: HTMLElement,
eventName: string,
handler: (ev: Event) => void,
): CustomEvent {
const event = new CustomEvent(eventName, EVENT_OPTIONS);
container.addEventListener(eventName, handler);
container.dispatchEvent(event);
container.removeEventListener(eventName, handler);
return event;
}
export function useAutoFocus(
container: Readonly<Ref<HTMLElement | null | undefined>>,
focusScope: FocusScopeAPI,
onMountAutoFocus: (ev: Event) => void,
onUnmountAutoFocus: (ev: Event) => void,
) {
const stack = createFocusScopesStack();
watchPostEffect(() => {
const el = container.value;
if (!el) return;
stack.add(focusScope);
const previouslyFocusedElement = getActiveElement();
if (!el.contains(previouslyFocusedElement)) {
const event = dispatchCancelableEvent(el, AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
if (!event.defaultPrevented) {
focusFirst(getTabbableCandidates(el), { select: true });
if (getActiveElement() === previouslyFocusedElement)
focus(el);
}
}
onWatcherCleanup(() => {
const event = dispatchCancelableEvent(el, AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
if (!event.defaultPrevented) {
focus(previouslyFocusedElement ?? document.body, { select: true });
}
stack.remove(focusScope);
});
});
}
@@ -0,0 +1,60 @@
import type { MaybeRefOrGetter, Ref } from 'vue';
import { onWatcherCleanup, shallowRef, toValue, watchPostEffect } from 'vue';
import type { FocusScopeAPI } from './stack';
import { focus } from '@robonen/platform/browsers';
export function useFocusTrap(
container: Readonly<Ref<HTMLElement | null | undefined>>,
focusScope: FocusScopeAPI,
trapped: MaybeRefOrGetter<boolean>,
) {
const lastFocusedElement = shallowRef<HTMLElement | null>(null);
watchPostEffect(() => {
const el = container.value;
if (!toValue(trapped) || !el) return;
function handleFocusIn(event: FocusEvent) {
if (focusScope.paused || !el) return;
const target = event.target as HTMLElement | null;
if (el.contains(target)) {
lastFocusedElement.value = target;
}
else {
focus(lastFocusedElement.value, { select: true });
}
}
function handleFocusOut(event: FocusEvent) {
if (focusScope.paused || !el) return;
const relatedTarget = event.relatedTarget as HTMLElement | null;
// null relatedTarget = браузер/вкладка потеряла фокус или элемент удалён из DOM.
if (relatedTarget === null) return;
if (!el.contains(relatedTarget)) {
focus(lastFocusedElement.value, { select: true });
}
}
function handleMutations() {
if (!el!.contains(lastFocusedElement.value))
focus(el!);
}
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
const observer = new MutationObserver(handleMutations);
observer.observe(el, { childList: true, subtree: true });
onWatcherCleanup(() => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
observer.disconnect();
});
});
}
@@ -0,0 +1,50 @@
<script lang="ts">
/**
* Controls the mount/unmount lifecycle of a single child while respecting CSS
* enter/leave animations, keeping it in the DOM until any exit animation has
* finished. Use it as the foundation for show/hide parts (dialog content,
* tooltips, collapsible panels) so they animate out before being removed,
* rather than disappearing instantly when their `present` state flips to false.
*
* `Presence` is a headless building block: it renders its child via `Slot`
* (merging attributes onto it), wires up animation tracking, and exposes the
* resolved presence to the default slot so children can reflect it (e.g. as a
* `data-state`). The same logic is available standalone through `usePresence`.
*/
export interface PresenceProps {
/** Whether the child should be present. While `false`, the child stays mounted until any leave animation completes. */
present: boolean;
/** Keep the child mounted regardless of `present` (useful for animation control or measuring while hidden). */
forceMount?: boolean;
}
export default {
inheritAttrs: false,
};
</script>
<script setup lang="ts">
import { Slot } from '../../internal/primitive/Slot';
import { usePresence } from './usePresence';
const {
present,
forceMount = false,
} = defineProps<PresenceProps>();
defineSlots<{
default?: (props: { present: boolean }) => unknown;
}>();
const { isPresent, setRef } = usePresence(() => present);
defineExpose({ present: isPresent });
</script>
<template>
<Slot v-if="forceMount || isPresent" :ref="setRef" v-bind="$attrs">
<slot :present="isPresent" />
</Slot>
</template>
@@ -0,0 +1,417 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ref } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { usePresence } from '../usePresence';
import Presence from '../Presence.vue';
import {
dispatchAnimationEvent,
getAnimationName,
onAnimationSettle,
shouldSuspendUnmount,
} from '@robonen/platform/browsers';
vi.mock('@robonen/platform/browsers', () => ({
getAnimationName: vi.fn(() => 'none'),
shouldSuspendUnmount: vi.fn(() => false),
dispatchAnimationEvent: vi.fn((el, name) => {
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
}),
onAnimationSettle: vi.fn(() => vi.fn()),
}));
const mockGetAnimationName = vi.mocked(getAnimationName);
const mockShouldSuspend = vi.mocked(shouldSuspendUnmount);
const mockDispatchEvent = vi.mocked(dispatchAnimationEvent);
const mockOnSettle = vi.mocked(onAnimationSettle);
function mountUsePresence(initial: boolean) {
const present = ref(initial);
const wrapper = mount(defineComponent({
setup() {
const { isPresent } = usePresence(present);
return { isPresent };
},
render() {
return h('div', this.isPresent ? 'visible' : 'hidden');
},
}));
return { wrapper, present };
}
function mountPresenceWithAnimation(present: Ref<boolean>) {
return mount(defineComponent({
setup() {
const { isPresent, setRef } = usePresence(present);
return { isPresent, setRef };
},
render() {
if (!this.isPresent) return h('div', 'hidden');
return h('div', {
ref: (el: any) => this.setRef(el),
}, 'visible');
},
}));
}
function findDispatchCall(name: string) {
return mockDispatchEvent.mock.calls.find(([, n]) => n === name);
}
describe('usePresence', () => {
it('returns isPresent=true when present is true', () => {
const { wrapper } = mountUsePresence(true);
expect(wrapper.text()).toBe('visible');
wrapper.unmount();
});
it('returns isPresent=false when present is false', () => {
const { wrapper } = mountUsePresence(false);
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('transitions to unmounted immediately when no animation', async () => {
const { wrapper, present } = mountUsePresence(true);
expect(wrapper.text()).toBe('visible');
present.value = false;
await nextTick();
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('transitions to mounted when present becomes true', async () => {
const { wrapper, present } = mountUsePresence(false);
expect(wrapper.text()).toBe('hidden');
present.value = true;
await nextTick();
expect(wrapper.text()).toBe('visible');
wrapper.unmount();
});
});
describe('Presence', () => {
it('renders child when present is true', () => {
const wrapper = mount(Presence, {
props: { present: true },
slots: { default: () => h('div', 'content') },
});
expect(wrapper.html()).toContain('content');
expect(wrapper.find('div').exists()).toBe(true);
wrapper.unmount();
});
it('does not render child when present is false', () => {
const wrapper = mount(Presence, {
props: { present: false },
slots: { default: () => h('div', 'content') },
});
expect(wrapper.html()).not.toContain('content');
wrapper.unmount();
});
it('removes child when present becomes false (no animation)', async () => {
const present = ref(true);
const wrapper = mount(defineComponent({
setup() {
return () => h(Presence, { present: present.value }, {
default: () => h('span', 'hello'),
});
},
}));
expect(wrapper.find('span').exists()).toBe(true);
present.value = false;
await nextTick();
expect(wrapper.find('span').exists()).toBe(false);
wrapper.unmount();
});
it('adds child when present becomes true', async () => {
const present = ref(false);
const wrapper = mount(defineComponent({
setup() {
return () => h(Presence, { present: present.value }, {
default: () => h('span', 'hello'),
});
},
}));
expect(wrapper.find('span').exists()).toBe(false);
present.value = true;
await nextTick();
expect(wrapper.find('span').exists()).toBe(true);
wrapper.unmount();
});
it('always renders child when forceMount is true', () => {
const wrapper = mount(Presence, {
props: { present: false, forceMount: true },
slots: { default: () => h('span', 'always') },
});
expect(wrapper.find('span').exists()).toBe(true);
expect(wrapper.find('span').text()).toBe('always');
wrapper.unmount();
});
it('exposes present state via scoped slot', () => {
let slotPresent: boolean | undefined;
const wrapper = mount(Presence, {
props: { present: true },
slots: {
default: (props: { present: boolean }) => {
slotPresent = props.present;
return h('div', 'content');
},
},
});
expect(slotPresent).toBe(true);
wrapper.unmount();
});
it('exposes present=false via scoped slot when forceMount and not present', () => {
let slotPresent: boolean | undefined;
const wrapper = mount(Presence, {
props: { present: false, forceMount: true },
slots: {
default: (props: { present: boolean }) => {
slotPresent = props.present;
return h('div', 'content');
},
},
});
expect(slotPresent).toBe(false);
wrapper.unmount();
});
it('forwards attrs to slot child', () => {
const wrapper = mount(Presence, {
props: { present: true },
attrs: { class: 'fade', 'data-testid': 'box' },
slots: { default: () => h('div', 'content') },
});
const div = wrapper.find('div');
expect(div.classes()).toContain('fade');
expect(div.attributes('data-testid')).toBe('box');
wrapper.unmount();
});
it('forwards style to slot child', () => {
const wrapper = mount(Presence, {
props: { present: true },
attrs: { style: 'color: red' },
slots: { default: () => h('div', 'content') },
});
expect(wrapper.find('div').attributes('style')).toContain('color: red');
wrapper.unmount();
});
it('merges attrs when child already has attrs', () => {
const wrapper = mount(Presence, {
props: { present: true },
attrs: { class: 'outer' },
slots: { default: () => h('div', { class: 'inner' }, 'content') },
});
const div = wrapper.find('div');
expect(div.classes()).toContain('outer');
expect(div.classes()).toContain('inner');
wrapper.unmount();
});
it('does not render attrs when not present', () => {
const wrapper = mount(Presence, {
props: { present: false },
attrs: { class: 'fade' },
slots: { default: () => h('div', 'content') },
});
expect(wrapper.find('div').exists()).toBe(false);
wrapper.unmount();
});
it('forwards attrs with forceMount', () => {
const wrapper = mount(Presence, {
props: { present: false, forceMount: true },
attrs: { class: 'fade', 'data-testid': 'forced' },
slots: { default: () => h('div', 'content') },
});
const div = wrapper.find('div');
expect(div.classes()).toContain('fade');
expect(div.attributes('data-testid')).toBe('forced');
wrapper.unmount();
});
});
describe('usePresence (animation)', () => {
beforeEach(() => {
mockGetAnimationName.mockReturnValue('none');
mockShouldSuspend.mockReturnValue(false);
mockOnSettle.mockImplementation(() => vi.fn());
mockDispatchEvent.mockClear();
});
it('dispatches enter and after-enter when present becomes true (no animation)', async () => {
const present = ref(false);
const wrapper = mountPresenceWithAnimation(present);
mockDispatchEvent.mockClear();
present.value = true;
await nextTick();
expect(findDispatchCall('enter')).toBeTruthy();
expect(findDispatchCall('after-enter')).toBeTruthy();
wrapper.unmount();
});
it('dispatches leave and after-leave when no animation on leave', async () => {
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
mockDispatchEvent.mockClear();
present.value = false;
await nextTick();
expect(findDispatchCall('leave')).toBeTruthy();
expect(findDispatchCall('after-leave')).toBeTruthy();
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('suspends unmount when shouldSuspendUnmount returns true', async () => {
mockShouldSuspend.mockReturnValue(true);
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
mockDispatchEvent.mockClear();
present.value = false;
await nextTick();
expect(findDispatchCall('leave')).toBeTruthy();
expect(findDispatchCall('after-leave')).toBeUndefined();
expect(wrapper.text()).toBe('visible');
wrapper.unmount();
});
it('dispatches after-leave and unmounts when animation settles', async () => {
mockShouldSuspend.mockReturnValue(true);
let settleCallback: (() => void) | undefined;
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
settleCallback = callbacks.onSettle;
return vi.fn();
});
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
mockDispatchEvent.mockClear();
present.value = false;
await nextTick();
expect(wrapper.text()).toBe('visible');
settleCallback!();
await nextTick();
expect(findDispatchCall('after-leave')).toBeTruthy();
expect(wrapper.text()).toBe('hidden');
wrapper.unmount();
});
it('tracks animation name on start via onAnimationSettle', async () => {
let startCallback: ((name: string) => void) | undefined;
mockOnSettle.mockImplementation((_el: any, callbacks: any) => {
startCallback = callbacks.onStart;
return vi.fn();
});
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
expect(startCallback).toBeDefined();
wrapper.unmount();
});
it('calls cleanup returned by onAnimationSettle on unmount', async () => {
const cleanupFn = vi.fn();
mockOnSettle.mockReturnValue(cleanupFn);
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
wrapper.unmount();
});
it('setRef connects DOM element for animation tracking', async () => {
const present = ref(true);
const wrapper = mountPresenceWithAnimation(present);
await nextTick();
expect(wrapper.text()).toBe('visible');
expect(mockOnSettle).toHaveBeenCalled();
expect(mockOnSettle.mock.calls[0]![0]).toBeInstanceOf(HTMLElement);
wrapper.unmount();
});
it('resets isAnimating when node ref becomes undefined', async () => {
mockShouldSuspend.mockReturnValue(true);
mockOnSettle.mockImplementation(() => vi.fn());
const present = ref(true);
const showEl = ref(true);
const wrapper = mount(defineComponent({
setup() {
const { isPresent, setRef } = usePresence(present);
return { isPresent, setRef, showEl };
},
render() {
if (!showEl.value) {
this.setRef(undefined);
return h('div', 'no-el');
}
return h('div', {
ref: (el: any) => this.setRef(el),
}, this.isPresent ? 'visible' : 'hidden');
},
}));
await nextTick();
expect(wrapper.text()).toBe('visible');
showEl.value = false;
await nextTick();
expect(wrapper.text()).toBe('no-el');
wrapper.unmount();
});
});
@@ -0,0 +1,5 @@
export { default as Presence } from './Presence.vue';
export { usePresence } from './usePresence';
export type { PresenceProps } from './Presence.vue';
export type { UsePresenceReturn } from './usePresence';
@@ -0,0 +1,84 @@
import type { MaybeRefOrGetter, Ref } from 'vue';
import { computed, onWatcherCleanup, readonly, shallowRef, toValue, watch } from 'vue';
import {
dispatchAnimationEvent,
getAnimationName,
onAnimationSettle,
shouldSuspendUnmount,
} from '@robonen/platform/browsers';
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
import type { MaybeElement } from '@robonen/vue';
export interface UsePresenceReturn {
isPresent: Readonly<Ref<boolean>>;
setRef: (v: unknown) => void;
}
export function usePresence(
present: MaybeRefOrGetter<boolean>,
): UsePresenceReturn {
const node = shallowRef<HTMLElement>();
const isAnimating = shallowRef(false);
let prevAnimationName = 'none';
const isPresent = computed(() => toValue(present) || isAnimating.value);
watch(isPresent, (current) => {
prevAnimationName = current ? getAnimationName(node.value) : 'none';
});
watch(() => toValue(present), (value, oldValue) => {
if (value === oldValue) return;
if (value) {
isAnimating.value = false;
dispatchAnimationEvent(node.value, 'enter');
if (getAnimationName(node.value) === 'none') {
dispatchAnimationEvent(node.value, 'after-enter');
}
}
else {
isAnimating.value = shouldSuspendUnmount(node.value, prevAnimationName);
dispatchAnimationEvent(node.value, 'leave');
if (!isAnimating.value) {
dispatchAnimationEvent(node.value, 'after-leave');
}
}
}, { flush: 'sync' });
watch(node, (el) => {
if (el) {
const cleanup = onAnimationSettle(el, {
onSettle: () => {
const direction = toValue(present) ? 'enter' : 'leave';
dispatchAnimationEvent(el, `after-${direction}`);
isAnimating.value = false;
},
onStart: (animationName) => {
prevAnimationName = animationName;
},
});
onWatcherCleanup(cleanup);
}
else {
isAnimating.value = false;
}
});
tryOnScopeDispose(() => {
isAnimating.value = false;
});
function setRef(v: unknown) {
const el = unrefElement(v as MaybeElement);
node.value = el instanceof HTMLElement ? el : undefined;
}
return {
isPresent: readonly(isPresent),
setRef,
};
}
@@ -0,0 +1,190 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { Orientation } from './utils';
import type { PrimitiveProps } from '../../internal/primitive';
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
/**
* A low-level building block that gives a set of items a single shared tab stop
* and lets arrow keys "rove" focus between them — the keyboard model behind
* toolbars, tabs, radio groups, menus and similar widgets. Only one item is in
* the page tab order at a time; Arrow/Home/End keys move focus and update the
* current tab stop, honouring `orientation`, writing `dir` (RTL-aware) and
* optional `loop` wrapping. Wrap focusable children in `RovingFocusItem`; this
* group renders a thin container and manages entry/exit focus, emitting
* `entryFocus` so consumers can override where focus lands on first entry.
*/
export interface RovingFocusGroupProps extends PrimitiveProps {
/** Navigation orientation — decides which arrow keys move focus. */
orientation?: Orientation;
/** Writing direction (LTR / RTL). Falls back to `useConfig().dir`. */
dir?: Direction;
/**
* Wrap around at the ends.
* @default false
*/
loop?: boolean;
/**
* Controlled current tab-stop id — bind with `v-model:currentTabStopId`.
* Registered automatically by the model macro; emits `update:currentTabStopId`.
*/
currentTabStopId?: string | null;
/** Initial current tab-stop id (uncontrolled). */
defaultCurrentTabStopId?: string;
/**
* Prevent scroll when focus enters the group.
* @default false
*/
preventScrollOnEntryFocus?: boolean;
}
export interface RovingFocusGroupEmits {
entryFocus: [event: Event];
/** Emitted whenever the current tab stop changes — backs `v-model:currentTabStopId`. */
'update:currentTabStopId': [value: string | null | undefined];
}
export interface RovingFocusGroupContext {
orientation: Ref<Orientation | undefined>;
dir: Ref<Direction>;
loop: Ref<boolean>;
currentTabStopId: Ref<string | null | undefined>;
onItemFocus: (tabStopId: string) => void;
onItemShiftTab: () => void;
onFocusableItemAdd: () => void;
onFocusableItemRemove: () => void;
}
export const RovingFocusGroupCtx = useContextFactory<RovingFocusGroupContext>(
'RovingFocusGroupContext',
);
</script>
<script setup lang="ts">
import { ENTRY_FOCUS, EVENT_OPTIONS, focusFirst } from './utils';
import { ref, toRef } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useCollectionProvider } from '../collection';
import { useConfig } from '../config-provider';
const {
orientation,
dir,
loop = false,
defaultCurrentTabStopId,
preventScrollOnEntryFocus = false,
as = 'div',
} = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupEmits>();
const config = useConfig();
// `dir` falls back to the provider's configured direction when not given as prop.
const dirRef = toRef(() => dir ?? config.dir.value);
const orientationRef = toRef(() => orientation);
const loopRef = toRef(() => loop);
// Uncontrolled backing state, seeded from `defaultCurrentTabStopId`.
const localTabStopId = ref<string | null | undefined>(defaultCurrentTabStopId);
// `currentTabStopId` is a real `v-model`: when the parent binds it (controlled)
// the external value is the source of truth and the model emits
// `update:currentTabStopId`; when unbound, the local ref drives the component.
// The model const shares the declared prop name on purpose (controlled/uncontrolled
// merge happens inside get/set), hence the dupe-keys exception.
// eslint-disable-next-line vue/no-dupe-keys
const currentTabStopId = defineModel<string | null | undefined>('currentTabStopId', {
default: undefined,
get: external => external ?? localTabStopId.value,
set: (value) => {
localTabStopId.value = value;
return value;
},
});
const isTabbingBackOut = ref(false);
const isClickFocus = ref(false);
const focusableItemsCount = ref(0);
const { getItems, CollectionSlot } = useCollectionProvider();
function handleFocus(event: FocusEvent): void {
const isKeyboardFocus = !isClickFocus.value;
if (
event.currentTarget
&& event.target === event.currentTarget
&& isKeyboardFocus
&& !isTabbingBackOut.value
) {
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
event.currentTarget.dispatchEvent(entryFocusEvent);
emit('entryFocus', entryFocusEvent);
if (!entryFocusEvent.defaultPrevented) {
const items = getItems()
.map(i => i.ref)
.filter(i => i.dataset['disabled'] !== '');
const activeItem = items.find(item => item.getAttribute('data-active') === '');
// Items rendered by listbox / combobox / menu mark the keyboard-highlighted
// option with `data-highlighted`; prioritise it on entry so focus lands on
// what the user was last navigating, ahead of the generic current tab stop.
const highlightedItem = items.find(
item => item.getAttribute('data-highlighted') === '',
);
const currentItem = items.find(item => item.id === currentTabStopId.value);
const candidateItems = [activeItem, highlightedItem, currentItem, ...items].filter(
Boolean,
) as HTMLElement[];
focusFirst(candidateItems, preventScrollOnEntryFocus);
}
}
isClickFocus.value = false;
}
function handleMouseUp(): void {
setTimeout(() => {
isClickFocus.value = false;
}, 1);
}
defineExpose({ getItems, currentTabStopId });
RovingFocusGroupCtx.provide({
loop: loopRef,
dir: dirRef,
orientation: orientationRef,
currentTabStopId,
onItemFocus: (tabStopId: string) => {
currentTabStopId.value = tabStopId;
},
onItemShiftTab: () => {
isTabbingBackOut.value = true;
},
onFocusableItemAdd: () => {
focusableItemsCount.value++;
},
onFocusableItemRemove: () => {
focusableItemsCount.value--;
},
});
</script>
<template>
<CollectionSlot>
<Primitive
:tabindex="isTabbingBackOut || focusableItemsCount === 0 ? -1 : 0"
:data-orientation="orientation"
:as="as"
:dir="dirRef"
style="outline: none"
@mousedown="isClickFocus = true"
@mouseup="handleMouseUp"
@focus="handleFocus"
@blur="isTabbingBackOut = false"
>
<slot />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,123 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single focusable item within a `RovingFocusGroup`. It registers itself with
* the group's collection, exposes itself as the sole tab stop when current
* (`tabindex="0"`, others `-1`), and handles the arrow-key navigation that moves
* focus to its siblings. Use `focusable` to opt an item out of the tab order and
* `active` to mark the current selection so it gets focus on group entry.
*/
export interface RovingFocusItemProps extends PrimitiveProps {
/** Unique tab-stop id. Auto-generated via config `useId` when omitted. */
tabStopId?: string;
/**
* Whether this item is focusable.
* @default true
*/
focusable?: boolean;
/** Marks the item as active (current selection). */
active?: boolean;
/**
* Allow `Shift+Arrow` for navigation.
* @default false
*/
allowShiftKey?: boolean;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted } from 'vue';
import { focusFirst, getFocusIntent, wrapArray } from './utils';
import { Primitive } from '../../internal/primitive';
import { RovingFocusGroupCtx } from './RovingFocusGroup.vue';
import { useCollectionInjector } from '../collection';
import { useId } from '../config-provider';
const {
tabStopId,
focusable = true,
active = false,
allowShiftKey = false,
as = 'span',
} = defineProps<RovingFocusItemProps>();
const context = RovingFocusGroupCtx.inject();
// `useId` returns a `ComputedRef<string>` in this repo — unwrap where needed.
const autoId = useId();
const id = computed(() => tabStopId ?? autoId.value);
const isCurrentTabStop = computed(() => context.currentTabStopId.value === id.value);
const { getItems, CollectionItem } = useCollectionInjector();
onMounted(() => {
if (focusable) context.onFocusableItemAdd();
});
onUnmounted(() => {
if (focusable) context.onFocusableItemRemove();
});
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Tab' && event.shiftKey) {
context.onItemShiftTab();
return;
}
if (event.target !== event.currentTarget) return;
const focusIntent = getFocusIntent(event, context.orientation.value, context.dir.value);
if (focusIntent === undefined) return;
if (
event.metaKey
|| event.ctrlKey
|| event.altKey
|| (allowShiftKey ? false : event.shiftKey)
)
return;
event.preventDefault();
let candidateNodes = getItems()
.map(i => i.ref)
.filter(i => i.dataset['disabled'] !== '');
if (focusIntent === 'last') {
candidateNodes.reverse();
}
else if (focusIntent === 'prev' || focusIntent === 'next') {
if (focusIntent === 'prev') candidateNodes.reverse();
const currentIndex = candidateNodes.indexOf(event.currentTarget as HTMLElement);
candidateNodes = context.loop.value
? wrapArray(candidateNodes, currentIndex + 1)
: candidateNodes.slice(currentIndex + 1);
}
nextTick(() => focusFirst(candidateNodes));
}
function handleMousedown(event: MouseEvent): void {
if (!focusable) event.preventDefault();
else context.onItemFocus(id.value);
}
</script>
<template>
<CollectionItem>
<Primitive
:tabindex="isCurrentTabStop ? 0 : -1"
:data-orientation="context.orientation.value"
:data-active="active ? '' : undefined"
:data-disabled="!focusable ? '' : undefined"
:as="as"
@mousedown="handleMousedown"
@focus="context.onItemFocus(id)"
@keydown="handleKeydown"
>
<slot />
</Primitive>
</CollectionItem>
</template>
@@ -0,0 +1,380 @@
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import RovingFocusGroup from '../RovingFocusGroup.vue';
import RovingFocusItem from '../RovingFocusItem.vue';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
interface ItemSpec {
id: string;
disabled?: boolean;
active?: boolean;
highlighted?: boolean;
}
function renderItem(spec: ItemSpec) {
return h(
RovingFocusItem,
{
as: 'button',
tabStopId: spec.id,
id: spec.id,
focusable: !spec.disabled,
active: spec.active,
'data-highlighted': spec.highlighted ? '' : undefined,
},
{ default: () => spec.id },
);
}
function makeGroup(
items: ItemSpec[],
groupProps: Record<string, unknown> = {},
) {
return defineComponent({
setup() {
return () =>
h(
RovingFocusGroup,
{ ...groupProps },
{ default: () => items.map(renderItem) },
);
},
});
}
function press(el: Element, key: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }),
);
}
const ITEMS: ItemSpec[] = [
{ id: 'one' },
{ id: 'two' },
{ id: 'three', disabled: true },
{ id: 'four' },
];
describe('rovingFocus — ARIA / tab-order skeleton', () => {
it('keeps a single tab stop: current item tabindex=0, the rest -1', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'one' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
expect(buttons[0]!.attributes('tabindex')).toBe('0');
expect(buttons[1]!.attributes('tabindex')).toBe('-1');
expect(buttons[3]!.attributes('tabindex')).toBe('-1');
});
it('marks active items with data-active and disabled with data-disabled', async () => {
const wrapper = track(
mount(
makeGroup([
{ id: 'one', active: true },
{ id: 'two', disabled: true },
]),
{ attachTo: document.body },
),
);
await nextTick();
const buttons = wrapper.findAll('button');
expect(buttons[0]!.attributes('data-active')).toBe('');
expect(buttons[1]!.attributes('data-active')).toBeUndefined();
expect(buttons[1]!.attributes('data-disabled')).toBe('');
});
it('reflects orientation onto the group container', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { orientation: 'vertical' }), {
attachTo: document.body,
}),
);
await nextTick();
expect(wrapper.find('[data-orientation="vertical"]').exists()).toBe(true);
});
});
describe('rovingFocus — uncontrolled (defaultCurrentTabStopId)', () => {
it('seeds the current tab stop from defaultCurrentTabStopId', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'two' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
expect(buttons[1]!.attributes('tabindex')).toBe('0');
expect(buttons[0]!.attributes('tabindex')).toBe('-1');
});
it('updates the current tab stop on item focus without external binding', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'one' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[1]!.element.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
await nextTick();
expect(buttons[1]!.attributes('tabindex')).toBe('0');
expect(buttons[0]!.attributes('tabindex')).toBe('-1');
});
});
describe('rovingFocus — controlled (v-model:currentTabStopId)', () => {
it('respects an externally controlled currentTabStopId prop and re-syncs on change', async () => {
const wrapper = track(
mount(RovingFocusGroup, {
attachTo: document.body,
props: { currentTabStopId: 'four' },
slots: { default: () => ITEMS.map(renderItem) },
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
expect(buttons[3]!.attributes('tabindex')).toBe('0');
// Pushing a new controlled value re-syncs the tab stop (no internal-state race).
await wrapper.setProps({ currentTabStopId: 'one' });
await nextTick();
expect(buttons[0]!.attributes('tabindex')).toBe('0');
expect(buttons[3]!.attributes('tabindex')).toBe('-1');
});
it('emits update:currentTabStopId when an item gains focus', async () => {
const onUpdate = vi.fn();
const wrapper = track(
mount(RovingFocusGroup, {
attachTo: document.body,
props: {
currentTabStopId: 'one',
'onUpdate:currentTabStopId': onUpdate,
},
slots: { default: () => ITEMS.map(renderItem) },
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[1]!.element.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
await nextTick();
expect(onUpdate).toHaveBeenCalledWith('two');
});
});
describe('rovingFocus — keyboard navigation', () => {
it('[loop=false] moves to next, skips disabled, and stops at the last item', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'two' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[1]!.element.focus();
press(buttons[1]!.element, 'ArrowRight');
await nextTick();
// index 2 is disabled, so focus lands on index 3
expect(document.activeElement).toBe(buttons[3]!.element);
press(buttons[3]!.element, 'ArrowRight');
await nextTick();
// loop=false: stay at the last item
expect(document.activeElement).toBe(buttons[3]!.element);
});
it('[loop=true] wraps around past the last item, skipping disabled', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'two', loop: true }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[1]!.element.focus();
press(buttons[1]!.element, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(buttons[3]!.element);
press(buttons[3]!.element, 'ArrowRight');
await nextTick();
// wraps to first enabled item
expect(document.activeElement).toBe(buttons[0]!.element);
});
it('Home / End jump to first and last enabled items', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'two' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[1]!.element.focus();
press(buttons[1]!.element, 'End');
await nextTick();
expect(document.activeElement).toBe(buttons[3]!.element);
press(buttons[3]!.element, 'Home');
await nextTick();
expect(document.activeElement).toBe(buttons[0]!.element);
});
it('does not navigate on the orientation-incompatible arrow key', async () => {
const wrapper = track(
mount(
makeGroup(ITEMS, { orientation: 'horizontal', defaultCurrentTabStopId: 'one' }),
{ attachTo: document.body },
),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[0]!.element.focus();
press(buttons[0]!.element, 'ArrowDown');
await nextTick();
// vertical arrow ignored in horizontal orientation
expect(document.activeElement).toBe(buttons[0]!.element);
});
it('respects RTL by swapping ArrowLeft / ArrowRight', async () => {
const wrapper = track(
mount(
makeGroup(ITEMS, {
orientation: 'horizontal',
dir: 'rtl',
defaultCurrentTabStopId: 'two',
}),
{ attachTo: document.body },
),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[1]!.element.focus();
// In RTL, ArrowLeft means "next"
press(buttons[1]!.element, 'ArrowLeft');
await nextTick();
expect(document.activeElement).toBe(buttons[3]!.element);
});
it('ignores arrow navigation when a modifier (ctrl) is held', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'one' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
buttons[0]!.element.focus();
press(buttons[0]!.element, 'ArrowRight', { ctrlKey: true });
await nextTick();
expect(document.activeElement).toBe(buttons[0]!.element);
});
});
describe('rovingFocus — disabled items', () => {
it('does not register a non-focusable item as a tab-order participant', async () => {
const wrapper = track(
mount(
makeGroup([
{ id: 'one', disabled: true },
{ id: 'two' },
]),
{ attachTo: document.body },
),
);
await nextTick();
const buttons = wrapper.findAll('button');
// disabled item carries data-disabled and is never the navigation target
expect(buttons[0]!.attributes('data-disabled')).toBe('');
buttons[1]!.element.focus();
press(buttons[1]!.element, 'Home');
await nextTick();
expect(document.activeElement).toBe(buttons[1]!.element);
});
});
describe('rovingFocus — entry focus priority', () => {
it('prioritises the highlighted item over the current tab stop on entry', async () => {
const wrapper = track(
mount(
makeGroup(
[
{ id: 'one' },
{ id: 'two', highlighted: true },
{ id: 'four' },
],
{ defaultCurrentTabStopId: 'one' },
),
{ attachTo: document.body },
),
);
await nextTick();
const groupEl = wrapper.find('button')!.element.closest('[style]') as HTMLElement;
// Dispatch a keyboard-driven focus onto the group container.
groupEl.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
await nextTick();
const buttons = wrapper.findAll('button');
// highlighted item ('two') wins entry focus over current tab stop ('one')
expect(document.activeElement).toBe(buttons[1]!.element);
});
it('falls back to the active item when nothing is highlighted', async () => {
const wrapper = track(
mount(
makeGroup([
{ id: 'one' },
{ id: 'two', active: true },
{ id: 'four' },
]),
{ attachTo: document.body },
),
);
await nextTick();
const groupEl = wrapper.find('button')!.element.closest('[style]') as HTMLElement;
groupEl.dispatchEvent(new FocusEvent('focus', { bubbles: false }));
await nextTick();
const buttons = wrapper.findAll('button');
expect(document.activeElement).toBe(buttons[1]!.element);
});
});
describe('rovingFocus — Tab back out', () => {
it('marks the group tabindex=-1 after Shift+Tab from an item', async () => {
const wrapper = track(
mount(makeGroup(ITEMS, { defaultCurrentTabStopId: 'one' }), {
attachTo: document.body,
}),
);
await nextTick();
const buttons = wrapper.findAll('button');
const groupEl = buttons[0]!.element.closest('[style]') as HTMLElement;
expect(groupEl.getAttribute('tabindex')).toBe('0');
press(buttons[0]!.element, 'Tab', { shiftKey: true });
await nextTick();
expect(groupEl.getAttribute('tabindex')).toBe('-1');
});
});
@@ -0,0 +1,20 @@
export { default as RovingFocusGroup, RovingFocusGroupCtx } from './RovingFocusGroup.vue';
export { default as RovingFocusItem } from './RovingFocusItem.vue';
export type {
RovingFocusGroupProps,
RovingFocusGroupEmits,
RovingFocusGroupContext,
} from './RovingFocusGroup.vue';
export type { RovingFocusItemProps } from './RovingFocusItem.vue';
export {
ENTRY_FOCUS,
EVENT_OPTIONS,
focusFirst,
getFocusIntent,
getDirectionAwareKey,
wrapArray,
type FocusIntent,
type Orientation,
} from './utils';
@@ -0,0 +1,78 @@
import type { Direction } from '../config-provider';
import { getActiveElement } from '@robonen/platform/browsers';
export type Orientation = 'horizontal' | 'vertical';
/** Custom event dispatched when focus enters a `RovingFocusGroup` for the first time. */
export const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus';
/** Event options for `ENTRY_FOCUS` — non-bubbling, cancelable. */
export const EVENT_OPTIONS = { bubbles: false, cancelable: true } as const;
export type FocusIntent = 'first' | 'last' | 'prev' | 'next';
const MAP_KEY_TO_FOCUS_INTENT: Record<string, FocusIntent> = {
ArrowLeft: 'prev',
ArrowUp: 'prev',
ArrowRight: 'next',
ArrowDown: 'next',
PageUp: 'first',
Home: 'first',
PageDown: 'last',
End: 'last',
};
/**
* For RTL: swaps `ArrowLeft`/`ArrowRight`. Leaves other keys untouched.
*/
export function getDirectionAwareKey(key: string, dir?: Direction): string {
if (dir !== 'rtl') return key;
if (key === 'ArrowLeft') return 'ArrowRight';
if (key === 'ArrowRight') return 'ArrowLeft';
return key;
}
/**
* Resolves a `FocusIntent` from a keyboard event, respecting `orientation`
* and `dir`. Returns `undefined` if the key has no mapping or conflicts with
* the orientation (e.g. `ArrowUp` in a horizontal group).
*/
export function getFocusIntent(
event: KeyboardEvent,
orientation?: Orientation,
dir?: Direction,
): FocusIntent | undefined {
const key = getDirectionAwareKey(event.key, dir);
if (orientation === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight'))
return undefined;
if (orientation === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown'))
return undefined;
return MAP_KEY_TO_FOCUS_INTENT[key];
}
/**
* Focuses the first element from `candidates` that actually accepts focus.
* No-op if the currently focused element is already a candidate.
*/
export function focusFirst(candidates: HTMLElement[], preventScroll = false): void {
const previouslyFocused = getActiveElement();
for (const candidate of candidates) {
if (candidate === previouslyFocused) return;
candidate.focus({ preventScroll });
if (getActiveElement() !== previouslyFocused) return;
}
}
/**
* Rotates `array` so that the element at `startIndex` becomes first.
*
* @example
* wrapArray(['a','b','c','d'], 2) // => ['c','d','a','b']
*/
export function wrapArray<T>(array: T[], startIndex: number): T[] {
const len = array.length;
if (len === 0) return array;
return Array.from({ length: len }, (_, i) => array[(startIndex + i) % len]!);
}
@@ -0,0 +1,72 @@
<script lang="ts">
/**
* Renders its slot content into a different part of the DOM (a portal), wrapping
* Vue's built-in `<Teleport>` with SSR-safe defaults and a configurable target.
* Use it as the building block for overlay primitives (dialogs, popovers, toasts)
* that must escape parent overflow/stacking contexts; the target defaults to the
* `teleportTarget` from `ConfigProvider` (`body` unless overridden).
*/
export interface TeleportPrimitiveProps {
/**
* Target DOM node or CSS selector. When `null`/`undefined`, Vue's built-in
* `<Teleport>` is rendered with its default behavior (body).
*/
to?: string | HTMLElement | null;
/**
* Defer teleport rendering until after the parent has mounted.
* Useful when the target node is rendered by the same component tree.
* @default false
*/
defer?: boolean;
/**
* Disable teleport — children render in place. SSR-safe default.
* @default false
*/
disabled?: boolean;
/**
* Forcibly keep children mounted even when Teleport is disabled/removed.
* Opt-in escape hatch for animation-aware primitives.
* @default true
*/
forceMount?: boolean;
}
export default {
inheritAttrs: false,
};
</script>
<script setup lang="ts">
import { isClient } from '@robonen/platform/multi';
import { useConfig } from '../config-provider';
import { computed } from 'vue';
const {
to,
defer = false,
disabled = false,
forceMount = true,
} = defineProps<TeleportPrimitiveProps>();
const config = useConfig();
// On the server, Vue's Teleport is a no-op for most targets; we also opt-out
// explicitly when `isClient` is false to avoid hydration mismatches when the
// target hasn't mounted yet.
const effectiveDisabled = computed(() => disabled || !isClient);
const target = computed(() => to ?? config.teleportTarget.value);
</script>
<template>
<Teleport
v-if="forceMount || !effectiveDisabled"
:to="target"
:defer="defer"
:disabled="effectiveDisabled"
>
<slot />
</Teleport>
</template>
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import Teleport from '../Teleport.vue';
describe('Teleport primitive', () => {
let host: HTMLElement;
beforeEach(() => {
host = document.createElement('div');
host.id = 'teleport-target';
document.body.appendChild(host);
});
afterEach(() => {
host.remove();
});
it('teleports default slot content to `to` target', async () => {
const w = mount(Teleport, {
props: { to: '#teleport-target' },
slots: { default: () => h('span', { 'data-testid': 'child' }, 'hello') },
attachTo: document.body,
});
await nextTick();
expect(host.querySelector('[data-testid=child]')?.textContent).toBe('hello');
w.unmount();
});
it('accepts an HTMLElement as `to`', async () => {
const w = mount(Teleport, {
props: { to: host },
slots: { default: () => h('span', { 'data-testid': 'child' }, 'x') },
attachTo: document.body,
});
await nextTick();
expect(host.querySelector('[data-testid=child]')).toBeTruthy();
w.unmount();
});
it('renders children in place when disabled', async () => {
const Parent = defineComponent({
render() {
return h('div', { id: 'parent' }, [
h(Teleport, { to: '#teleport-target', disabled: true }, {
default: () => h('span', { 'data-testid': 'child' }, 'inline'),
}),
]);
},
});
const w = mount(Parent, { attachTo: document.body });
await nextTick();
expect(document.querySelector('#parent [data-testid=child]')).toBeTruthy();
expect(host.querySelector('[data-testid=child]')).toBeFalsy();
w.unmount();
});
it('defaults to body when `to` is omitted', async () => {
const w = mount(Teleport, {
slots: { default: () => h('span', { 'data-testid': 'body-child' }, 'hi') },
attachTo: document.body,
});
await nextTick();
expect(document.body.querySelector('[data-testid=body-child]')).toBeTruthy();
w.unmount();
});
it('teleports content to the target on the client mount tick', async () => {
// Once mounted, content must end up inside the target rather than in place,
// proving the reactive mounted gating flips `effectiveDisabled` to false.
const w = mount(Teleport, {
props: { to: '#teleport-target' },
slots: { default: () => h('span', { 'data-testid': 'mounted-child' }, 'm') },
attachTo: document.body,
});
await nextTick();
expect(host.querySelector('[data-testid=mounted-child]')).toBeTruthy();
w.unmount();
});
it('reacts to a changing `to` target', async () => {
const second = document.createElement('div');
second.id = 'teleport-target-2';
document.body.appendChild(second);
const w = mount(Teleport, {
props: { to: '#teleport-target' },
slots: { default: () => h('span', { 'data-testid': 'movable' }, 'mv') },
attachTo: document.body,
});
await nextTick();
expect(host.querySelector('[data-testid=movable]')).toBeTruthy();
expect(second.querySelector('[data-testid=movable]')).toBeFalsy();
await w.setProps({ to: '#teleport-target-2' });
await nextTick();
expect(host.querySelector('[data-testid=movable]')).toBeFalsy();
expect(second.querySelector('[data-testid=movable]')).toBeTruthy();
w.unmount();
second.remove();
});
it('renders the Teleport node when forceMount is true (default)', async () => {
// With forceMount the node always renders; teleporting still occurs once
// mounted because `disabled` alone gates inline-vs-teleported.
const w = mount(Teleport, {
props: { to: '#teleport-target', forceMount: true },
slots: { default: () => h('span', { 'data-testid': 'forced' }, 'f') },
attachTo: document.body,
});
await nextTick();
expect(host.querySelector('[data-testid=forced]')).toBeTruthy();
w.unmount();
});
it('teleports after mount even with forceMount disabled', async () => {
// forceMount=false only changes pre-mount rendering; after the mount tick
// content must still teleport to the target.
const w = mount(Teleport, {
props: { to: '#teleport-target', forceMount: false },
slots: { default: () => h('span', { 'data-testid': 'gated' }, 'g') },
attachTo: document.body,
});
await nextTick();
expect(host.querySelector('[data-testid=gated]')).toBeTruthy();
w.unmount();
});
it('keeps children inline when disabled regardless of mount state', async () => {
const Parent = defineComponent({
render() {
return h('div', { id: 'parent-disabled' }, [
h(Teleport, { to: '#teleport-target', disabled: true, forceMount: true }, {
default: () => h('span', { 'data-testid': 'still-inline' }, 'i'),
}),
]);
},
});
const w = mount(Parent, { attachTo: document.body });
await nextTick();
expect(document.querySelector('#parent-disabled [data-testid=still-inline]')).toBeTruthy();
expect(host.querySelector('[data-testid=still-inline]')).toBeFalsy();
w.unmount();
});
});
@@ -0,0 +1,2 @@
export { default as Teleport, default as Portal } from './Teleport.vue';
export type { TeleportPrimitiveProps, TeleportPrimitiveProps as PortalProps } from './Teleport.vue';
@@ -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"
>&minus;</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';