From 4574bae0b65924713569b377bcd9da5e3888bb0e Mon Sep 17 00:00:00 2001 From: robonen Date: Tue, 10 Mar 2026 18:28:52 +0700 Subject: [PATCH] feat(vue/primitives): add FocusScope component with auto-focus and focus trap functionality --- configs/oxlint/src/presets/imports.ts | 2 +- configs/oxlint/src/presets/stylistic.ts | 2 +- .../src/browsers/focusScope/index.test.ts | 262 ++++++++++ .../platform/src/browsers/focusScope/index.ts | 168 +++++++ core/platform/src/browsers/index.ts | 1 + pnpm-lock.yaml | 9 + vue/primitives/oxlint.config.ts | 2 +- vue/primitives/package.json | 1 + vue/primitives/primitives.zip | Bin 0 -> 36345 bytes .../__test__/config-provider.test.ts | 7 +- vue/primitives/src/config-provider/context.ts | 6 +- vue/primitives/src/focus-scope/FocusScope.vue | 93 ++++ .../focus-scope/__test__/FocusScope.test.ts | 450 ++++++++++++++++++ .../src/focus-scope/__test__/a11y.test.ts | 67 +++ vue/primitives/src/focus-scope/index.ts | 3 + vue/primitives/src/focus-scope/stack.ts | 29 ++ .../src/focus-scope/useAutoFocus.ts | 63 +++ .../src/focus-scope/useFocusTrap.ts | 60 +++ vue/primitives/src/index.ts | 1 + .../src/pagination/PaginationFirst.vue | 4 +- .../src/pagination/PaginationLast.vue | 4 +- .../src/pagination/PaginationListItem.vue | 4 +- .../src/pagination/PaginationNext.vue | 4 +- .../src/pagination/PaginationPrev.vue | 4 +- .../src/pagination/PaginationRoot.vue | 2 +- .../pagination/__test__/Pagination.test.ts | 18 +- .../src/pagination/__test__/a11y.test.ts | 18 +- .../src/pagination/__test__/utils.test.ts | 4 +- .../src/presence/__test__/Presence.test.ts | 6 +- vue/primitives/src/presence/usePresence.ts | 4 +- .../src/primitive/__test__/Primitive.bench.ts | 16 +- .../src/primitive/__test__/Primitive.test.ts | 6 +- .../utils/__test__/getRawChildren.bench.ts | 2 +- .../src/utils/__test__/getRawChildren.test.ts | 4 +- vue/primitives/tsconfig.json | 2 +- vue/primitives/vitest.config.ts | 3 +- 36 files changed, 1266 insertions(+), 65 deletions(-) create mode 100644 core/platform/src/browsers/focusScope/index.test.ts create mode 100644 core/platform/src/browsers/focusScope/index.ts create mode 100644 vue/primitives/primitives.zip create mode 100644 vue/primitives/src/focus-scope/FocusScope.vue create mode 100644 vue/primitives/src/focus-scope/__test__/FocusScope.test.ts create mode 100644 vue/primitives/src/focus-scope/__test__/a11y.test.ts create mode 100644 vue/primitives/src/focus-scope/index.ts create mode 100644 vue/primitives/src/focus-scope/stack.ts create mode 100644 vue/primitives/src/focus-scope/useAutoFocus.ts create mode 100644 vue/primitives/src/focus-scope/useFocusTrap.ts 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 0000000000000000000000000000000000000000..224578ea021abca47e9f3d641caf3757116f8078 GIT binary patch literal 36345 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 { 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 {