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,171 @@
<script lang="ts">
import type { DismissableLayerEmits, DismissableLayerProps } from '../dismissable-layer';
export interface NavigationMenuContentImplProps extends DismissableLayerProps {}
export type NavigationMenuContentImplEmits = DismissableLayerEmits & {
pointerEnterContent: [];
pointerLeaveContent: [];
};
</script>
<script setup lang="ts">
import { computed, watchEffect } from 'vue';
import { focusFirst, getActiveElement, getTabbableCandidates } from '@robonen/platform/browsers';
import { useForwardExpose } from '@robonen/vue';
import { DismissableLayer } from '../dismissable-layer';
import { FocusScope } from '../focus-scope';
import { getFocusIntent, wrapArray } from '../roving-focus/utils';
import { useNavigationMenuContext, useNavigationMenuItemContext } from './context';
import { COLLECTION_ITEM_ATTR, EVENT_ROOT_CONTENT_DISMISS, getOpenState } from './utils';
defineOptions({ inheritAttrs: false });
defineProps<NavigationMenuContentImplProps>();
const emit = defineEmits<NavigationMenuContentImplEmits>();
const menuContext = useNavigationMenuContext();
const itemContext = useNavigationMenuItemContext();
const { forwardRef, currentElement } = useForwardExpose();
const motionAttribute = computed<'from-start' | 'from-end' | 'to-start' | 'to-end' | undefined>(() => {
const items = menuContext.rootNavigationMenu.value
? Array.from(menuContext.rootNavigationMenu.value.querySelectorAll('[data-primitives-navigation-menu-trigger]'))
: [];
const values = items.map(el => el.id.split('-trigger-').pop()).filter(Boolean) as string[];
if (menuContext.dir.value === 'rtl') values.reverse();
const index = values.indexOf(itemContext.value);
const prevIndex = values.indexOf(menuContext.previousValue.value);
const isSelected = itemContext.value === menuContext.modelValue.value;
const wasSelected = prevIndex === values.indexOf(menuContext.modelValue.value) && prevIndex !== -1;
if (!isSelected && !wasSelected) return undefined;
if (index === -1) return undefined;
if (isSelected) {
return prevIndex === -1 ? undefined : index > prevIndex ? 'from-end' : 'from-start';
}
// we are leaving
const curIndex = values.indexOf(menuContext.modelValue.value);
if (curIndex === -1) return undefined;
return curIndex > index ? 'to-start' : 'to-end';
});
function handleKeydown(ev: KeyboardEvent) {
const isMetaKey = ev.altKey || ev.ctrlKey || ev.metaKey;
if (ev.key === 'Tab' && !isMetaKey) {
const root = currentElement.value;
if (!root) return;
const candidates = getTabbableCandidates(root);
const focused = getActiveElement(document) as HTMLElement | null;
const idx = focused ? candidates.indexOf(focused) : -1;
const isMovingBackwards = ev.shiftKey;
const nextCandidates = isMovingBackwards
? candidates.slice(0, idx).reverse()
: candidates.slice(idx + 1);
if (focusFirst(nextCandidates)) {
ev.preventDefault();
}
else {
// edge — delegate to focus proxy
itemContext.focusProxyRef.value?.focus();
}
return;
}
const intent = getFocusIntent(ev, menuContext.orientation, menuContext.dir.value);
if (!intent) return;
const root = currentElement.value;
if (!root) return;
const linkItems = Array.from(root.querySelectorAll<HTMLElement>(`[${COLLECTION_ITEM_ATTR}]`));
const focused = getActiveElement(document) as HTMLElement | null;
const focusedIndex = focused ? linkItems.indexOf(focused) : -1;
if (intent === 'first') {
if (focusFirst(linkItems)) ev.preventDefault();
return;
}
if (intent === 'last') {
if (focusFirst([...linkItems].reverse())) ev.preventDefault();
return;
}
if (focusedIndex === -1) {
if (focusFirst(linkItems)) ev.preventDefault();
return;
}
const rotated = wrapArray(linkItems, focusedIndex);
const candidates = intent === 'prev' ? rotated.slice(1).reverse() : rotated.slice(1);
if (focusFirst(candidates)) ev.preventDefault();
}
function handleEscapeKeyDown(ev: KeyboardEvent) {
emit('escapeKeyDown', ev);
if (ev.defaultPrevented) return;
itemContext.wasEscapeCloseRef.value = true;
menuContext.onItemDismiss();
itemContext.triggerRef.value?.focus();
}
function handleFocusOutside(ev: FocusEvent) {
emit('focusOutside', ev);
if (ev.defaultPrevented) return;
itemContext.onContentFocusOutside();
const target = ev.target as Node | null;
if (menuContext.rootNavigationMenu.value?.contains(target)) ev.preventDefault();
}
function handlePointerDownOutside(ev: PointerEvent | MouseEvent) {
emit('pointerDownOutside', ev);
if (ev.defaultPrevented) return;
const target = ev.target as HTMLElement | null;
const isTrigger = menuContext.activeTrigger.value?.contains(target);
const isRootViewport = menuContext.isRootMenu && menuContext.viewport.value?.contains(target);
if (isTrigger || isRootViewport || !menuContext.isRootMenu) ev.preventDefault();
}
// Listen for sibling/global EVENT_ROOT_CONTENT_DISMISS for root menus so links
// inside content can request the whole root close.
watchEffect((onCleanup) => {
const el = currentElement.value;
if (!el || !menuContext.isRootMenu) return;
function onDismiss() {
itemContext.onRootContentClose();
}
el.addEventListener(EVENT_ROOT_CONTENT_DISMISS, onDismiss);
onCleanup(() => el.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, onDismiss));
});
</script>
<template>
<FocusScope
:trapped="false"
@mount-auto-focus.prevent
@unmount-auto-focus.prevent
>
<DismissableLayer
:id="itemContext.contentId"
:ref="forwardRef"
:aria-labelledby="itemContext.triggerId"
:data-motion="motionAttribute"
:data-state="getOpenState(menuContext.modelValue.value, itemContext.value)"
:data-orientation="menuContext.orientation"
:data-primitives-navigation-menu-content="itemContext.value"
:disable-outside-pointer-events="false"
v-bind="$attrs"
@keydown="handleKeydown"
@escape-key-down="handleEscapeKeyDown"
@pointer-down-outside="handlePointerDownOutside"
@focus-outside="handleFocusOutside"
@interact-outside="emit('interactOutside', $event)"
@dismiss="emit('dismiss')"
@pointerenter="emit('pointerEnterContent')"
@pointerleave="emit('pointerLeaveContent')"
>
<slot />
</DismissableLayer>
</FocusScope>
</template>