diff --git a/configs/oxlint/src/presets/imports.ts b/configs/oxlint/src/presets/imports.ts
index 3e3a3d1..09d2f6e 100644
--- a/configs/oxlint/src/presets/imports.ts
+++ b/configs/oxlint/src/presets/imports.ts
@@ -17,6 +17,6 @@ export const imports: OxlintConfig = {
'import/no-empty-named-blocks': 'warn',
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
- 'sort-imports': ['warn', { ignoreDeclarationSort: false, ignoreMemberSort: false, ignoreCase: true, allowSeparatedGroups: true }],
+ 'sort-imports': 'warn',
},
};
diff --git a/configs/oxlint/src/presets/stylistic.ts b/configs/oxlint/src/presets/stylistic.ts
index a69bccc..bb57dd4 100644
--- a/configs/oxlint/src/presets/stylistic.ts
+++ b/configs/oxlint/src/presets/stylistic.ts
@@ -59,7 +59,7 @@ export const stylistic: OxlintConfig = {
'@stylistic/comma-style': ['error', 'last'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
- '@stylistic/quote-props': ['error', 'consistent-as-needed'],
+ '@stylistic/quote-props': ['error', 'as-needed'],
/* ── indentation ──────────────────────────────────────── */
'@stylistic/indent': ['error', 2, {
diff --git a/core/platform/src/browsers/focusScope/index.test.ts b/core/platform/src/browsers/focusScope/index.test.ts
new file mode 100644
index 0000000..4c95e22
--- /dev/null
+++ b/core/platform/src/browsers/focusScope/index.test.ts
@@ -0,0 +1,262 @@
+import { afterEach, describe, it, expect } from 'vitest';
+import {
+ getActiveElement,
+ getTabbableCandidates,
+ getTabbableEdges,
+ focusFirst,
+ focus,
+ isHidden,
+ isSelectableInput,
+ AUTOFOCUS_ON_MOUNT,
+ AUTOFOCUS_ON_UNMOUNT,
+ EVENT_OPTIONS,
+} from '.';
+
+function createContainer(html: string): HTMLElement {
+ const container = document.createElement('div');
+
+ container.innerHTML = html;
+ document.body.appendChild(container);
+
+ return container;
+}
+
+describe('constants', () => {
+ it('exports correct event names', () => {
+ expect(AUTOFOCUS_ON_MOUNT).toBe('focusScope.autoFocusOnMount');
+ expect(AUTOFOCUS_ON_UNMOUNT).toBe('focusScope.autoFocusOnUnmount');
+ });
+
+ it('exports correct event options', () => {
+ expect(EVENT_OPTIONS).toEqual({ bubbles: false, cancelable: true });
+ });
+});
+
+describe('getActiveElement', () => {
+ it('returns document.body when nothing is focused', () => {
+ const active = getActiveElement();
+ expect(active).toBe(document.body);
+ });
+
+ it('returns the focused element', () => {
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+ input.focus();
+
+ expect(getActiveElement()).toBe(input);
+
+ input.remove();
+ });
+});
+
+describe('getTabbableCandidates', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('returns focusable elements with tabindex >= 0', () => {
+ const container = createContainer(`
+
+
+ Link
+
Div
+ `);
+
+ const candidates = getTabbableCandidates(container);
+ expect(candidates.length).toBe(4);
+
+ container.remove();
+ });
+
+ it('skips disabled elements', () => {
+ const container = createContainer(`
+
+
+ `);
+
+ const candidates = getTabbableCandidates(container);
+ expect(candidates.length).toBe(1);
+ expect(candidates[0]!.tagName).toBe('INPUT');
+
+ container.remove();
+ });
+
+ it('skips hidden inputs', () => {
+ const container = createContainer(`
+
+
+ `);
+
+ const candidates = getTabbableCandidates(container);
+ expect(candidates.length).toBe(1);
+ expect((candidates[0] as HTMLInputElement).type).toBe('text');
+
+ container.remove();
+ });
+
+ it('skips elements with hidden attribute', () => {
+ const container = createContainer(`
+
+
+ `);
+
+ const candidates = getTabbableCandidates(container);
+ expect(candidates.length).toBe(1);
+
+ container.remove();
+ });
+
+ it('returns empty array for container with no focusable elements', () => {
+ const container = createContainer(`
+ Just text
+ More text
+ `);
+
+ const candidates = getTabbableCandidates(container);
+ expect(candidates.length).toBe(0);
+
+ container.remove();
+ });
+});
+
+describe('getTabbableEdges', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('returns first and last tabbable elements', () => {
+ const container = createContainer(`
+
+
+
+ `);
+
+ const { first, last } = getTabbableEdges(container);
+ expect(first?.getAttribute('data-testid')).toBe('first');
+ expect(last?.getAttribute('data-testid')).toBe('last');
+
+ container.remove();
+ });
+
+ it('returns undefined for both when no tabbable elements', () => {
+ const container = createContainer(`no focusable
`);
+
+ const { first, last } = getTabbableEdges(container);
+ expect(first).toBeUndefined();
+ expect(last).toBeUndefined();
+
+ container.remove();
+ });
+});
+
+describe('focusFirst', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('focuses the first element in the list', () => {
+ const container = createContainer(`
+
+
+ `);
+
+ const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
+ focusFirst(candidates);
+
+ expect(document.activeElement).toBe(candidates[0]);
+
+ container.remove();
+ });
+
+ it('returns true when focus changed', () => {
+ const container = createContainer(``);
+ const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
+
+ const result = focusFirst(candidates);
+ expect(result).toBe(true);
+
+ container.remove();
+ });
+
+ it('returns false when no candidate receives focus', () => {
+ const result = focusFirst([]);
+ expect(result).toBe(false);
+ });
+});
+
+describe('focus', () => {
+ it('does nothing when element is null', () => {
+ expect(() => focus(null)).not.toThrow();
+ });
+
+ it('focuses the given element', () => {
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+
+ focus(input);
+ expect(document.activeElement).toBe(input);
+
+ input.remove();
+ });
+
+ it('calls select on input when select=true', () => {
+ const input = document.createElement('input');
+ input.value = 'hello';
+ document.body.appendChild(input);
+
+ focus(input, { select: true });
+ expect(document.activeElement).toBe(input);
+
+ input.remove();
+ });
+});
+
+describe('isSelectableInput', () => {
+ it('returns true for input elements', () => {
+ const input = document.createElement('input');
+ expect(isSelectableInput(input)).toBe(true);
+ });
+
+ it('returns false for non-input elements', () => {
+ const div = document.createElement('div');
+ expect(isSelectableInput(div)).toBe(false);
+ });
+});
+
+describe('isHidden', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('detects elements with visibility: hidden', () => {
+ const container = createContainer('');
+ const el = document.createElement('div');
+ el.style.visibility = 'hidden';
+ container.appendChild(el);
+
+ expect(isHidden(el)).toBe(true);
+
+ container.remove();
+ });
+
+ it('detects elements with display: none', () => {
+ const container = createContainer('');
+ const el = document.createElement('div');
+ el.style.display = 'none';
+ container.appendChild(el);
+
+ expect(isHidden(el)).toBe(true);
+
+ container.remove();
+ });
+
+ it('returns false for visible elements', () => {
+ const container = createContainer('');
+ const el = document.createElement('div');
+ container.appendChild(el);
+
+ expect(isHidden(el, container)).toBe(false);
+
+ container.remove();
+ });
+});
diff --git a/core/platform/src/browsers/focusScope/index.ts b/core/platform/src/browsers/focusScope/index.ts
new file mode 100644
index 0000000..b1f00b9
--- /dev/null
+++ b/core/platform/src/browsers/focusScope/index.ts
@@ -0,0 +1,168 @@
+export type FocusableTarget = HTMLElement | { focus: () => void };
+
+export const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
+export const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
+export const EVENT_OPTIONS = { bubbles: false, cancelable: true };
+
+/**
+ * @name getActiveElement
+ * @category Browsers
+ * @description Returns the active element of the document (or shadow root)
+ *
+ * @since 0.0.5
+ */
+export function getActiveElement(doc: Document | ShadowRoot = document): HTMLElement | null {
+ let active = doc.activeElement as HTMLElement | null;
+
+ while (active?.shadowRoot)
+ active = active.shadowRoot.activeElement as HTMLElement | null;
+
+ return active;
+}
+
+/**
+ * @name isSelectableInput
+ * @category Browsers
+ * @description Checks if an element is an input element with a select method
+ *
+ * @since 0.0.5
+ */
+export function isSelectableInput(element: unknown): element is FocusableTarget & { select: () => void } {
+ return element instanceof HTMLInputElement && 'select' in element;
+}
+
+/**
+ * @name focus
+ * @category Browsers
+ * @description Focuses an element without scrolling. Optionally calls select on input elements.
+ *
+ * @since 0.0.5
+ */
+export function focus(element?: FocusableTarget | null, { select = false } = {}) {
+ if (element && element.focus) {
+ const previouslyFocused = getActiveElement();
+
+ element.focus({ preventScroll: true });
+
+ if (element !== previouslyFocused && isSelectableInput(element) && select) {
+ element.select();
+ }
+ }
+}
+
+/**
+ * @name focusFirst
+ * @category Browsers
+ * @description Attempts to focus the first element from a list of candidates. Stops when focus actually moves.
+ *
+ * @since 0.0.5
+ */
+export function focusFirst(candidates: HTMLElement[], { select = false } = {}): boolean {
+ const previouslyFocused = getActiveElement();
+
+ for (const candidate of candidates) {
+ focus(candidate, { select });
+
+ if (getActiveElement() !== previouslyFocused)
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * @name getTabbableCandidates
+ * @category Browsers
+ * @description Collects all tabbable candidates via TreeWalker (faster than querySelectorAll).
+ * This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.
+ *
+ * @since 0.0.5
+ */
+export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
+ const nodes: HTMLElement[] = [];
+
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node: HTMLElement) => {
+ const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
+
+ if ((node as any).disabled || node.hidden || isHiddenInput)
+ return NodeFilter.FILTER_SKIP;
+
+ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+ },
+ });
+
+ while (walker.nextNode())
+ nodes.push(walker.currentNode as HTMLElement);
+
+ return nodes;
+}
+
+/**
+ * @name isHidden
+ * @category Browsers
+ * @description Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree
+ *
+ * @since 0.0.5
+ */
+export function isHidden(node: HTMLElement, upTo?: HTMLElement): boolean {
+ const style = getComputedStyle(node);
+
+ if (style.visibility === 'hidden' || style.display === 'none')
+ return true;
+
+ while (node.parentElement) {
+ node = node.parentElement;
+
+ if (upTo !== undefined && node === upTo)
+ return false;
+
+ if (getComputedStyle(node).display === 'none')
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * @name findFirstVisible
+ * @category Browsers
+ * @description Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).
+ *
+ * @since 0.0.5
+ */
+export function findFirstVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
+ for (const element of elements) {
+ if (!isHidden(element, container))
+ return element;
+ }
+}
+
+/**
+ * @name findLastVisible
+ * @category Browsers
+ * @description Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).
+ *
+ * @since 0.0.5
+ */
+export function findLastVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
+ for (let i = elements.length - 1; i >= 0; i--) {
+ if (!isHidden(elements[i]!, container))
+ return elements[i];
+ }
+}
+
+/**
+ * @name getTabbableEdges
+ * @category Browsers
+ * @description Returns the first and last tabbable elements inside a container
+ *
+ * @since 0.0.5
+ */
+export function getTabbableEdges(container: HTMLElement): { first: HTMLElement | undefined; last: HTMLElement | undefined } {
+ const candidates = getTabbableCandidates(container);
+ const first = findFirstVisible(candidates, container);
+ const last = findLastVisible(candidates, container);
+
+ return { first, last };
+}
diff --git a/core/platform/src/browsers/index.ts b/core/platform/src/browsers/index.ts
index 0a9fd37..65bdb0b 100644
--- a/core/platform/src/browsers/index.ts
+++ b/core/platform/src/browsers/index.ts
@@ -1,2 +1,3 @@
export * from './animationLifecycle';
export * from './focusGuard';
+export * from './focusScope';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1f1aff1..8ddbce1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -218,6 +218,9 @@ importers:
'@robonen/platform':
specifier: workspace:*
version: link:../../core/platform
+ '@robonen/stdlib':
+ specifier: ^0.0.9
+ version: 0.0.9
'@robonen/vue':
specifier: workspace:*
version: link:../toolkit
@@ -1983,6 +1986,10 @@ packages:
resolution: {integrity: sha512-zTK2X2r6fQTgQ1lqM0jaF/MgxmXCp0UrfiE1Ks3rQOBQjci4Xez1Zzsy4MgtjhMiHcdDi4lbBvtlPnksvEU8GQ==}
engines: {node: ^20.9.0 || ^22.11.0 || ^24, pnpm: ^10.0.0}
+ '@robonen/stdlib@0.0.9':
+ resolution: {integrity: sha512-JrnOEILRde0bX50C1lY1ZY90QQ18pe6Z47Lw45vYFi2fAcoDSgeKztl028heaFyDXLjsFdc2VGhkt5I+DFCFuQ==}
+ engines: {node: '>=24.13.1'}
+
'@rolldown/binding-android-arm64@1.0.0-rc.6':
resolution: {integrity: sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -9558,6 +9565,8 @@ snapshots:
'@renovatebot/ruby-semver@4.1.2': {}
+ '@robonen/stdlib@0.0.9': {}
+
'@rolldown/binding-android-arm64@1.0.0-rc.6':
optional: true
diff --git a/vue/primitives/oxlint.config.ts b/vue/primitives/oxlint.config.ts
index 81bd50b..53290d8 100644
--- a/vue/primitives/oxlint.config.ts
+++ b/vue/primitives/oxlint.config.ts
@@ -1,5 +1,5 @@
+import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
import { defineConfig } from 'oxlint';
-import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic, {
overrides: [
diff --git a/vue/primitives/package.json b/vue/primitives/package.json
index 7853168..9db1ce1 100644
--- a/vue/primitives/package.json
+++ b/vue/primitives/package.json
@@ -57,6 +57,7 @@
},
"dependencies": {
"@robonen/platform": "workspace:*",
+ "@robonen/stdlib": "^0.0.9",
"@robonen/vue": "workspace:*",
"@vue/shared": "catalog:",
"vue": "catalog:"
diff --git a/vue/primitives/primitives.zip b/vue/primitives/primitives.zip
new file mode 100644
index 0000000..224578e
Binary files /dev/null and b/vue/primitives/primitives.zip differ
diff --git a/vue/primitives/src/config-provider/__test__/config-provider.test.ts b/vue/primitives/src/config-provider/__test__/config-provider.test.ts
index 6e212b6..267b163 100644
--- a/vue/primitives/src/config-provider/__test__/config-provider.test.ts
+++ b/vue/primitives/src/config-provider/__test__/config-provider.test.ts
@@ -1,9 +1,9 @@
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import {
- provideConfig,
provideAppConfig,
+ provideConfig,
useConfig,
} from '..';
@@ -42,7 +42,6 @@ describe('useConfig', () => {
return h('div', {
'data-dir': this.config.dir.value,
'data-target': this.config.teleportTarget.value,
- 'data-nonce': this.config.nonce.value,
});
},
});
@@ -52,7 +51,6 @@ describe('useConfig', () => {
provideConfig({
dir: 'rtl',
teleportTarget: '#app',
- nonce: 'abc123',
});
},
render() {
@@ -64,7 +62,6 @@ describe('useConfig', () => {
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
- expect(wrapper.find('div').attributes('data-nonce')).toBe('abc123');
wrapper.unmount();
});
diff --git a/vue/primitives/src/config-provider/context.ts b/vue/primitives/src/config-provider/context.ts
index 24406c5..4310060 100644
--- a/vue/primitives/src/config-provider/context.ts
+++ b/vue/primitives/src/config-provider/context.ts
@@ -1,24 +1,21 @@
-import { ref, shallowRef, toValue } from 'vue';
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
+import { ref, shallowRef, toValue } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type Direction = 'ltr' | 'rtl';
export interface ConfigContext {
dir: Ref;
- nonce: Ref;
teleportTarget: ShallowRef;
}
export interface ConfigOptions {
dir?: MaybeRefOrGetter;
- nonce?: MaybeRefOrGetter;
teleportTarget?: MaybeRefOrGetter;
}
const DEFAULT_CONFIG: UnwrapRef = {
dir: 'ltr',
- nonce: undefined,
teleportTarget: 'body',
};
@@ -27,7 +24,6 @@ const ConfigCtx = useContextFactory('ConfigContext');
function resolveContext(options?: ConfigOptions): ConfigContext {
return {
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
- nonce: ref(toValue(options?.nonce) ?? DEFAULT_CONFIG.nonce),
teleportTarget: shallowRef(toValue(options?.teleportTarget) ?? DEFAULT_CONFIG.teleportTarget),
};
}
diff --git a/vue/primitives/src/focus-scope/FocusScope.vue b/vue/primitives/src/focus-scope/FocusScope.vue
new file mode 100644
index 0000000..db2a56f
--- /dev/null
+++ b/vue/primitives/src/focus-scope/FocusScope.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
diff --git a/vue/primitives/src/focus-scope/__test__/FocusScope.test.ts b/vue/primitives/src/focus-scope/__test__/FocusScope.test.ts
new file mode 100644
index 0000000..bb5588b
--- /dev/null
+++ b/vue/primitives/src/focus-scope/__test__/FocusScope.test.ts
@@ -0,0 +1,450 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { defineComponent, h, nextTick, ref } from 'vue';
+import FocusScope from '../FocusScope.vue';
+import { mount } from '@vue/test-utils';
+
+function createFocusScope(props: Record = {}, slots?: Record 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();
+ });
+});
diff --git a/vue/primitives/src/focus-scope/__test__/a11y.test.ts b/vue/primitives/src/focus-scope/__test__/a11y.test.ts
new file mode 100644
index 0000000..73b4e90
--- /dev/null
+++ b/vue/primitives/src/focus-scope/__test__/a11y.test.ts
@@ -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 = {}) {
+ 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();
+ });
+});
diff --git a/vue/primitives/src/focus-scope/index.ts b/vue/primitives/src/focus-scope/index.ts
new file mode 100644
index 0000000..98a19d6
--- /dev/null
+++ b/vue/primitives/src/focus-scope/index.ts
@@ -0,0 +1,3 @@
+export { default as FocusScope } from './FocusScope.vue';
+
+export type { FocusScopeEmits, FocusScopeProps } from './FocusScope.vue';
diff --git a/vue/primitives/src/focus-scope/stack.ts b/vue/primitives/src/focus-scope/stack.ts
new file mode 100644
index 0000000..ce081f6
--- /dev/null
+++ b/vue/primitives/src/focus-scope/stack.ts
@@ -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();
+ },
+ };
+}
diff --git a/vue/primitives/src/focus-scope/useAutoFocus.ts b/vue/primitives/src/focus-scope/useAutoFocus.ts
new file mode 100644
index 0000000..3ff1704
--- /dev/null
+++ b/vue/primitives/src/focus-scope/useAutoFocus.ts
@@ -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 { ShallowRef } from 'vue';
+import { createFocusScopesStack } from './stack';
+import { 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>,
+ focusScope: FocusScopeAPI,
+ onMountAutoFocus: (ev: Event) => void,
+ onUnmountAutoFocus: (ev: Event) => void,
+) {
+ const stack = createFocusScopesStack();
+
+ watchPostEffect((onCleanup) => {
+ 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);
+ }
+ }
+
+ onCleanup(() => {
+ const event = dispatchCancelableEvent(el, AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
+
+ if (!event.defaultPrevented) {
+ focus(previouslyFocusedElement ?? document.body, { select: true });
+ }
+
+ stack.remove(focusScope);
+ });
+ });
+}
diff --git a/vue/primitives/src/focus-scope/useFocusTrap.ts b/vue/primitives/src/focus-scope/useFocusTrap.ts
new file mode 100644
index 0000000..28c80c1
--- /dev/null
+++ b/vue/primitives/src/focus-scope/useFocusTrap.ts
@@ -0,0 +1,60 @@
+import type { MaybeRefOrGetter, ShallowRef } from 'vue';
+import { shallowRef, toValue, watchPostEffect } from 'vue';
+import type { FocusScopeAPI } from './stack';
+import { focus } from '@robonen/platform/browsers';
+
+export function useFocusTrap(
+ container: Readonly>,
+ focusScope: FocusScopeAPI,
+ trapped: MaybeRefOrGetter,
+) {
+ const lastFocusedElement = shallowRef(null);
+
+ watchPostEffect((onCleanup) => {
+ 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 });
+
+ onCleanup(() => {
+ document.removeEventListener('focusin', handleFocusIn);
+ document.removeEventListener('focusout', handleFocusOut);
+ observer.disconnect();
+ });
+ });
+}
diff --git a/vue/primitives/src/index.ts b/vue/primitives/src/index.ts
index 4b96ffe..ded042a 100644
--- a/vue/primitives/src/index.ts
+++ b/vue/primitives/src/index.ts
@@ -2,3 +2,4 @@ export * from './config-provider';
export * from './primitive';
export * from './presence';
export * from './pagination';
+export * from './focus-scope';
diff --git a/vue/primitives/src/pagination/PaginationFirst.vue b/vue/primitives/src/pagination/PaginationFirst.vue
index 77a5b5c..94cb7ca 100644
--- a/vue/primitives/src/pagination/PaginationFirst.vue
+++ b/vue/primitives/src/pagination/PaginationFirst.vue
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'First Page',
- 'type': as === 'button' ? 'button' as const : undefined,
- 'disabled': disabled.value,
+ type: as === 'button' ? 'button' as const : undefined,
+ disabled: disabled.value,
}));
function handleClick() {
diff --git a/vue/primitives/src/pagination/PaginationLast.vue b/vue/primitives/src/pagination/PaginationLast.vue
index 9195c69..a2f7a4c 100644
--- a/vue/primitives/src/pagination/PaginationLast.vue
+++ b/vue/primitives/src/pagination/PaginationLast.vue
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'Last Page',
- 'type': as === 'button' ? 'button' as const : undefined,
- 'disabled': disabled.value,
+ type: as === 'button' ? 'button' as const : undefined,
+ disabled: disabled.value,
}));
function handleClick() {
diff --git a/vue/primitives/src/pagination/PaginationListItem.vue b/vue/primitives/src/pagination/PaginationListItem.vue
index 4d35d9b..704017a 100644
--- a/vue/primitives/src/pagination/PaginationListItem.vue
+++ b/vue/primitives/src/pagination/PaginationListItem.vue
@@ -25,8 +25,8 @@ const attrs = computed(() => ({
'aria-label': `Page ${value}`,
'aria-current': isSelected.value ? 'page' as const : undefined,
'data-selected': isSelected.value ? 'true' : undefined,
- 'disabled': disabled.value,
- 'type': as === 'button' ? 'button' as const : undefined,
+ disabled: disabled.value,
+ type: as === 'button' ? 'button' as const : undefined,
}));
function handleClick() {
diff --git a/vue/primitives/src/pagination/PaginationNext.vue b/vue/primitives/src/pagination/PaginationNext.vue
index f2e4dba..fab3bd4 100644
--- a/vue/primitives/src/pagination/PaginationNext.vue
+++ b/vue/primitives/src/pagination/PaginationNext.vue
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'Next Page',
- 'type': as === 'button' ? 'button' as const : undefined,
- 'disabled': disabled.value,
+ type: as === 'button' ? 'button' as const : undefined,
+ disabled: disabled.value,
}));
function handleClick() {
diff --git a/vue/primitives/src/pagination/PaginationPrev.vue b/vue/primitives/src/pagination/PaginationPrev.vue
index 8da1aa6..c802afa 100644
--- a/vue/primitives/src/pagination/PaginationPrev.vue
+++ b/vue/primitives/src/pagination/PaginationPrev.vue
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'Previous Page',
- 'type': as === 'button' ? 'button' as const : undefined,
- 'disabled': disabled.value,
+ type: as === 'button' ? 'button' as const : undefined,
+ disabled: disabled.value,
}));
function handleClick() {
diff --git a/vue/primitives/src/pagination/PaginationRoot.vue b/vue/primitives/src/pagination/PaginationRoot.vue
index 010f067..280d388 100644
--- a/vue/primitives/src/pagination/PaginationRoot.vue
+++ b/vue/primitives/src/pagination/PaginationRoot.vue
@@ -13,7 +13,7 @@ export interface PaginationRootProps extends PrimitiveProps {