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:
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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]!);
|
||||
}
|
||||
Reference in New Issue
Block a user