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

@@ -1,5 +1,5 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic, {
overrides: [

View File

@@ -57,6 +57,7 @@
},
"dependencies": {
"@robonen/platform": "workspace:*",
"@robonen/stdlib": "^0.0.9",
"@robonen/vue": "workspace:*",
"@vue/shared": "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 { mount } from '@vue/test-utils';
import {
provideConfig,
provideAppConfig,
provideConfig,
useConfig,
} from '..';
@@ -42,7 +42,6 @@ describe('useConfig', () => {
return h('div', {
'data-dir': this.config.dir.value,
'data-target': this.config.teleportTarget.value,
'data-nonce': this.config.nonce.value,
});
},
});
@@ -52,7 +51,6 @@ describe('useConfig', () => {
provideConfig({
dir: 'rtl',
teleportTarget: '#app',
nonce: 'abc123',
});
},
render() {
@@ -64,7 +62,6 @@ describe('useConfig', () => {
expect(wrapper.find('div').attributes('data-dir')).toBe('rtl');
expect(wrapper.find('div').attributes('data-target')).toBe('#app');
expect(wrapper.find('div').attributes('data-nonce')).toBe('abc123');
wrapper.unmount();
});

View File

@@ -1,24 +1,21 @@
import { ref, shallowRef, toValue } from 'vue';
import type { App, MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
import { ref, shallowRef, toValue } from 'vue';
import { useContextFactory } from '@robonen/vue';
export type Direction = 'ltr' | 'rtl';
export interface ConfigContext {
dir: Ref<Direction>;
nonce: Ref<string | undefined>;
teleportTarget: ShallowRef<string | HTMLElement>;
}
export interface ConfigOptions {
dir?: MaybeRefOrGetter<Direction>;
nonce?: MaybeRefOrGetter<string | undefined>;
teleportTarget?: MaybeRefOrGetter<string | HTMLElement>;
}
const DEFAULT_CONFIG: UnwrapRef<ConfigContext> = {
dir: 'ltr',
nonce: undefined,
teleportTarget: 'body',
};
@@ -27,7 +24,6 @@ const ConfigCtx = useContextFactory<ConfigContext>('ConfigContext');
function resolveContext(options?: ConfigOptions): ConfigContext {
return {
dir: ref(toValue(options?.dir) ?? DEFAULT_CONFIG.dir),
nonce: ref(toValue(options?.nonce) ?? DEFAULT_CONFIG.nonce),
teleportTarget: shallowRef(toValue(options?.teleportTarget) ?? DEFAULT_CONFIG.teleportTarget),
};
}

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 './presence';
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(() => ({
'aria-label': 'First Page',
'type': as === 'button' ? 'button' as const : undefined,
'disabled': disabled.value,
type: as === 'button' ? 'button' as const : undefined,
disabled: disabled.value,
}));
function handleClick() {

View File

@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'Last Page',
'type': as === 'button' ? 'button' as const : undefined,
'disabled': disabled.value,
type: as === 'button' ? 'button' as const : undefined,
disabled: disabled.value,
}));
function handleClick() {

View File

@@ -25,8 +25,8 @@ const attrs = computed(() => ({
'aria-label': `Page ${value}`,
'aria-current': isSelected.value ? 'page' as const : undefined,
'data-selected': isSelected.value ? 'true' : undefined,
'disabled': disabled.value,
'type': as === 'button' ? 'button' as const : undefined,
disabled: disabled.value,
type: as === 'button' ? 'button' as const : undefined,
}));
function handleClick() {

View File

@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isLastPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'Next Page',
'type': as === 'button' ? 'button' as const : undefined,
'disabled': disabled.value,
type: as === 'button' ? 'button' as const : undefined,
disabled: disabled.value,
}));
function handleClick() {

View File

@@ -19,8 +19,8 @@ const disabled = computed(() => ctx.isFirstPage.value || ctx.disabled.value);
const attrs = computed(() => ({
'aria-label': 'Previous Page',
'type': as === 'button' ? 'button' as const : undefined,
'disabled': disabled.value,
type: as === 'button' ? 'button' as const : undefined,
disabled: disabled.value,
}));
function handleClick() {

View File

@@ -13,7 +13,7 @@ export interface PaginationRootProps extends PrimitiveProps {
<script setup lang="ts">
import { toRef } from 'vue';
import { useOffsetPagination, useForwardExpose } from '@robonen/vue';
import { useForwardExpose, useOffsetPagination } from '@robonen/vue';
import { Primitive } from '@/primitive';
import { providePaginationContext } from './context';

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 { mount } from '@vue/test-utils';
import {
PaginationRoot,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationFirst,
PaginationPrev,
PaginationNext,
PaginationLast,
PaginationEllipsis,
PaginationPrev,
PaginationRoot,
} from '..';
import type { PaginationItem } from '../utils';
@@ -23,10 +23,10 @@ function createPagination(props: Record<string, unknown> = {}) {
h(
PaginationRoot,
{
'total': 100,
'pageSize': 10,
total: 100,
pageSize: 10,
...props,
'page': page.value,
page: page.value,
'onUpdate:page': (v: number) => {
page.value = v;
},

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 { mount } from '@vue/test-utils';
import axe from 'axe-core';
import {
PaginationRoot,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationFirst,
PaginationPrev,
PaginationNext,
PaginationLast,
PaginationEllipsis,
PaginationPrev,
PaginationRoot,
} from '..';
import type { PaginationItem } from '../utils';
@@ -30,10 +30,10 @@ function createPagination(props: Record<string, unknown> = {}) {
h(
PaginationRoot,
{
'total': 100,
'pageSize': 10,
total: 100,
pageSize: 10,
...props,
'page': page.value,
page: page.value,
'onUpdate:page': (v: number) => {
page.value = v;
},

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getRange, transform, PaginationItemType } from '../utils';
import { describe, expect, it } from 'vitest';
import { PaginationItemType, getRange, transform } from '../utils';
describe(getRange, () => {
it('returns empty array for zero total pages', () => {

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 { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { usePresence } from '../usePresence';
import Presence from '../Presence.vue';
import {
getAnimationName,
shouldSuspendUnmount,
dispatchAnimationEvent,
getAnimationName,
onAnimationSettle,
shouldSuspendUnmount,
} from '@robonen/platform/browsers';
vi.mock('@robonen/platform/browsers', () => ({

View File

@@ -1,13 +1,13 @@
import type { MaybeElement } from '@robonen/vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { computed, readonly, shallowRef, toValue, watch } from 'vue';
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
import {
dispatchAnimationEvent,
getAnimationName,
onAnimationSettle,
shouldSuspendUnmount,
} from '@robonen/platform/browsers';
import { tryOnScopeDispose, unrefElement } from '@robonen/vue';
import type { MaybeElement } from '@robonen/vue';
export interface UsePresenceReturn {
isPresent: Readonly<Ref<boolean>>;

View File

@@ -1,5 +1,5 @@
import { bench, describe } from 'vitest';
import { cloneVNode, Comment, createVNode, h } from 'vue';
import { Comment, cloneVNode, createVNode, h } from 'vue';
import { Primitive, Slot } from '..';
// -- Attribute sets of increasing size --
@@ -9,13 +9,13 @@ const attrs1 = { class: 'a' };
const attrs5 = { class: 'a', id: 'b', role: 'button', tabindex: '0', title: 'tip' };
const attrs15 = {
'class': 'a',
'id': 'b',
'style': { color: 'red' },
'onClick': () => {},
'role': 'button',
'tabindex': '0',
'title': 'tip',
class: 'a',
id: 'b',
style: { color: 'red' },
onClick: () => {},
role: 'button',
tabindex: '0',
title: 'tip',
'data-a': '1',
'data-b': '2',
'data-c': '3',

View File

@@ -1,6 +1,6 @@
import type { PrimitiveProps } from '..';
import { describe, it, expect, vi } from 'vitest';
import { createVNode, Comment, h, defineComponent, markRaw, nextTick, ref, shallowRef } from 'vue';
import { describe, expect, it, vi } from 'vitest';
import { Comment, createVNode, defineComponent, h, markRaw, nextTick, ref, shallowRef } from 'vue';
import { mount } from '@vue/test-utils';
import { Primitive, Slot } from '..';
@@ -224,7 +224,7 @@ describe(Primitive, () => {
it('merges attrs onto the slotted child in template mode', () => {
const wrapper = mount(Primitive, {
props: { as: 'template' },
attrs: { 'class': 'merged', 'data-testid': 'slot' },
attrs: { class: 'merged', 'data-testid': 'slot' },
slots: { default: () => h('div', 'child') },
});

View File

@@ -1,5 +1,5 @@
import { bench, describe } from 'vitest';
import { createVNode, Comment, Fragment, h, render } from 'vue';
import { Comment, Fragment, createVNode, h, render } from 'vue';
import { PatchFlags } from '@vue/shared';
import { getRawChildren } from '../getRawChildren';

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { createVNode, Comment, Fragment, h } from 'vue';
import { describe, expect, it } from 'vitest';
import { Comment, Fragment, createVNode, h } from 'vue';
import { getRawChildren } from '../getRawChildren';
describe(getRawChildren, () => {

View File

@@ -1,7 +1,7 @@
{
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": ["DOM"],
"lib": ["ESNext", "DOM"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]

View File

@@ -1,6 +1,6 @@
import Vue from 'unplugin-vue/vite';
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
import Vue from 'unplugin-vue/vite';
export default defineConfig({
plugins: [Vue()],
@@ -14,5 +14,6 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
execArgv: ['--expose-gc'],
},
});