1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 02:44:45 +00:00

feat(vue/primitives): add FocusScope component with auto-focus and focus trap functionality

This commit is contained in:
2026-03-10 18:28:52 +07:00
parent a996eb74b9
commit 4574bae0b6
36 changed files with 1266 additions and 65 deletions

View File

@@ -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',
}, },
}; };

View File

@@ -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, {

View 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();
});
});

View 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 };
}

View File

@@ -1,2 +1,3 @@
export * from './animationLifecycle'; export * from './animationLifecycle';
export * from './focusGuard'; export * from './focusGuard';
export * from './focusScope';

9
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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: [

View File

@@ -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:"

Binary file not shown.

View File

@@ -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();
}); });

View File

@@ -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),
}; };
} }

View 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>

View 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();
});
});

View 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();
});
});

View File

@@ -0,0 +1,3 @@
export { default as FocusScope } from './FocusScope.vue';
export type { FocusScopeEmits, FocusScopeProps } from './FocusScope.vue';

View 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();
},
};
}

View 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);
});
});
}

View 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();
});
});
}

View File

@@ -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';

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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';

View File

@@ -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;
}, },

View File

@@ -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;
}, },

View File

@@ -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', () => {

View File

@@ -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', () => ({

View File

@@ -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>>;

View File

@@ -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',

View File

@@ -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') },
}); });

View File

@@ -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';

View File

@@ -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, () => {

View File

@@ -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/*"]

View File

@@ -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()],
@@ -14,5 +14,6 @@ export default defineConfig({
}, },
test: { test: {
environment: 'jsdom', environment: 'jsdom',
execArgv: ['--expose-gc'],
}, },
}); });