feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,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"
|
||||
>−</span>
|
||||
<VisuallyHidden>Decrease quantity</VisuallyHidden>
|
||||
</button>
|
||||
|
||||
<span class="min-w-8 text-center text-sm font-medium tabular-nums">
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-9 items-center justify-center rounded-md border border-border bg-bg-subtle text-fg transition-colors hover:bg-bg-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="count += 1"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="text-base leading-none"
|
||||
>+</span>
|
||||
<VisuallyHidden>Increase quantity</VisuallyHidden>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-fg-subtle">
|
||||
Quantity is now
|
||||
<span class="font-medium text-fg">{{ count }}</span>.
|
||||
<VisuallyHidden
|
||||
as="span"
|
||||
aria-live="polite"
|
||||
>
|
||||
Quantity changed to {{ count }}.
|
||||
</VisuallyHidden>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as VisuallyHidden } from './VisuallyHidden.vue';
|
||||
export type { VisuallyHiddenProps } from './VisuallyHidden.vue';
|
||||
export { default as VisuallyHiddenInput } from './VisuallyHiddenInput.vue';
|
||||
export type { VisuallyHiddenInputProps } from './VisuallyHiddenInput.vue';
|
||||
export { default as VisuallyHiddenInputBubble } from './VisuallyHiddenInputBubble.vue';
|
||||
export type { VisuallyHiddenInputBubbleProps } from './VisuallyHiddenInputBubble.vue';
|
||||
Reference in New Issue
Block a user