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,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>
|
||||
Reference in New Issue
Block a user