mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 10:54:44 +00:00
Compare commits
2 Commits
a996eb74b9
...
docs
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fa38110b7 | |||
| 4574bae0b6 |
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
@@ -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(`
|
||||
<input type="text" />
|
||||
<button>Click</button>
|
||||
<a href="#">Link</a>
|
||||
<div tabindex="0">Div</div>
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(4);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips disabled elements', () => {
|
||||
const container = createContainer(`
|
||||
<button disabled>Disabled</button>
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
expect(candidates[0]!.tagName).toBe('INPUT');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips hidden inputs', () => {
|
||||
const container = createContainer(`
|
||||
<input type="hidden" />
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
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(`
|
||||
<input type="text" hidden />
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns empty array for container with no focusable elements', () => {
|
||||
const container = createContainer(`
|
||||
<div>Just text</div>
|
||||
<span>More text</span>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<input type="text" data-testid="first" />
|
||||
<button>Middle</button>
|
||||
<input type="text" data-testid="last" />
|
||||
`);
|
||||
|
||||
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(`<div>no focusable</div>`);
|
||||
|
||||
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(`
|
||||
<input type="text" data-testid="a" />
|
||||
<input type="text" data-testid="b" />
|
||||
`);
|
||||
|
||||
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(`<input type="text" />`);
|
||||
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();
|
||||
});
|
||||
});
|
||||
168
core/platform/src/browsers/focusScope/index.ts
Normal file
168
core/platform/src/browsers/focusScope/index.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "^0.0.9",
|
||||
"@robonen/vue": "workspace:*",
|
||||
"@vue/shared": "catalog:",
|
||||
"vue": "catalog:"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<Direction>;
|
||||
nonce: Ref<string | undefined>;
|
||||
teleportTarget: ShallowRef<string | HTMLElement>;
|
||||
}
|
||||
|
||||
export interface ConfigOptions {
|
||||
dir?: MaybeRefOrGetter<Direction>;
|
||||
nonce?: MaybeRefOrGetter<string | undefined>;
|
||||
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
|
||||
dir: 'ltr',
|
||||
nonce: undefined,
|
||||
teleportTarget: 'body',
|
||||
};
|
||||
|
||||
@@ -27,7 +24,6 @@ const ConfigCtx = useContextFactory<ConfigContext>('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),
|
||||
};
|
||||
}
|
||||
|
||||
93
vue/primitives/src/focus-scope/FocusScope.vue
Normal file
93
vue/primitives/src/focus-scope/FocusScope.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
export type FocusScopeEmits = {
|
||||
/** Автофокус при монтировании. Можно предотвратить через `event.preventDefault()`. */
|
||||
mountAutoFocus: [event: Event];
|
||||
/** Автофокус при размонтировании. Можно предотвратить через `event.preventDefault()`. */
|
||||
unmountAutoFocus: [event: Event];
|
||||
};
|
||||
|
||||
export interface FocusScopeProps extends PrimitiveProps {
|
||||
/**
|
||||
* Зациклить Tab/Shift+Tab: с последнего элемента — на первый и наоборот.
|
||||
* @default false
|
||||
*/
|
||||
loop?: boolean;
|
||||
|
||||
/**
|
||||
* Удерживать фокус внутри scope — фокус не может покинуть контейнер
|
||||
* через клавиатуру, указатель или программный вызов.
|
||||
* @default false
|
||||
*/
|
||||
trapped?: boolean;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { focus, getActiveElement, getTabbableEdges } from '@robonen/platform/browsers';
|
||||
import type { FocusScopeAPI } from './stack';
|
||||
import { Primitive } from '../primitive';
|
||||
import { useAutoFocus } from './useAutoFocus';
|
||||
import { useFocusTrap } from './useFocusTrap';
|
||||
import { useTemplateRef } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<FocusScopeProps>(), {
|
||||
loop: false,
|
||||
trapped: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<FocusScopeEmits>();
|
||||
|
||||
const containerRef = useTemplateRef<HTMLElement>('containerRef');
|
||||
|
||||
const focusScope: FocusScopeAPI = {
|
||||
paused: false,
|
||||
pause() { this.paused = true; },
|
||||
resume() { this.paused = false; },
|
||||
};
|
||||
|
||||
useFocusTrap(containerRef, focusScope, () => props.trapped);
|
||||
useAutoFocus(
|
||||
containerRef,
|
||||
focusScope,
|
||||
ev => emit('mountAutoFocus', ev),
|
||||
ev => emit('unmountAutoFocus', ev),
|
||||
);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!props.loop && !props.trapped) return;
|
||||
if (focusScope.paused) return;
|
||||
|
||||
const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||
const focusedElement = getActiveElement();
|
||||
|
||||
if (!isTabKey || !focusedElement) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const { first, last } = getTabbableEdges(container);
|
||||
|
||||
if (!first || !last) {
|
||||
if (focusedElement === container) event.preventDefault();
|
||||
}
|
||||
else if (!event.shiftKey && focusedElement === last) {
|
||||
event.preventDefault();
|
||||
if (props.loop) focus(first, { select: true });
|
||||
}
|
||||
else if (event.shiftKey && focusedElement === first) {
|
||||
event.preventDefault();
|
||||
if (props.loop) focus(last, { select: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
ref="containerRef"
|
||||
tabindex="-1"
|
||||
:as="as"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
450
vue/primitives/src/focus-scope/__test__/FocusScope.test.ts
Normal file
450
vue/primitives/src/focus-scope/__test__/FocusScope.test.ts
Normal file
@@ -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<string, unknown> = {}, slots?: Record<string, () => any>) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
FocusScope,
|
||||
props,
|
||||
slots ?? {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'first' }),
|
||||
h('input', { type: 'text', 'data-testid': 'second' }),
|
||||
h('input', { type: 'text', 'data-testid': 'third' }),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
describe('FocusScope', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('renders slot content inside a div with tabindex="-1"', () => {
|
||||
const wrapper = createFocusScope();
|
||||
|
||||
expect(wrapper.find('[tabindex="-1"]').exists()).toBe(true);
|
||||
expect(wrapper.findAll('input').length).toBe(3);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders with custom element via as prop', () => {
|
||||
const wrapper = createFocusScope({ as: 'section' });
|
||||
|
||||
expect(wrapper.find('section').exists()).toBe(true);
|
||||
expect(wrapper.find('section').attributes('tabindex')).toBe('-1');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('auto-focuses first tabbable element on mount', async () => {
|
||||
const wrapper = createFocusScope();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits mountAutoFocus on mount', async () => {
|
||||
const onMountAutoFocus = vi.fn();
|
||||
const wrapper = createFocusScope({ onMountAutoFocus });
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(onMountAutoFocus).toHaveBeenCalled();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits unmountAutoFocus on unmount', async () => {
|
||||
const onUnmountAutoFocus = vi.fn();
|
||||
const wrapper = createFocusScope({ onUnmountAutoFocus });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
expect(onUnmountAutoFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('focuses container when no tabbable elements exist', async () => {
|
||||
const wrapper = createFocusScope({}, {
|
||||
default: () => h('span', 'no focusable elements'),
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]').element;
|
||||
expect(document.activeElement).toBe(container);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope loop', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('wraps focus from last to first on Tab when loop=true', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab' });
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('wraps focus from first to last on Shift+Tab when loop=true', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
firstInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab', shiftKey: true });
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not wrap focus when loop=false', async () => {
|
||||
const wrapper = createFocusScope({ loop: false });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab' });
|
||||
|
||||
// Focus should remain on the last element (no wrapping)
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ignores non-Tab keys', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Enter' });
|
||||
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('ignores Tab with modifier keys', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const lastInput = wrapper.find('[data-testid="third"]').element as HTMLInputElement;
|
||||
lastInput.focus();
|
||||
await nextTick();
|
||||
|
||||
const container = wrapper.find('[tabindex="-1"]');
|
||||
await container.trigger('keydown', { key: 'Tab', ctrlKey: true });
|
||||
|
||||
expect(document.activeElement).toBe(lastInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope trapped', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('returns focus to last focused element when focus leaves', async () => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h('button', { id: 'outside' }, 'outside'),
|
||||
h(FocusScope, { trapped: true }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inside' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
// Simulate focus moving outside
|
||||
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
|
||||
outsideButton.focus();
|
||||
|
||||
// The focusin event handler should bring focus back
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('activates trap when trapped changes from false to true', async () => {
|
||||
const trapped = ref(false);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h('button', { id: 'outside' }, 'outside'),
|
||||
h(FocusScope, { trapped: trapped.value }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inside' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Not trapped yet — focus can leave
|
||||
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
|
||||
outsideButton.focus();
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
|
||||
// Enable trap
|
||||
trapped.value = true;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Focus inside first
|
||||
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
|
||||
insideInput.focus();
|
||||
await nextTick();
|
||||
|
||||
// Try to leave — should be pulled back
|
||||
outsideButton.focus();
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('deactivates trap when trapped changes from true to false', async () => {
|
||||
const trapped = ref(true);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => [
|
||||
h('button', { id: 'outside' }, 'outside'),
|
||||
h(FocusScope, { trapped: trapped.value }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inside' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const insideInput = wrapper.find('[data-testid="inside"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(insideInput);
|
||||
|
||||
// Disable trap
|
||||
trapped.value = false;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Focus can now leave
|
||||
const outsideButton = wrapper.find('#outside').element as HTMLButtonElement;
|
||||
outsideButton.focus();
|
||||
await nextTick();
|
||||
expect(document.activeElement).toBe(outsideButton);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('refocuses container when focused element is removed from DOM', async () => {
|
||||
const showChild = ref(true);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(FocusScope, { trapped: true }, {
|
||||
default: () =>
|
||||
showChild.value
|
||||
? [h('input', { type: 'text', 'data-testid': 'removable' })]
|
||||
: [h('span', 'empty')],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const input = wrapper.find('[data-testid="removable"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
// Remove the focused element
|
||||
showChild.value = false;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// MutationObserver should refocus the container
|
||||
const container = wrapper.find('[tabindex="-1"]').element;
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(container);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope preventAutoFocus', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('prevents auto-focus on mount via event.preventDefault()', async () => {
|
||||
const wrapper = createFocusScope({
|
||||
onMountAutoFocus: (e: Event) => e.preventDefault(),
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element;
|
||||
// Focus should not have been moved to the first input
|
||||
expect(document.activeElement).not.toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('prevents focus restore on unmount via event.preventDefault()', async () => {
|
||||
const wrapper = createFocusScope({
|
||||
onUnmountAutoFocus: (e: Event) => e.preventDefault(),
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const firstInput = wrapper.find('[data-testid="first"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(firstInput);
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
// Focus should NOT have been restored to body
|
||||
expect(document.activeElement).not.toBe(firstInput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FocusScope nested stacks', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.body.focus();
|
||||
});
|
||||
|
||||
it('pauses outer scope when inner scope mounts, resumes on inner unmount', async () => {
|
||||
const showInner = ref(false);
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(FocusScope, { trapped: true }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'outer-input' }),
|
||||
showInner.value
|
||||
? h(FocusScope, { trapped: true }, {
|
||||
default: () => [
|
||||
h('input', { type: 'text', 'data-testid': 'inner-input' }),
|
||||
],
|
||||
})
|
||||
: null,
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Outer scope auto-focused
|
||||
const outerInput = wrapper.find('[data-testid="outer-input"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(outerInput);
|
||||
|
||||
// Mount inner scope
|
||||
showInner.value = true;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Inner scope should auto-focus its content
|
||||
const innerInput = wrapper.find('[data-testid="inner-input"]').element as HTMLInputElement;
|
||||
expect(document.activeElement).toBe(innerInput);
|
||||
|
||||
// Unmount inner scope
|
||||
showInner.value = false;
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// Focus should return to outer scope's previously focused element
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(outerInput);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
67
vue/primitives/src/focus-scope/__test__/a11y.test.ts
Normal file
67
vue/primitives/src/focus-scope/__test__/a11y.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import FocusScope from '../FocusScope.vue';
|
||||
import axe from 'axe-core';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
async function checkA11y(element: Element) {
|
||||
const results = await axe.run(element);
|
||||
return results.violations;
|
||||
}
|
||||
|
||||
function createFocusScope(props: Record<string, unknown> = {}) {
|
||||
return mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
FocusScope,
|
||||
props,
|
||||
{
|
||||
default: () => [
|
||||
h('button', { type: 'button' }, 'First'),
|
||||
h('button', { type: 'button' }, 'Second'),
|
||||
h('button', { type: 'button' }, 'Third'),
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ attachTo: document.body },
|
||||
);
|
||||
}
|
||||
|
||||
describe('FocusScope a11y', () => {
|
||||
it('has no axe violations with default props', async () => {
|
||||
const wrapper = createFocusScope();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations with loop enabled', async () => {
|
||||
const wrapper = createFocusScope({ loop: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('has no axe violations with trapped enabled', async () => {
|
||||
const wrapper = createFocusScope({ trapped: true });
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const violations = await checkA11y(wrapper.element);
|
||||
expect(violations).toEqual([]);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
3
vue/primitives/src/focus-scope/index.ts
Normal file
3
vue/primitives/src/focus-scope/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FocusScope } from './FocusScope.vue';
|
||||
|
||||
export type { FocusScopeEmits, FocusScopeProps } from './FocusScope.vue';
|
||||
29
vue/primitives/src/focus-scope/stack.ts
Normal file
29
vue/primitives/src/focus-scope/stack.ts
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
63
vue/primitives/src/focus-scope/useAutoFocus.ts
Normal file
63
vue/primitives/src/focus-scope/useAutoFocus.ts
Normal file
@@ -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<ShallowRef<HTMLElement | null>>,
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
60
vue/primitives/src/focus-scope/useFocusTrap.ts
Normal file
60
vue/primitives/src/focus-scope/useFocusTrap.ts
Normal file
@@ -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<ShallowRef<HTMLElement | null>>,
|
||||
focusScope: FocusScopeAPI,
|
||||
trapped: MaybeRefOrGetter<boolean>,
|
||||
) {
|
||||
const lastFocusedElement = shallowRef<HTMLElement | null>(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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './config-provider';
|
||||
export * from './primitive';
|
||||
export * from './presence';
|
||||
export * from './pagination';
|
||||
export * from './focus-scope';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface PaginationRootProps extends PrimitiveProps {
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue';
|
||||
import { useOffsetPagination, useForwardExpose } from '@robonen/vue';
|
||||
import { useForwardExpose, useOffsetPagination } from '@robonen/vue';
|
||||
import { Primitive } from '@/primitive';
|
||||
import { providePaginationContext } from './context';
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import {
|
||||
PaginationRoot,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationFirst,
|
||||
PaginationPrev,
|
||||
PaginationNext,
|
||||
PaginationLast,
|
||||
PaginationEllipsis,
|
||||
PaginationPrev,
|
||||
PaginationRoot,
|
||||
} from '..';
|
||||
import type { PaginationItem } from '../utils';
|
||||
|
||||
@@ -23,10 +23,10 @@ function createPagination(props: Record<string, unknown> = {}) {
|
||||
h(
|
||||
PaginationRoot,
|
||||
{
|
||||
'total': 100,
|
||||
'pageSize': 10,
|
||||
total: 100,
|
||||
pageSize: 10,
|
||||
...props,
|
||||
'page': page.value,
|
||||
page: page.value,
|
||||
'onUpdate:page': (v: number) => {
|
||||
page.value = v;
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defineComponent, h, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import axe from 'axe-core';
|
||||
import {
|
||||
PaginationRoot,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationFirst,
|
||||
PaginationPrev,
|
||||
PaginationNext,
|
||||
PaginationLast,
|
||||
PaginationEllipsis,
|
||||
PaginationPrev,
|
||||
PaginationRoot,
|
||||
} from '..';
|
||||
import type { PaginationItem } from '../utils';
|
||||
|
||||
@@ -30,10 +30,10 @@ function createPagination(props: Record<string, unknown> = {}) {
|
||||
h(
|
||||
PaginationRoot,
|
||||
{
|
||||
'total': 100,
|
||||
'pageSize': 10,
|
||||
total: 100,
|
||||
pageSize: 10,
|
||||
...props,
|
||||
'page': page.value,
|
||||
page: page.value,
|
||||
'onUpdate:page': (v: number) => {
|
||||
page.value = v;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getRange, transform, PaginationItemType } from '../utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PaginationItemType, getRange, transform } from '../utils';
|
||||
|
||||
describe(getRange, () => {
|
||||
it('returns empty array for zero total pages', () => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Ref } from 'vue';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { usePresence } from '../usePresence';
|
||||
import Presence from '../Presence.vue';
|
||||
import {
|
||||
getAnimationName,
|
||||
shouldSuspendUnmount,
|
||||
dispatchAnimationEvent,
|
||||
getAnimationName,
|
||||
onAnimationSettle,
|
||||
shouldSuspendUnmount,
|
||||
} from '@robonen/platform/browsers';
|
||||
|
||||
vi.mock('@robonen/platform/browsers', () => ({
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { MaybeElement } from '@robonen/vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
|
||||
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
||||
import {
|
||||
dispatchAnimationEvent,
|
||||
getAnimationName,
|
||||
onAnimationSettle,
|
||||
shouldSuspendUnmount,
|
||||
} from '@robonen/platform/browsers';
|
||||
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
||||
import type { MaybeElement } from '@robonen/vue';
|
||||
|
||||
export interface UsePresenceReturn {
|
||||
isPresent: Readonly<Ref<boolean>>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { cloneVNode, Comment, createVNode, h } from 'vue';
|
||||
import { Comment, cloneVNode, createVNode, h } from 'vue';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
// -- Attribute sets of increasing size --
|
||||
@@ -9,13 +9,13 @@ const attrs1 = { class: 'a' };
|
||||
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
|
||||
|
||||
const attrs15 = {
|
||||
'class': 'a',
|
||||
'id': 'b',
|
||||
'style': { color: 'red' },
|
||||
'onClick': () => {},
|
||||
'role': 'button',
|
||||
'tabindex': '0',
|
||||
'title': 'tip',
|
||||
class: 'a',
|
||||
id: 'b',
|
||||
style: { color: 'red' },
|
||||
onClick: () => {},
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: 'tip',
|
||||
'data-a': '1',
|
||||
'data-b': '2',
|
||||
'data-c': '3',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PrimitiveProps } from '..';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createVNode, Comment, h, defineComponent, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Comment, createVNode, defineComponent, h, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { Primitive, Slot } from '..';
|
||||
|
||||
@@ -224,7 +224,7 @@ describe(Primitive, () => {
|
||||
it('merges attrs onto the slotted child in template mode', () => {
|
||||
const wrapper = mount(Primitive, {
|
||||
props: { as: 'template' },
|
||||
attrs: { 'class': 'merged', 'data-testid': 'slot' },
|
||||
attrs: { class: 'merged', 'data-testid': 'slot' },
|
||||
slots: { default: () => h('div', 'child') },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h, render } from 'vue';
|
||||
import { Comment, Fragment, createVNode, h, render } from 'vue';
|
||||
import { PatchFlags } from '@vue/shared';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createVNode, Comment, Fragment, h } from 'vue';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Comment, Fragment, createVNode, h } from 'vue';
|
||||
import { getRawChildren } from '../getRawChildren';
|
||||
|
||||
describe(getRawChildren, () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Vue from 'unplugin-vue/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { resolve } from 'node:path';
|
||||
import Vue from 'unplugin-vue/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [Vue()],
|
||||
|
||||
Reference in New Issue
Block a user