mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 19:04:46 +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/no-empty-named-blocks': 'warn',
|
||||||
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
|
'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/comma-style': ['error', 'last'],
|
||||||
'@stylistic/semi': ['error', 'always'],
|
'@stylistic/semi': ['error', 'always'],
|
||||||
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
|
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
|
||||||
'@stylistic/quote-props': ['error', 'consistent-as-needed'],
|
'@stylistic/quote-props': ['error', 'as-needed'],
|
||||||
|
|
||||||
/* ── indentation ──────────────────────────────────────── */
|
/* ── indentation ──────────────────────────────────────── */
|
||||||
'@stylistic/indent': ['error', 2, {
|
'@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 './animationLifecycle';
|
||||||
export * from './focusGuard';
|
export * from './focusGuard';
|
||||||
|
export * from './focusScope';
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -218,6 +218,9 @@ importers:
|
|||||||
'@robonen/platform':
|
'@robonen/platform':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../core/platform
|
version: link:../../core/platform
|
||||||
|
'@robonen/stdlib':
|
||||||
|
specifier: ^0.0.9
|
||||||
|
version: 0.0.9
|
||||||
'@robonen/vue':
|
'@robonen/vue':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../toolkit
|
version: link:../toolkit
|
||||||
@@ -1983,6 +1986,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-zTK2X2r6fQTgQ1lqM0jaF/MgxmXCp0UrfiE1Ks3rQOBQjci4Xez1Zzsy4MgtjhMiHcdDi4lbBvtlPnksvEU8GQ==}
|
resolution: {integrity: sha512-zTK2X2r6fQTgQ1lqM0jaF/MgxmXCp0UrfiE1Ks3rQOBQjci4Xez1Zzsy4MgtjhMiHcdDi4lbBvtlPnksvEU8GQ==}
|
||||||
engines: {node: ^20.9.0 || ^22.11.0 || ^24, pnpm: ^10.0.0}
|
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':
|
'@rolldown/binding-android-arm64@1.0.0-rc.6':
|
||||||
resolution: {integrity: sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==}
|
resolution: {integrity: sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -9558,6 +9565,8 @@ snapshots:
|
|||||||
|
|
||||||
'@renovatebot/ruby-semver@4.1.2': {}
|
'@renovatebot/ruby-semver@4.1.2': {}
|
||||||
|
|
||||||
|
'@robonen/stdlib@0.0.9': {}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.6':
|
'@rolldown/binding-android-arm64@1.0.0-rc.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
|
||||||
import { defineConfig } from 'oxlint';
|
import { defineConfig } from 'oxlint';
|
||||||
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
|
||||||
|
|
||||||
export default defineConfig(compose(base, typescript, imports, stylistic, {
|
export default defineConfig(compose(base, typescript, imports, stylistic, {
|
||||||
overrides: [
|
overrides: [
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@robonen/platform": "workspace:*",
|
"@robonen/platform": "workspace:*",
|
||||||
|
"@robonen/stdlib": "^0.0.9",
|
||||||
"@robonen/vue": "workspace:*",
|
"@robonen/vue": "workspace:*",
|
||||||
"@vue/shared": "catalog:",
|
"@vue/shared": "catalog:",
|
||||||
"vue": "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 { defineComponent, h } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import {
|
import {
|
||||||
provideConfig,
|
|
||||||
provideAppConfig,
|
provideAppConfig,
|
||||||
|
provideConfig,
|
||||||
useConfig,
|
useConfig,
|
||||||
} from '..';
|
} from '..';
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ describe('useConfig', () => {
|
|||||||
return h('div', {
|
return h('div', {
|
||||||
'data-dir': this.config.dir.value,
|
'data-dir': this.config.dir.value,
|
||||||
'data-target': this.config.teleportTarget.value,
|
'data-target': this.config.teleportTarget.value,
|
||||||
'data-nonce': this.config.nonce.value,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -52,7 +51,6 @@ describe('useConfig', () => {
|
|||||||
provideConfig({
|
provideConfig({
|
||||||
dir: 'rtl',
|
dir: 'rtl',
|
||||||
teleportTarget: '#app',
|
teleportTarget: '#app',
|
||||||
nonce: 'abc123',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
render() {
|
render() {
|
||||||
@@ -64,7 +62,6 @@ describe('useConfig', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
|
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
|
||||||
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
|
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
|
||||||
expect(wrapper.find('div').attributes('data-nonce')).toBe('abc123');
|
|
||||||
|
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { ref, shallowRef, toValue } from 'vue';
|
|
||||||
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||||
|
import { ref, shallowRef, toValue } from 'vue';
|
||||||
import { useContextFactory } from '@robonen/vue';
|
import { useContextFactory } from '@robonen/vue';
|
||||||
|
|
||||||
export type Direction = 'ltr' | 'rtl';
|
export type Direction = 'ltr' | 'rtl';
|
||||||
|
|
||||||
export interface ConfigContext {
|
export interface ConfigContext {
|
||||||
dir: Ref<Direction>;
|
dir: Ref<Direction>;
|
||||||
nonce: Ref<string | undefined>;
|
|
||||||
teleportTarget: ShallowRef<string | HTMLElement>;
|
teleportTarget: ShallowRef<string | HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigOptions {
|
export interface ConfigOptions {
|
||||||
dir?: MaybeRefOrGetter<Direction>;
|
dir?: MaybeRefOrGetter<Direction>;
|
||||||
nonce?: MaybeRefOrGetter<string | undefined>;
|
|
||||||
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
|
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
|
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
|
||||||
dir: 'ltr',
|
dir: 'ltr',
|
||||||
nonce: undefined,
|
|
||||||
teleportTarget: 'body',
|
teleportTarget: 'body',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +24,6 @@ const ConfigCtx = useContextFactory<ConfigContext>('ConfigContext');
|
|||||||
function resolveContext(options?: ConfigOptions): ConfigContext {
|
function resolveContext(options?: ConfigOptions): ConfigContext {
|
||||||
return {
|
return {
|
||||||
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
|
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
|
||||||
nonce: ref(toValue(options?.nonce) ?? DEFAULT_CONFIG.nonce),
|
|
||||||
teleportTarget: shallowRef(toValue(options?.teleportTarget) ?? DEFAULT_CONFIG.teleportTarget),
|
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 './primitive';
|
||||||
export * from './presence';
|
export * from './presence';
|
||||||
export * from './pagination';
|
export * from './pagination';
|
||||||
|
export * from './focus-scope';
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
|
|||||||
|
|
||||||
const attrs = computed(() => ({
|
const attrs = computed(() => ({
|
||||||
'aria-label': 'First Page',
|
'aria-label': 'First Page',
|
||||||
'type': as === 'button' ? 'button' as const : undefined,
|
type: as === 'button' ? 'button' as const : undefined,
|
||||||
'disabled': disabled.value,
|
disabled: disabled.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
|
|||||||
|
|
||||||
const attrs = computed(() => ({
|
const attrs = computed(() => ({
|
||||||
'aria-label': 'Last Page',
|
'aria-label': 'Last Page',
|
||||||
'type': as === 'button' ? 'button' as const : undefined,
|
type: as === 'button' ? 'button' as const : undefined,
|
||||||
'disabled': disabled.value,
|
disabled: disabled.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const attrs = computed(() => ({
|
|||||||
'aria-label': `Page ${value}`,
|
'aria-label': `Page ${value}`,
|
||||||
'aria-current': isSelected.value ? 'page' as const : undefined,
|
'aria-current': isSelected.value ? 'page' as const : undefined,
|
||||||
'data-selected': isSelected.value ? 'true' : undefined,
|
'data-selected': isSelected.value ? 'true' : undefined,
|
||||||
'disabled': disabled.value,
|
disabled: disabled.value,
|
||||||
'type': as === 'button' ? 'button' as const : undefined,
|
type: as === 'button' ? 'button' as const : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
|
|||||||
|
|
||||||
const attrs = computed(() => ({
|
const attrs = computed(() => ({
|
||||||
'aria-label': 'Next Page',
|
'aria-label': 'Next Page',
|
||||||
'type': as === 'button' ? 'button' as const : undefined,
|
type: as === 'button' ? 'button' as const : undefined,
|
||||||
'disabled': disabled.value,
|
disabled: disabled.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
|
|||||||
|
|
||||||
const attrs = computed(() => ({
|
const attrs = computed(() => ({
|
||||||
'aria-label': 'Previous Page',
|
'aria-label': 'Previous Page',
|
||||||
'type': as === 'button' ? 'button' as const : undefined,
|
type: as === 'button' ? 'button' as const : undefined,
|
||||||
'disabled': disabled.value,
|
disabled: disabled.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface PaginationRootProps extends PrimitiveProps {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { toRef } from 'vue';
|
import { toRef } from 'vue';
|
||||||
import { useOffsetPagination, useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose, useOffsetPagination } from '@robonen/vue';
|
||||||
import { Primitive } from '@/primitive';
|
import { Primitive } from '@/primitive';
|
||||||
import { providePaginationContext } from './context';
|
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 { defineComponent, h, nextTick, ref } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import {
|
import {
|
||||||
PaginationRoot,
|
PaginationEllipsis,
|
||||||
|
PaginationFirst,
|
||||||
|
PaginationLast,
|
||||||
PaginationList,
|
PaginationList,
|
||||||
PaginationListItem,
|
PaginationListItem,
|
||||||
PaginationFirst,
|
|
||||||
PaginationPrev,
|
|
||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationLast,
|
PaginationPrev,
|
||||||
PaginationEllipsis,
|
PaginationRoot,
|
||||||
} from '..';
|
} from '..';
|
||||||
import type { PaginationItem } from '../utils';
|
import type { PaginationItem } from '../utils';
|
||||||
|
|
||||||
@@ -23,10 +23,10 @@ function createPagination(props: Record<string, unknown> = {}) {
|
|||||||
h(
|
h(
|
||||||
PaginationRoot,
|
PaginationRoot,
|
||||||
{
|
{
|
||||||
'total': 100,
|
total: 100,
|
||||||
'pageSize': 10,
|
pageSize: 10,
|
||||||
...props,
|
...props,
|
||||||
'page': page.value,
|
page: page.value,
|
||||||
'onUpdate:page': (v: number) => {
|
'onUpdate:page': (v: number) => {
|
||||||
page.value = v;
|
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 { defineComponent, h, ref } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import axe from 'axe-core';
|
import axe from 'axe-core';
|
||||||
import {
|
import {
|
||||||
PaginationRoot,
|
PaginationEllipsis,
|
||||||
|
PaginationFirst,
|
||||||
|
PaginationLast,
|
||||||
PaginationList,
|
PaginationList,
|
||||||
PaginationListItem,
|
PaginationListItem,
|
||||||
PaginationFirst,
|
|
||||||
PaginationPrev,
|
|
||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationLast,
|
PaginationPrev,
|
||||||
PaginationEllipsis,
|
PaginationRoot,
|
||||||
} from '..';
|
} from '..';
|
||||||
import type { PaginationItem } from '../utils';
|
import type { PaginationItem } from '../utils';
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ function createPagination(props: Record<string, unknown> = {}) {
|
|||||||
h(
|
h(
|
||||||
PaginationRoot,
|
PaginationRoot,
|
||||||
{
|
{
|
||||||
'total': 100,
|
total: 100,
|
||||||
'pageSize': 10,
|
pageSize: 10,
|
||||||
...props,
|
...props,
|
||||||
'page': page.value,
|
page: page.value,
|
||||||
'onUpdate:page': (v: number) => {
|
'onUpdate:page': (v: number) => {
|
||||||
page.value = v;
|
page.value = v;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getRange, transform, PaginationItemType } from '../utils';
|
import { PaginationItemType, getRange, transform } from '../utils';
|
||||||
|
|
||||||
describe(getRange, () => {
|
describe(getRange, () => {
|
||||||
it('returns empty array for zero total pages', () => {
|
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 type { Ref } from 'vue';
|
||||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { usePresence } from '../usePresence';
|
import { usePresence } from '../usePresence';
|
||||||
import Presence from '../Presence.vue';
|
import Presence from '../Presence.vue';
|
||||||
import {
|
import {
|
||||||
getAnimationName,
|
|
||||||
shouldSuspendUnmount,
|
|
||||||
dispatchAnimationEvent,
|
dispatchAnimationEvent,
|
||||||
|
getAnimationName,
|
||||||
onAnimationSettle,
|
onAnimationSettle,
|
||||||
|
shouldSuspendUnmount,
|
||||||
} from '@robonen/platform/browsers';
|
} from '@robonen/platform/browsers';
|
||||||
|
|
||||||
vi.mock('@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 type { MaybeRefOrGetter, Ref } from 'vue';
|
||||||
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
|
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
|
||||||
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
|
||||||
import {
|
import {
|
||||||
dispatchAnimationEvent,
|
dispatchAnimationEvent,
|
||||||
getAnimationName,
|
getAnimationName,
|
||||||
onAnimationSettle,
|
onAnimationSettle,
|
||||||
shouldSuspendUnmount,
|
shouldSuspendUnmount,
|
||||||
} from '@robonen/platform/browsers';
|
} from '@robonen/platform/browsers';
|
||||||
|
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
|
||||||
|
import type { MaybeElement } from '@robonen/vue';
|
||||||
|
|
||||||
export interface UsePresenceReturn {
|
export interface UsePresenceReturn {
|
||||||
isPresent: Readonly<Ref<boolean>>;
|
isPresent: Readonly<Ref<boolean>>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { bench, describe } from 'vitest';
|
import { bench, describe } from 'vitest';
|
||||||
import { cloneVNode, Comment, createVNode, h } from 'vue';
|
import { Comment, cloneVNode, createVNode, h } from 'vue';
|
||||||
import { Primitive, Slot } from '..';
|
import { Primitive, Slot } from '..';
|
||||||
|
|
||||||
// -- Attribute sets of increasing size --
|
// -- 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 attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
|
||||||
|
|
||||||
const attrs15 = {
|
const attrs15 = {
|
||||||
'class': 'a',
|
class: 'a',
|
||||||
'id': 'b',
|
id: 'b',
|
||||||
'style': { color: 'red' },
|
style: { color: 'red' },
|
||||||
'onClick': () => {},
|
onClick: () => {},
|
||||||
'role': 'button',
|
role: 'button',
|
||||||
'tabindex': '0',
|
tabindex: '0',
|
||||||
'title': 'tip',
|
title: 'tip',
|
||||||
'data-a': '1',
|
'data-a': '1',
|
||||||
'data-b': '2',
|
'data-b': '2',
|
||||||
'data-c': '3',
|
'data-c': '3',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PrimitiveProps } from '..';
|
import type { PrimitiveProps } from '..';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { createVNode, Comment, h, defineComponent, markRaw, nextTick, ref, shallowRef } from 'vue';
|
import { Comment, createVNode, defineComponent, h, markRaw, nextTick, ref, shallowRef } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { Primitive, Slot } from '..';
|
import { Primitive, Slot } from '..';
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ describe(Primitive, () => {
|
|||||||
it('merges attrs onto the slotted child in template mode', () => {
|
it('merges attrs onto the slotted child in template mode', () => {
|
||||||
const wrapper = mount(Primitive, {
|
const wrapper = mount(Primitive, {
|
||||||
props: { as: 'template' },
|
props: { as: 'template' },
|
||||||
attrs: { 'class': 'merged', 'data-testid': 'slot' },
|
attrs: { class: 'merged', 'data-testid': 'slot' },
|
||||||
slots: { default: () => h('div', 'child') },
|
slots: { default: () => h('div', 'child') },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { bench, describe } from 'vitest';
|
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 { PatchFlags } from '@vue/shared';
|
||||||
import { getRawChildren } from '../getRawChildren';
|
import { getRawChildren } from '../getRawChildren';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createVNode, Comment, Fragment, h } from 'vue';
|
import { Comment, Fragment, createVNode, h } from 'vue';
|
||||||
import { getRawChildren } from '../getRawChildren';
|
import { getRawChildren } from '../getRawChildren';
|
||||||
|
|
||||||
describe(getRawChildren, () => {
|
describe(getRawChildren, () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM"],
|
"lib": ["ESNext", "DOM"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import Vue from 'unplugin-vue/vite';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import Vue from 'unplugin-vue/vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [Vue()],
|
plugins: [Vue()],
|
||||||
|
|||||||
Reference in New Issue
Block a user