fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,156 @@
<script lang="ts">
import type { Direction } from '../config-provider';
import type { Orientation } from './utils';
import type { PrimitiveProps } from '../primitive';
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface RovingFocusGroupProps extends PrimitiveProps {
/** Navigation orientation — decides which arrow keys move focus. */
orientation?: Orientation;
/** Writing direction (LTR / RTL). Falls back to `useConfig().dir`. */
dir?: Direction;
/**
* Wrap around at the ends.
* @default false
*/
loop?: boolean;
/** Controlled current tab-stop id. */
currentTabStopId?: string | null;
/** Initial current tab-stop id (uncontrolled). */
defaultCurrentTabStopId?: string;
/**
* Prevent scroll when focus enters the group.
* @default false
*/
preventScrollOnEntryFocus?: boolean;
}
export interface RovingFocusGroupEmits {
entryFocus: [event: Event];
}
export interface RovingFocusGroupContext {
orientation: Ref<Orientation | undefined>;
dir: Ref<Direction>;
loop: Ref<boolean>;
currentTabStopId: Ref<string | null | undefined>;
onItemFocus: (tabStopId: string) => void;
onItemShiftTab: () => void;
onFocusableItemAdd: () => void;
onFocusableItemRemove: () => void;
}
export const RovingFocusGroupCtx = useContextFactory<RovingFocusGroupContext>(
'RovingFocusGroupContext',
);
</script>
<script setup lang="ts">
import { ENTRY_FOCUS, EVENT_OPTIONS, focusFirst } from './utils';
import { ref, toRef } from 'vue';
import { Primitive } from '../primitive';
import { useCollectionProvider } from '../collection';
import { useConfig } from '../config-provider';
const {
orientation,
dir,
loop = false,
currentTabStopId: currentTabStopIdProp,
defaultCurrentTabStopId,
preventScrollOnEntryFocus = false,
as = 'div',
} = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupEmits>();
const config = useConfig();
// `dir` falls back to the provider's configured direction when not given as prop.
const dirRef = toRef(() => dir ?? config.dir.value);
const orientationRef = toRef(() => orientation);
const loopRef = toRef(() => loop);
const currentTabStopId = ref<string | null | undefined>(
currentTabStopIdProp ?? defaultCurrentTabStopId,
);
const isTabbingBackOut = ref(false);
const isClickFocus = ref(false);
const focusableItemsCount = ref(0);
const { getItems, CollectionSlot } = useCollectionProvider();
function handleFocus(event: FocusEvent): void {
const isKeyboardFocus = !isClickFocus.value;
if (
event.currentTarget
&& event.target === event.currentTarget
&& isKeyboardFocus
&& !isTabbingBackOut.value
) {
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
event.currentTarget.dispatchEvent(entryFocusEvent);
emit('entryFocus', entryFocusEvent);
if (!entryFocusEvent.defaultPrevented) {
const items = getItems()
.map(i => i.ref)
.filter(i => i.dataset['disabled'] !== '');
const activeItem = items.find(item => item.getAttribute('data-active') === '');
const currentItem = items.find(item => item.id === currentTabStopId.value);
const candidateItems = [activeItem, currentItem, ...items].filter(
Boolean,
) as HTMLElement[];
focusFirst(candidateItems, preventScrollOnEntryFocus);
}
}
isClickFocus.value = false;
}
function handleMouseUp(): void {
setTimeout(() => {
isClickFocus.value = false;
}, 1);
}
defineExpose({ getItems });
RovingFocusGroupCtx.provide({
loop: loopRef,
dir: dirRef,
orientation: orientationRef,
currentTabStopId,
onItemFocus: (tabStopId: string) => {
currentTabStopId.value = tabStopId;
},
onItemShiftTab: () => {
isTabbingBackOut.value = true;
},
onFocusableItemAdd: () => {
focusableItemsCount.value++;
},
onFocusableItemRemove: () => {
focusableItemsCount.value--;
},
});
</script>
<template>
<CollectionSlot>
<Primitive
:tabindex="isTabbingBackOut || focusableItemsCount === 0 ? -1 : 0"
:data-orientation="orientation"
:as="as"
:dir="dirRef"
style="outline: none"
@mousedown="isClickFocus = true"
@mouseup="handleMouseUp"
@focus="handleFocus"
@blur="isTabbingBackOut = false"
>
<slot />
</Primitive>
</CollectionSlot>
</template>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface RovingFocusItemProps extends PrimitiveProps {
/** Unique tab-stop id. Auto-generated via config `useId` when omitted. */
tabStopId?: string;
/**
* Whether this item is focusable.
* @default true
*/
focusable?: boolean;
/** Marks the item as active (current selection). */
active?: boolean;
/**
* Allow `Shift+Arrow` for navigation.
* @default false
*/
allowShiftKey?: boolean;
}
</script>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted } from 'vue';
import { focusFirst, getFocusIntent, wrapArray } from './utils';
import { Primitive } from '../primitive';
import { RovingFocusGroupCtx } from './RovingFocusGroup.vue';
import { useCollectionInjector } from '../collection';
import { useId } from '../config-provider';
const {
tabStopId,
focusable = true,
active = false,
allowShiftKey = false,
as = 'span',
} = defineProps<RovingFocusItemProps>();
const context = RovingFocusGroupCtx.inject();
// `useId` returns a `ComputedRef<string>` in this repo — unwrap where needed.
const autoId = useId();
const id = computed(() => tabStopId ?? autoId.value);
const isCurrentTabStop = computed(() => context.currentTabStopId.value === id.value);
const { getItems, CollectionItem } = useCollectionInjector();
onMounted(() => {
if (focusable) context.onFocusableItemAdd();
});
onUnmounted(() => {
if (focusable) context.onFocusableItemRemove();
});
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Tab' && event.shiftKey) {
context.onItemShiftTab();
return;
}
if (event.target !== event.currentTarget) return;
const focusIntent = getFocusIntent(event, context.orientation.value, context.dir.value);
if (focusIntent === undefined) return;
if (
event.metaKey
|| event.ctrlKey
|| event.altKey
|| (allowShiftKey ? false : event.shiftKey)
)
return;
event.preventDefault();
let candidateNodes = getItems()
.map(i => i.ref)
.filter(i => i.dataset['disabled'] !== '');
if (focusIntent === 'last') {
candidateNodes.reverse();
}
else if (focusIntent === 'prev' || focusIntent === 'next') {
if (focusIntent === 'prev') candidateNodes.reverse();
const currentIndex = candidateNodes.indexOf(event.currentTarget as HTMLElement);
candidateNodes = context.loop.value
? wrapArray(candidateNodes, currentIndex + 1)
: candidateNodes.slice(currentIndex + 1);
}
nextTick(() => focusFirst(candidateNodes));
}
function handleMousedown(event: MouseEvent): void {
if (!focusable) event.preventDefault();
else context.onItemFocus(id.value);
}
</script>
<template>
<CollectionItem>
<Primitive
:tabindex="isCurrentTabStop ? 0 : -1"
:data-orientation="context.orientation.value"
:data-active="active ? '' : undefined"
:data-disabled="!focusable ? '' : undefined"
:as="as"
@mousedown="handleMousedown"
@focus="context.onItemFocus(id)"
@keydown="handleKeydown"
>
<slot />
</Primitive>
</CollectionItem>
</template>
+20
View File
@@ -0,0 +1,20 @@
export { default as RovingFocusGroup, RovingFocusGroupCtx } from './RovingFocusGroup.vue';
export { default as RovingFocusItem } from './RovingFocusItem.vue';
export type {
RovingFocusGroupProps,
RovingFocusGroupEmits,
RovingFocusGroupContext,
} from './RovingFocusGroup.vue';
export type { RovingFocusItemProps } from './RovingFocusItem.vue';
export {
ENTRY_FOCUS,
EVENT_OPTIONS,
focusFirst,
getFocusIntent,
getDirectionAwareKey,
wrapArray,
type FocusIntent,
type Orientation,
} from './utils';
+78
View File
@@ -0,0 +1,78 @@
import type { Direction } from '../config-provider';
import { getActiveElement } from '@robonen/platform/browsers';
export type Orientation = 'horizontal' | 'vertical';
/** Custom event dispatched when focus enters a `RovingFocusGroup` for the first time. */
export const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus';
/** Event options for `ENTRY_FOCUS` — non-bubbling, cancelable. */
export const EVENT_OPTIONS = { bubbles: false, cancelable: true } as const;
export type FocusIntent = 'first' | 'last' | 'prev' | 'next';
const MAP_KEY_TO_FOCUS_INTENT: Record<string, FocusIntent> = {
ArrowLeft: 'prev',
ArrowUp: 'prev',
ArrowRight: 'next',
ArrowDown: 'next',
PageUp: 'first',
Home: 'first',
PageDown: 'last',
End: 'last',
};
/**
* For RTL: swaps `ArrowLeft`/`ArrowRight`. Leaves other keys untouched.
*/
export function getDirectionAwareKey(key: string, dir?: Direction): string {
if (dir !== 'rtl') return key;
if (key === 'ArrowLeft') return 'ArrowRight';
if (key === 'ArrowRight') return 'ArrowLeft';
return key;
}
/**
* Resolves a `FocusIntent` from a keyboard event, respecting `orientation`
* and `dir`. Returns `undefined` if the key has no mapping or conflicts with
* the orientation (e.g. `ArrowUp` in a horizontal group).
*/
export function getFocusIntent(
event: KeyboardEvent,
orientation?: Orientation,
dir?: Direction,
): FocusIntent | undefined {
const key = getDirectionAwareKey(event.key, dir);
if (orientation === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight'))
return undefined;
if (orientation === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown'))
return undefined;
return MAP_KEY_TO_FOCUS_INTENT[key];
}
/**
* Focuses the first element from `candidates` that actually accepts focus.
* No-op if the currently focused element is already a candidate.
*/
export function focusFirst(candidates: HTMLElement[], preventScroll = false): void {
const previouslyFocused = getActiveElement();
for (const candidate of candidates) {
if (candidate === previouslyFocused) return;
candidate.focus({ preventScroll });
if (getActiveElement() !== previouslyFocused) return;
}
}
/**
* Rotates `array` so that the element at `startIndex` becomes first.
*
* @example
* wrapArray(['a','b','c','d'], 2) // => ['c','d','a','b']
*/
export function wrapArray<T>(array: T[], startIndex: number): T[] {
const len = array.length;
if (len === 0) return array;
return Array.from({ length: len }, (_, i) => array[(startIndex + i) % len]!);
}