feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import type { Direction } from '../../utilities/config-provider';
|
||||
import type { PrimitiveProps } from '../../internal/primitive';
|
||||
import type { Orientation } from '../../utilities/roving-focus';
|
||||
|
||||
/**
|
||||
* A collection of navigation links and disclosure menus, typically used for the
|
||||
* primary site header. `NavigationMenuRoot` owns the open state and hover/click
|
||||
* timing for the whole menu, rendering as a `<nav>` landmark and providing context
|
||||
* to every list, item, trigger, content, viewport, and indicator beneath it. Reach
|
||||
* for it over a generic dropdown when you need keyboard-accessible, animatable
|
||||
* mega-menu panels that share a single active state.
|
||||
*/
|
||||
export interface NavigationMenuRootProps extends PrimitiveProps {
|
||||
/** Uncontrolled initial value. */
|
||||
defaultValue?: string;
|
||||
/** Reading direction. Falls back to `ConfigProvider`. */
|
||||
dir?: Direction;
|
||||
/** Menu orientation. @default 'horizontal' */
|
||||
orientation?: Orientation;
|
||||
/**
|
||||
* Time (ms) between pointer entering a trigger and the menu opening.
|
||||
* @default 200
|
||||
*/
|
||||
delayDuration?: number;
|
||||
/**
|
||||
* Window (ms) during which switching triggers skips `delayDuration`.
|
||||
* @default 300
|
||||
*/
|
||||
skipDelayDuration?: number;
|
||||
/** Disable opening via click. @default false */
|
||||
disableClickTrigger?: boolean;
|
||||
/** Disable opening via hover. @default false */
|
||||
disableHoverTrigger?: boolean;
|
||||
/** Disable closing when pointer leaves the menu. @default false */
|
||||
disablePointerLeaveClose?: boolean;
|
||||
/** Unmount content when hidden. @default true */
|
||||
unmountOnHide?: boolean;
|
||||
}
|
||||
|
||||
export interface NavigationMenuRootEmits {
|
||||
'update:modelValue': [value: string];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { computed, onScopeDispose, onWatcherCleanup, ref, shallowRef, toRef, watchEffect } from 'vue';
|
||||
|
||||
import { useForwardExpose, useId } from '@robonen/vue';
|
||||
import { useCollectionProvider } from '../../utilities/collection';
|
||||
import { useConfig } from '../../utilities/config-provider';
|
||||
import { Primitive } from '../../internal/primitive';
|
||||
import { provideNavigationMenuContext } from './context';
|
||||
import { EVENT_ROOT_CONTENT_DISMISS, NAVIGATION_MENU_COLLECTION_KEY } from './utils';
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const {
|
||||
defaultValue,
|
||||
dir,
|
||||
orientation = 'horizontal',
|
||||
delayDuration = 200,
|
||||
skipDelayDuration = 300,
|
||||
disableClickTrigger = false,
|
||||
disableHoverTrigger = false,
|
||||
disablePointerLeaveClose = false,
|
||||
unmountOnHide = true,
|
||||
as = 'nav',
|
||||
} = defineProps<NavigationMenuRootProps>();
|
||||
|
||||
defineEmits<NavigationMenuRootEmits>();
|
||||
|
||||
defineSlots<{
|
||||
default?: (props: { modelValue: string }) => unknown;
|
||||
}>();
|
||||
|
||||
const config = useConfig();
|
||||
const dirRef = computed<Direction>(() => dir ?? config.dir.value);
|
||||
|
||||
const localValue = ref<string>(defaultValue ?? '');
|
||||
/** Controlled active item value. Use `v-model`. */
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default: undefined,
|
||||
get: v => v ?? localValue.value,
|
||||
set: (v) => {
|
||||
const next = v ?? '';
|
||||
localValue.value = next;
|
||||
return next;
|
||||
},
|
||||
}) as unknown as Ref<string>;
|
||||
|
||||
const previousValue = ref<string>('');
|
||||
|
||||
const baseId = useId(undefined, 'primitives-navigation-menu');
|
||||
const { forwardRef, currentElement: rootNavigationMenu } = useForwardExpose();
|
||||
|
||||
const indicatorTrack = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const viewport = shallowRef<HTMLElement | undefined>(undefined);
|
||||
const activeTrigger = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
const { getItems, CollectionSlot } = useCollectionProvider<{ value: string }>(NAVIGATION_MENU_COLLECTION_KEY);
|
||||
|
||||
// Manual debounce — open delay shrinks to 150ms once the menu is open or while
|
||||
// the skip window is active (so moving between triggers feels instantaneous).
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let skipDelayTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const isDelaySkipped = ref(false);
|
||||
|
||||
function clearDebounce() {
|
||||
if (debounceTimer !== undefined) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSkipDelay() {
|
||||
if (skipDelayTimer !== undefined) {
|
||||
clearTimeout(skipDelayTimer);
|
||||
skipDelayTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSkipDelay() {
|
||||
clearSkipDelay();
|
||||
isDelaySkipped.value = true;
|
||||
skipDelayTimer = setTimeout(() => {
|
||||
isDelaySkipped.value = false;
|
||||
skipDelayTimer = undefined;
|
||||
}, skipDelayDuration);
|
||||
}
|
||||
|
||||
const computedDelay = computed(() => {
|
||||
const isOpen = modelValue.value !== '';
|
||||
if (isOpen || isDelaySkipped.value) return 150;
|
||||
return delayDuration;
|
||||
});
|
||||
|
||||
function debouncedSet(val: string) {
|
||||
clearDebounce();
|
||||
debounceTimer = setTimeout(() => {
|
||||
previousValue.value = modelValue.value;
|
||||
modelValue.value = val;
|
||||
debounceTimer = undefined;
|
||||
}, computedDelay.value);
|
||||
}
|
||||
|
||||
function cancelDebounce() {
|
||||
clearDebounce();
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!modelValue.value) return;
|
||||
const items = getItems().map(i => i.ref);
|
||||
// Trigger id pattern: `${baseId}-trigger-${value}`
|
||||
const matched = items.find(item => item.id.includes(`-trigger-${modelValue.value}`));
|
||||
if (matched) activeTrigger.value = matched;
|
||||
});
|
||||
|
||||
function onItemDismiss() {
|
||||
previousValue.value = modelValue.value;
|
||||
modelValue.value = '';
|
||||
}
|
||||
|
||||
// Custom event isn't part of HTMLElementEventMap so wire it up manually.
|
||||
watchEffect(() => {
|
||||
const el = rootNavigationMenu.value;
|
||||
if (!el) return;
|
||||
el.addEventListener(EVENT_ROOT_CONTENT_DISMISS, onItemDismiss);
|
||||
onWatcherCleanup(() => el.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, onItemDismiss));
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
clearDebounce();
|
||||
clearSkipDelay();
|
||||
});
|
||||
|
||||
provideNavigationMenuContext({
|
||||
isRootMenu: true,
|
||||
modelValue,
|
||||
previousValue,
|
||||
baseId,
|
||||
dir: dirRef,
|
||||
orientation,
|
||||
disableClickTrigger: toRef(() => disableClickTrigger),
|
||||
disableHoverTrigger: toRef(() => disableHoverTrigger),
|
||||
disablePointerLeaveClose: toRef(() => disablePointerLeaveClose),
|
||||
unmountOnHide: toRef(() => unmountOnHide),
|
||||
rootNavigationMenu,
|
||||
activeTrigger,
|
||||
onActiveTriggerChange: (el) => { activeTrigger.value = el; },
|
||||
indicatorTrack,
|
||||
onIndicatorTrackChange: (el) => { indicatorTrack.value = el; },
|
||||
viewport,
|
||||
onViewportChange: (el) => { viewport.value = el; },
|
||||
onTriggerEnter: (val) => {
|
||||
debouncedSet(val);
|
||||
},
|
||||
onTriggerLeave: () => {
|
||||
triggerSkipDelay();
|
||||
debouncedSet('');
|
||||
},
|
||||
onContentEnter: () => {
|
||||
cancelDebounce();
|
||||
},
|
||||
onContentLeave: () => {
|
||||
if (!disablePointerLeaveClose) debouncedSet('');
|
||||
},
|
||||
onItemSelect: (val) => {
|
||||
previousValue.value = modelValue.value;
|
||||
modelValue.value = val;
|
||||
},
|
||||
onItemDismiss,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollectionSlot>
|
||||
<Primitive
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:aria-label="($attrs['aria-label'] ?? 'Main') as string"
|
||||
:data-orientation="orientation"
|
||||
:dir="dirRef"
|
||||
data-primitives-navigation-menu
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot :model-value="modelValue" />
|
||||
</Primitive>
|
||||
</CollectionSlot>
|
||||
</template>
|
||||
Reference in New Issue
Block a user