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 {