diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts
index c19d8477e..91979b5a7 100644
--- a/packages/bits-ui/src/lib/bits/index.ts
+++ b/packages/bits-ui/src/lib/bits/index.ts
@@ -19,6 +19,7 @@ export { Label } from "./label/index.js";
export { LinkPreview } from "./link-preview/index.js";
export { Menubar } from "./menubar/index.js";
export { NavigationMenu } from "./navigation-menu/index.js";
+export { NavigationMenu as NavMenu } from "./navigation-menu-2/index.js";
export { Pagination } from "./pagination/index.js";
export { PinInput } from "./pin-input/index.js";
export { Popover } from "./popover/index.js";
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte
new file mode 100644
index 000000000..9b3930900
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte
@@ -0,0 +1,84 @@
+
+
+ {
+ onInteractOutside(e);
+ if (e.defaultPrevented) return;
+ contentImplState.onInteractOutside(e);
+ }}
+ onFocusOutside={(e) => {
+ onFocusOutside(e);
+ if (e.defaultPrevented) return;
+ contentImplState.onFocusOutside(e);
+ }}
+ {interactOutsideBehavior}
+>
+ {#snippet children({ props: dismissibleProps })}
+ {
+ onEscapeKeydown(e);
+ if (e.defaultPrevented) return;
+ contentImplState.onEscapeKeydown(e);
+ }}
+ {escapeKeydownBehavior}
+ >
+ {@const finalProps = mergeProps(mergedProps, dismissibleProps)}
+ {#if contentImplState.itemContext.contentChild.current}
+ {@render contentImplState.itemContext.contentChild.current?.({ props: finalProps })}
+ {:else}
+
+ {@render contentImplState.itemContext.contentChildren.current?.()}
+
+ {/if}
+
+ {/snippet}
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte
new file mode 100644
index 000000000..14772a048
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte
@@ -0,0 +1,38 @@
+
+
+{#if !contentState.context.viewportRef.current}
+
+ {#snippet presence()}
+
+ {/snippet}
+
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte
new file mode 100644
index 000000000..a139e6ba9
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte
@@ -0,0 +1,34 @@
+
+
+{#if indicatorState.position}
+ {#if child}
+ {@render child({ props: mergedProps })}
+ {:else}
+
+ {@render children?.()}
+
+ {/if}
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte
new file mode 100644
index 000000000..f334b8a28
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte
@@ -0,0 +1,31 @@
+
+
+{#if indicatorState.context.indicatorTrackRef.current}
+
+
+ {#snippet presence()}
+
+ {/snippet}
+
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte
new file mode 100644
index 000000000..93eecb3e5
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte
@@ -0,0 +1,34 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte
new file mode 100644
index 000000000..6f482aa2b
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte
@@ -0,0 +1,37 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte
new file mode 100644
index 000000000..614db6c9f
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte
@@ -0,0 +1,35 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps, wrapperProps })}
+{:else}
+
+
+ {@render children?.()}
+
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte
new file mode 100644
index 000000000..6f9a1b33c
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte
@@ -0,0 +1,49 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte
new file mode 100644
index 000000000..8402f75b0
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte
@@ -0,0 +1,44 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
+
+{#if triggerState.open}
+ (triggerState.focusProxyMounted = m)} />
+
+ {#if triggerState.context.viewportRef.current}
+
+ {/if}
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte
new file mode 100644
index 000000000..3e4103325
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte
@@ -0,0 +1,13 @@
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte
new file mode 100644
index 000000000..610685248
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte
@@ -0,0 +1,54 @@
+
+
+
+ {#each viewportContent as [value, item]}
+ {@const isActive = viewportState.activeContentValue === value}
+
+ {#snippet presence()}
+ {
+ if (isActive && v) {
+ viewportState.contentNode = v;
+ }
+ }}
+ />
+ {/snippet}
+
+ {/each}
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte
new file mode 100644
index 000000000..7dd6e0234
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {#snippet presence()}
+
+ {/snippet}
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte
new file mode 100644
index 000000000..dd3e4d0e6
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte
@@ -0,0 +1,56 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts
new file mode 100644
index 000000000..e25251951
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts
@@ -0,0 +1,20 @@
+export { default as Root } from "./components/navigation-menu.svelte";
+export { default as Content } from "./components/navigation-menu-content.svelte";
+export { default as Indicator } from "./components/navigation-menu-indicator.svelte";
+export { default as Item } from "./components/navigation-menu-item.svelte";
+export { default as Link } from "./components/navigation-menu-link.svelte";
+export { default as List } from "./components/navigation-menu-list.svelte";
+export { default as Trigger } from "./components/navigation-menu-trigger.svelte";
+export { default as Viewport } from "./components/navigation-menu-viewport.svelte";
+
+export type {
+ NavigationMenuRootProps as RootProps,
+ NavigationMenuItemProps as ItemProps,
+ NavigationMenuListProps as ListProps,
+ NavigationMenuTriggerProps as TriggerProps,
+ NavigationMenuViewportProps as ViewportProps,
+ NavigationMenuIndicatorProps as IndicatorProps,
+ NavigationMenuContentProps as ContentProps,
+ NavigationMenuLinkProps as LinkProps,
+ NavigationMenuSubProps as SubProps,
+} from "./types.js";
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts
new file mode 100644
index 000000000..960bdca7c
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts
@@ -0,0 +1 @@
+export * as NavigationMenu from "./exports.js";
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts
new file mode 100644
index 000000000..500318592
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts
@@ -0,0 +1,1148 @@
+/**
+ * Based on Radix UI's Navigation Menu
+ * https://www.radix-ui.com/docs/primitives/components/navigation-menu
+ */
+
+import {
+ type AnyFn,
+ type ReadableBox,
+ type ReadableBoxedValues,
+ type WithRefProps,
+ type WritableBoxedValues,
+ box,
+ onDestroyEffect,
+ useRefById,
+} from "svelte-toolbelt";
+import { Context, watch } from "runed";
+import { type Snippet, untrack } from "svelte";
+import { SvelteMap } from "svelte/reactivity";
+import { type Direction, type Orientation, useId } from "$lib/shared/index.js";
+import {
+ getAriaExpanded,
+ getDataDisabled,
+ getDataOpenClosed,
+ getDataOrientation,
+} from "$lib/internal/attrs.js";
+import { noop } from "$lib/internal/noop.js";
+import { getTabbableCandidates } from "$lib/internal/focus.js";
+import type {
+ BitsFocusEvent,
+ BitsKeyboardEvent,
+ BitsMouseEvent,
+ BitsPointerEvent,
+} from "$lib/internal/types.js";
+import { kbd } from "$lib/internal/kbd.js";
+import { useResizeObserver } from "$lib/internal/use-resize-observer.svelte.js";
+import { PreviousWithInit } from "$lib/internal/previous-with-init.svelte.js";
+import { CustomEventDispatcher } from "$lib/internal/events.js";
+
+const ROOT_ATTR = "data-navigation-menu-root";
+const SUB_ATTR = "data-navigation-menu-sub";
+const LIST_ATTR = "data-navigation-menu-list";
+const ITEM_ATTR = "data-navigation-menu-item";
+const TRIGGER_ATTR = "data-navigation-menu-trigger";
+const LINK_ATTR = "data-navigation-menu-link";
+
+type NavigationMenuProviderStateProps = ReadableBoxedValues<{
+ dir: Direction;
+ orientation: Orientation;
+}> &
+ WritableBoxedValues<{
+ rootNavigationMenuRef: HTMLElement | null;
+ value: string;
+ }> & {
+ isRootMenu: boolean;
+ onTriggerEnter: (itemValue: string) => void;
+ onTriggerLeave?: () => void;
+ onContentEnter?: () => void;
+ onContentLeave?: () => void;
+ onItemSelect: (itemValue: string) => void;
+ onItemDismiss: () => void;
+ };
+
+class NavigationMenuProviderState {
+ isRootMenu: NavigationMenuProviderStateProps["isRootMenu"];
+ value: NavigationMenuProviderStateProps["value"];
+ previousValue: PreviousWithInit;
+ dir: NavigationMenuProviderStateProps["dir"];
+ orientation: NavigationMenuProviderStateProps["orientation"];
+ rootNavigationMenuRef: NavigationMenuProviderStateProps["rootNavigationMenuRef"];
+ indicatorTrackRef = box(null);
+ viewportRef = box(null);
+ viewportContent = new SvelteMap();
+ onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"];
+ onTriggerLeave: () => void = noop;
+ onContentEnter: () => void = noop;
+ onContentLeave: () => void = noop;
+ onItemSelect: NavigationMenuProviderStateProps["onItemSelect"];
+ onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"];
+
+ constructor(props: NavigationMenuProviderStateProps) {
+ this.isRootMenu = props.isRootMenu;
+ this.value = props.value;
+ this.previousValue = new PreviousWithInit(() => this.value.current);
+ this.dir = props.dir;
+ this.orientation = props.orientation;
+ this.rootNavigationMenuRef = props.rootNavigationMenuRef;
+ this.onTriggerEnter = props.onTriggerEnter;
+ this.onTriggerLeave = props.onTriggerLeave ?? noop;
+ this.onContentEnter = props.onContentEnter ?? noop;
+ this.onContentLeave = props.onContentLeave ?? noop;
+ this.onItemDismiss = props.onItemDismiss;
+ this.onItemSelect = props.onItemSelect;
+ }
+
+ onViewportContentChange(contentValue: string, item: NavigationMenuItemState) {
+ this.viewportContent.set(contentValue, item);
+ }
+
+ onViewportContentRemove(contentValue: string) {
+ if (!this.viewportContent.has(contentValue)) return;
+ this.viewportContent.delete(contentValue);
+ }
+}
+
+type NavigationMenuRootStateProps = WithRefProps<
+ WritableBoxedValues<{
+ value: string;
+ }> &
+ ReadableBoxedValues<{
+ dir: Direction;
+ orientation: Orientation;
+ delayDuration: number;
+ skipDelayDuration: number;
+ }>
+>;
+
+class NavigationMenuRootState {
+ id: NavigationMenuRootStateProps["id"];
+ ref: NavigationMenuRootStateProps["ref"];
+ value: NavigationMenuRootStateProps["value"];
+ dir: NavigationMenuRootStateProps["dir"];
+ orientation: NavigationMenuRootStateProps["orientation"];
+ delayDuration: NavigationMenuRootStateProps["delayDuration"];
+ skipDelayDuration: NavigationMenuRootStateProps["skipDelayDuration"];
+ openTimer = $state(0);
+ closeTimer = $state(0);
+ skipDelayTimer = $state(0);
+ isOpenDelayed = $state(true);
+ provider: NavigationMenuProviderState;
+
+ constructor(props: NavigationMenuRootStateProps) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.value = props.value;
+ this.dir = props.dir;
+ this.orientation = props.orientation;
+ this.delayDuration = props.delayDuration;
+ this.skipDelayDuration = props.skipDelayDuration;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ });
+
+ onDestroyEffect(() => {
+ window.clearTimeout(this.openTimer);
+ window.clearTimeout(this.closeTimer);
+ window.clearTimeout(this.skipDelayTimer);
+ });
+
+ this.provider = useNavigationMenuProvider({
+ value: this.value,
+ dir: this.dir,
+ orientation: this.orientation,
+ rootNavigationMenuRef: this.ref,
+ isRootMenu: true,
+ onTriggerEnter: (itemValue) => {
+ return this.#onTriggerEnter(itemValue);
+ },
+ onTriggerLeave: () => this.#onTriggerLeave(),
+ onContentEnter: () => this.#onContentEnter(),
+ onContentLeave: () => this.#onContentLeave(),
+ onItemSelect: (itemValue) => this.#onItemSelect(itemValue),
+ onItemDismiss: () => this.#onItemDismiss(),
+ });
+ }
+
+ #onTriggerEnter(itemValue: string) {
+ window.clearTimeout(this.openTimer);
+ if (this.isOpenDelayed) this.handleDelayedOpen(itemValue);
+ else this.handleOpen(itemValue);
+ }
+
+ #onTriggerLeave() {
+ window.clearTimeout(this.openTimer);
+ this.startCloseTimer();
+ }
+
+ #onContentEnter() {
+ window.clearTimeout(this.closeTimer);
+ }
+
+ #onContentLeave() {
+ this.startCloseTimer();
+ }
+
+ #onItemSelect(itemValue: string) {
+ if (this.value.current === itemValue) {
+ this.setValue("");
+ } else {
+ this.setValue(itemValue);
+ }
+ }
+
+ #onItemDismiss() {
+ this.setValue("");
+ }
+
+ setValue(newValue: string) {
+ this.value.current = newValue;
+ }
+
+ handleValueChange(newValue: string) {
+ const isOpen = newValue !== "";
+ const hasSkipDelayDuration = this.skipDelayDuration.current > 0;
+
+ if (isOpen) {
+ window.clearTimeout(this.skipDelayTimer);
+ if (hasSkipDelayDuration) this.isOpenDelayed = false;
+ } else {
+ window.clearTimeout(this.skipDelayTimer);
+ this.skipDelayTimer = window.setTimeout(
+ () => (this.isOpenDelayed = true),
+ this.skipDelayDuration.current
+ );
+ }
+ }
+
+ startCloseTimer() {
+ window.clearTimeout(this.closeTimer);
+ this.closeTimer = window.setTimeout(() => this.setValue(""), 150);
+ }
+
+ handleOpen(itemValue: string) {
+ window.clearTimeout(this.closeTimer);
+ this.setValue(itemValue);
+ }
+
+ handleDelayedOpen(itemValue: string) {
+ const isOpenItem = this.value.current === itemValue;
+ if (isOpenItem) {
+ // If the item is already open (e.g. we're transitioning from the content to the trigger) then we want to clear the close timer immediately.
+ window.clearTimeout(this.closeTimer);
+ } else {
+ this.openTimer = window.setTimeout(() => {
+ window.clearTimeout(this.closeTimer);
+ this.setValue(itemValue);
+ }, this.delayDuration.current);
+ }
+ }
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "data-orientation": getDataOrientation(this.orientation.current),
+ dir: this.dir.current,
+ [ROOT_ATTR]: "",
+ }) as const
+ );
+}
+
+type NavigationMenuSubStateProps = WithRefProps<
+ WritableBoxedValues<{
+ value: string;
+ }> &
+ ReadableBoxedValues<{
+ orientation: Orientation;
+ }>
+>;
+
+class NavigationMenuSubState {
+ id: NavigationMenuSubStateProps["id"];
+ ref: NavigationMenuSubStateProps["ref"];
+ value: NavigationMenuSubStateProps["value"];
+ context: NavigationMenuProviderState;
+ orientation: NavigationMenuSubStateProps["orientation"];
+
+ constructor(props: NavigationMenuSubStateProps, context: NavigationMenuProviderState) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.value = props.value;
+ this.orientation = props.orientation;
+ this.context = context;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ });
+
+ useNavigationMenuProvider({
+ isRootMenu: false,
+ value: this.value,
+ dir: this.context.dir,
+ orientation: this.orientation,
+ rootNavigationMenuRef: this.context.rootNavigationMenuRef,
+ onTriggerEnter: (itemValue) => this.setValue(itemValue),
+ onItemSelect: (itemValue) => this.setValue(itemValue),
+ onItemDismiss: () => this.setValue(""),
+ });
+ }
+
+ setValue(newValue: string) {
+ this.value.current = newValue;
+ }
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "data-orientation": getDataOrientation(this.orientation.current),
+ [SUB_ATTR]: "",
+ }) as const
+ );
+}
+
+type NavigationMenuListStateProps = WithRefProps;
+
+class NavigationMenuListState {
+ id: NavigationMenuListStateProps["id"];
+ ref: NavigationMenuListStateProps["ref"];
+ context: NavigationMenuProviderState;
+ wrapperId = box.with(() => useId());
+ wrapperRef = box(null);
+ listTriggers = $state.raw([]);
+
+ constructor(props: NavigationMenuListStateProps, context: NavigationMenuProviderState) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.context = context;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ });
+
+ useRefById({
+ id: this.wrapperId,
+ ref: this.wrapperRef,
+ onRefChange: (node) => {
+ this.context.indicatorTrackRef.current = node;
+ },
+ });
+ }
+
+ registerTrigger(trigger: HTMLElement | null) {
+ if (trigger) this.listTriggers.push(trigger);
+ return () => {
+ this.listTriggers = this.listTriggers.filter((t) => t.id !== trigger!.id);
+ };
+ }
+
+ wrapperProps = $derived.by(
+ () =>
+ ({
+ id: this.wrapperId.current,
+ }) as const
+ );
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "data-orientation": getDataOrientation(this.context.orientation.current),
+ [LIST_ATTR]: "",
+ }) as const
+ );
+}
+
+type NavigationMenuItemStateProps = WithRefProps<
+ ReadableBoxedValues<{
+ value: string;
+ }>
+>;
+
+export class NavigationMenuItemState {
+ ref: NavigationMenuItemStateProps["ref"];
+ id: NavigationMenuItemStateProps["id"];
+ value: NavigationMenuItemStateProps["value"];
+ contentNode = $state(null);
+ triggerNode = $state(null);
+ focusProxyNode = $state(null);
+ restoreContentTabOrder: AnyFn = noop;
+ wasEscapeClose = $state(false);
+ contentId = $derived.by(() => this.contentNode?.id);
+ triggerId = $derived.by(() => this.triggerNode?.id);
+ listContext: NavigationMenuListState;
+ contentChildren: ReadableBox = box(undefined);
+ contentChild: ReadableBox = box(undefined);
+ contentProps: ReadableBox> = box({});
+
+ constructor(props: NavigationMenuItemStateProps, listContext: NavigationMenuListState) {
+ this.ref = props.ref;
+ this.id = props.id;
+ this.value = props.value;
+ this.listContext = listContext;
+ }
+
+ #handleContentEntry = (side: "start" | "end" = "start") => {
+ if (!this.contentNode) return;
+ this.restoreContentTabOrder();
+ const candidates = getTabbableCandidates(this.contentNode);
+ if (candidates.length) focusFirst(side === "start" ? candidates : candidates.reverse());
+ };
+
+ #handleContextExit = () => {
+ if (!this.contentNode) return;
+ const candidates = getTabbableCandidates(this.contentNode);
+ if (candidates.length) this.restoreContentTabOrder = removeFromTabOrder(candidates);
+ };
+
+ onEntryKeydown = this.#handleContentEntry;
+ onFocusProxyEnter = this.#handleContentEntry;
+ onRootContentClose = this.#handleContextExit;
+ onContentFocusOutside = this.#handleContextExit;
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ [ITEM_ATTR]: "",
+ }) as const
+ );
+}
+
+type NavigationMenuTriggerStateProps = WithRefProps &
+ ReadableBoxedValues<{
+ disabled: boolean | null | undefined;
+ }>;
+
+class NavigationMenuTriggerState {
+ id: NavigationMenuTriggerStateProps["id"];
+ ref: NavigationMenuTriggerStateProps["ref"];
+ focusProxyId = box.with(() => useId());
+ focusProxyRef = box(null);
+ disabled: NavigationMenuTriggerStateProps["disabled"];
+ context: NavigationMenuProviderState;
+ itemContext: NavigationMenuItemState;
+ listContext: NavigationMenuListState;
+ hasPointerMoveOpened = $state(false);
+ wasClickClose = $state(false);
+ open = $derived.by(() => this.itemContext.value.current === this.context.value.current);
+ focusProxyMounted = $state(false);
+
+ constructor(
+ props: NavigationMenuTriggerStateProps,
+ context: {
+ provider: NavigationMenuProviderState;
+ item: NavigationMenuItemState;
+ list: NavigationMenuListState;
+ }
+ ) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.disabled = props.disabled;
+ this.context = context.provider;
+ this.itemContext = context.item;
+ this.listContext = context.list;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ });
+
+ useRefById({
+ id: this.focusProxyId,
+ ref: this.focusProxyRef,
+ onRefChange: (node) => {
+ this.itemContext.focusProxyNode = node;
+ },
+ deps: () => this.focusProxyMounted,
+ });
+
+ $effect(() => {
+ const node = this.ref.current;
+
+ if (node) {
+ const unregister = this.listContext.registerTrigger(node);
+
+ return () => {
+ unregister();
+ };
+ }
+ });
+
+ this.onpointerenter = this.onpointerenter.bind(this);
+ this.onpointerleave = this.onpointerleave.bind(this);
+ this.onclick = this.onclick.bind(this);
+ this.onkeydown = this.onkeydown.bind(this);
+ this.focusProxyOnFocus = this.focusProxyOnFocus.bind(this);
+ }
+
+ onpointerenter(_: BitsPointerEvent) {
+ this.wasClickClose = false;
+ this.itemContext.wasEscapeClose = false;
+ }
+
+ onpointermove = whenMouse(() => {
+ if (
+ this.disabled.current ||
+ this.wasClickClose ||
+ this.itemContext.wasEscapeClose ||
+ this.hasPointerMoveOpened
+ ) {
+ return;
+ }
+ this.context.onTriggerEnter(this.itemContext.value.current);
+ this.hasPointerMoveOpened = true;
+ });
+
+ onpointerleave = whenMouse(() => {
+ if (this.disabled.current) return;
+ this.context.onTriggerLeave();
+ this.hasPointerMoveOpened = false;
+ });
+
+ onclick(_: BitsMouseEvent) {
+ this.context.onItemSelect(this.itemContext.value.current);
+ this.wasClickClose = this.open;
+ }
+
+ onkeydown(e: BitsKeyboardEvent) {
+ const verticalEntryKey =
+ this.context.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT;
+ const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[
+ this.context.orientation.current
+ ];
+ if (this.open && e.key === entryKey) {
+ this.itemContext.onEntryKeydown();
+ // prevent focus group from handling the event
+ e.preventDefault();
+ }
+ }
+
+ focusProxyOnFocus(e: BitsFocusEvent) {
+ const content = this.itemContext.contentNode;
+ const prevFocusedElement = e.relatedTarget as HTMLElement | null;
+ const wasTriggerFocused = this.ref.current && prevFocusedElement === this.ref.current;
+ const wasFocusFromContent = content?.contains(prevFocusedElement);
+
+ if (wasTriggerFocused || !wasFocusFromContent) {
+ this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end");
+ }
+ }
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ disabled: this.disabled.current,
+ "data-disabled": getDataDisabled(Boolean(this.disabled.current)),
+ "data-state": getDataOpenClosed(this.open),
+ "data-value": this.itemContext.value.current,
+ "aria-expanded": getAriaExpanded(this.open),
+ "aria-controls": this.itemContext.contentId,
+ [TRIGGER_ATTR]: "",
+ onpointermove: this.onpointermove,
+ onpointerleave: this.onpointerleave,
+ onpointerenter: this.onpointerenter,
+ onclick: this.onclick,
+ onkeydown: this.onkeydown,
+ }) as const
+ );
+
+ focusProxyProps = $derived.by(
+ () =>
+ ({
+ "aria-hidden": "true",
+ tabindex: 0,
+ onfocus: this.focusProxyOnFocus,
+ }) as const
+ );
+
+ restructureSpanProps = $derived.by(
+ () =>
+ ({
+ "aria-owns": this.itemContext.contentId,
+ }) as const
+ );
+}
+
+type NavigationMenuLinkStateProps = WithRefProps &
+ ReadableBoxedValues<{
+ active: boolean;
+ onSelect: (e: Event) => void;
+ }>;
+
+const LINK_SELECT_EVENT = new CustomEventDispatcher("bitsLinkSelect", {
+ bubbles: true,
+ cancelable: true,
+});
+
+const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDismiss", {
+ cancelable: true,
+ bubbles: true,
+});
+
+class NavigationMenuLinkState {
+ id: NavigationMenuLinkStateProps["id"];
+ ref: NavigationMenuLinkStateProps["ref"];
+ active: NavigationMenuLinkStateProps["active"];
+ onSelect: NavigationMenuLinkStateProps["onSelect"];
+
+ constructor(props: NavigationMenuLinkStateProps, context: NavigationMenuProviderState) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.active = props.active;
+ this.onSelect = props.onSelect;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ });
+
+ this.onclick = this.onclick.bind(this);
+ }
+
+ onclick(e: BitsMouseEvent) {
+ const currTarget = e.currentTarget;
+
+ LINK_SELECT_EVENT.listen(currTarget, (e) => this.onSelect.current(e), { once: true });
+ const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget);
+
+ if (!linkSelectEvent.defaultPrevented && !e.metaKey) {
+ ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget);
+ }
+ }
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "data-active": this.active.current ? "" : undefined,
+ "aria-current": this.active.current ? "page" : undefined,
+ onclick: this.onclick,
+ [LINK_ATTR]: "",
+ }) as const
+ );
+}
+
+type NavigationMenuIndicatorStateProps = WithRefProps;
+
+class NavigationMenuIndicatorState {
+ context: NavigationMenuProviderState;
+ isVisible = $derived.by(() => Boolean(this.context.value.current));
+
+ constructor(context: NavigationMenuProviderState) {
+ this.context = context;
+ }
+}
+
+class NavigationMenuIndicatorImplState {
+ id: NavigationMenuIndicatorStateProps["id"];
+ ref: NavigationMenuIndicatorStateProps["ref"];
+ context: NavigationMenuProviderState;
+ listContext: NavigationMenuListState;
+ position = $state.raw<{ size: number; offset: number } | null>(null);
+ isHorizontal = $derived.by(() => this.context.orientation.current === "horizontal");
+ isVisible = $derived.by(() => Boolean(this.context.value.current));
+ activeTrigger = $derived.by(() => {
+ const items = this.listContext.listTriggers;
+ const triggerNode = items.find(
+ (item) => item.getAttribute("data-value") === this.context.value.current
+ );
+ return triggerNode ?? null;
+ });
+ shouldRender = $derived.by(() => this.position !== null);
+
+ constructor(
+ props: NavigationMenuIndicatorStateProps,
+ context: {
+ provider: NavigationMenuProviderState;
+ list: NavigationMenuListState;
+ }
+ ) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.context = context.provider;
+ this.listContext = context.list;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ deps: () => this.context.value.current,
+ });
+
+ useResizeObserver(() => this.activeTrigger, this.handlePositionChange);
+ useResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange);
+ }
+
+ handlePositionChange = () => {
+ if (!this.activeTrigger) return;
+ this.position = {
+ size: this.isHorizontal
+ ? this.activeTrigger.offsetWidth
+ : this.activeTrigger.offsetHeight,
+ offset: this.isHorizontal
+ ? this.activeTrigger.offsetLeft
+ : this.activeTrigger.offsetTop,
+ };
+ };
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "data-state": this.isVisible ? "visible" : "hidden",
+ "data-orientation": getDataOrientation(this.context.orientation.current),
+ style: this.position
+ ? {
+ position: "absolute",
+ ...(this.isHorizontal
+ ? {
+ left: 0,
+ width: `${this.position.size}px`,
+ transform: `translateX(${this.position.offset}px)`,
+ }
+ : {
+ top: 0,
+ height: `${this.position.size}px`,
+ transform: `translateY(${this.position.offset}px)`,
+ }),
+ }
+ : undefined,
+ }) as const
+ );
+}
+
+type NavigationMenuContentStateProps = WithRefProps;
+
+class NavigationMenuContentState {
+ id: NavigationMenuContentStateProps["id"];
+ ref: NavigationMenuContentStateProps["ref"];
+ context: NavigationMenuProviderState;
+ itemContext: NavigationMenuItemState;
+ listContext: NavigationMenuListState;
+ open = $derived.by(() => this.itemContext.value.current === this.context.value.current);
+ value = $derived.by(() => this.itemContext.value.current);
+
+ constructor(
+ props: NavigationMenuContentStateProps,
+ context: {
+ provider: NavigationMenuProviderState;
+ item: NavigationMenuItemState;
+ list: NavigationMenuListState;
+ }
+ ) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.context = context.provider;
+ this.itemContext = context.item;
+ this.listContext = context.list;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ });
+
+ this.onpointerenter = this.onpointerenter.bind(this);
+ }
+
+ onpointerenter(_: BitsPointerEvent) {
+ this.context.onContentEnter;
+ }
+
+ onpointerleave = whenMouse(() => {
+ this.context.onContentLeave();
+ });
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ onpointerenter: this.onpointerenter,
+ onpointerleave: this.onpointerleave,
+ }) as const
+ );
+}
+
+type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end";
+type NavigationMenuContentImplStateProps = WithRefProps;
+
+class NavigationMenuContentImplState {
+ ref: NavigationMenuContentImplStateProps["ref"];
+ id: NavigationMenuContentImplStateProps["id"];
+ context: NavigationMenuProviderState;
+ itemContext: NavigationMenuItemState;
+ listContext: NavigationMenuListState;
+ prevMotionAttribute = $state(null);
+
+ motionAttribute: MotionAttribute | null = $derived.by(() => {
+ const items = this.listContext.listTriggers;
+ const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean);
+ if (this.context.dir.current === "rtl") values.reverse();
+ const index = values.indexOf(this.context.value.current);
+ const prevIndex = values.indexOf(this.context.previousValue.current);
+ const isSelected = this.itemContext.value.current === this.context.value.current;
+ const wasSelected = prevIndex === values.indexOf(this.itemContext.value.current);
+
+ // We only want to update selected and the last selected content
+ // this avoids animations being interrupted outside of that range
+ if (!isSelected && !wasSelected) return untrack(() => this.prevMotionAttribute);
+
+ const attribute = (() => {
+ // Don't provide a direction on the initial open
+ if (index !== prevIndex) {
+ // If we're moving to this item from another
+ if (isSelected && prevIndex !== -1)
+ return index > prevIndex ? "from-end" : "from-start";
+ // If we're leaving this item for another
+ if (wasSelected && index !== -1) return index > prevIndex ? "to-start" : "to-end";
+ }
+ // Otherwise we're entering from close or leaving the list
+ // entirely and should not animate in any direction
+ return null;
+ })();
+
+ untrack(() => (this.prevMotionAttribute = attribute));
+ return attribute;
+ });
+
+ constructor(props: NavigationMenuContentImplStateProps, itemContext: NavigationMenuItemState) {
+ this.ref = props.ref;
+ this.id = props.id;
+ this.itemContext = itemContext;
+ this.listContext = itemContext.listContext;
+ this.context = itemContext.listContext.context;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ deps: () => this.context.value.current,
+ });
+
+ watch(
+ [
+ () => this.itemContext.value.current,
+ () => this.itemContext.triggerNode,
+ () => this.ref.current,
+ ],
+ () => {
+ const content = this.ref.current;
+ if (!(content && this.context.isRootMenu)) return;
+
+ const handleClose = () => {
+ this.context.onItemDismiss();
+ this.itemContext.onRootContentClose();
+ if (content.contains(document.activeElement)) {
+ this.itemContext.triggerNode?.focus();
+ }
+ };
+ const removeListener = ROOT_CONTENT_DISMISS_EVENT.listen(content, handleClose);
+
+ return () => {
+ removeListener();
+ };
+ }
+ );
+
+ this.onFocusOutside = this.onFocusOutside.bind(this);
+ this.onInteractOutside = this.onInteractOutside.bind(this);
+ this.onkeydown = this.onkeydown.bind(this);
+ this.onEscapeKeydown = this.onEscapeKeydown.bind(this);
+ }
+
+ onFocusOutside(e: Event) {
+ this.itemContext.onContentFocusOutside();
+ const target = e.target as HTMLElement;
+ // only dismiss content when focus moves outside of the menu
+ if (this.context.rootNavigationMenuRef.current?.contains(target)) {
+ e.preventDefault();
+ }
+ }
+
+ onInteractOutside(e: PointerEvent) {
+ const target = e.target as HTMLElement;
+ const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target));
+ const isRootViewport =
+ this.context.isRootMenu && this.context.viewportRef.current?.contains(target);
+ if (isTrigger || isRootViewport || !this.context.isRootMenu) e.preventDefault();
+ }
+
+ onkeydown(e: BitsKeyboardEvent) {
+ const isMetaKey = e.altKey || e.ctrlKey || e.metaKey;
+ const isTabKey = e.key === kbd.TAB && !isMetaKey;
+ if (!isTabKey) return;
+ const candidates = getTabbableCandidates(e.currentTarget);
+ const focusedElement = document.activeElement;
+ const index = candidates.findIndex((candidate) => candidate === focusedElement);
+ const isMovingBackwards = e.shiftKey;
+ const nextCandidates = isMovingBackwards
+ ? candidates.slice(0, index).reverse()
+ : candidates.slice(index + 1, candidates.length);
+
+ if (focusFirst(nextCandidates)) {
+ // prevent browser tab keydown because we've handled focus
+ e.preventDefault();
+ } else {
+ // If we can't focus that means we're at the edges
+ // so focus the proxy and let browser handle
+ // tab/shift+tab keypress on the proxy instead
+ this.itemContext.focusProxyNode?.focus();
+ }
+ }
+
+ onEscapeKeydown(_: KeyboardEvent) {
+ // prevent the dropdown from reopening after the
+ // escape key has been pressed
+ this.itemContext.wasEscapeClose = true;
+ }
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "aria-labelledby": this.itemContext.triggerId,
+ "data-motion": this.motionAttribute ?? undefined,
+ "data-orientation": getDataOrientation(this.context.orientation.current),
+ }) as const
+ );
+}
+
+type NavigationMenuViewportContentMounterStateProps = ReadableBoxedValues<{
+ children: Snippet | undefined;
+ child: Snippet | undefined;
+ props: Record;
+}>;
+
+class NavigationMenuViewportContentMounterState {
+ context: NavigationMenuProviderState;
+ contentContext: NavigationMenuContentState;
+
+ constructor(
+ props: NavigationMenuViewportContentMounterStateProps,
+ context: NavigationMenuProviderState,
+ contentContext: NavigationMenuContentState
+ ) {
+ this.context = context;
+ this.contentContext = contentContext;
+ this.contentContext.itemContext.contentChildren = props.children;
+ this.contentContext.itemContext.contentChild = props.child;
+ this.contentContext.itemContext.contentProps = props.props;
+
+ $effect(() => {
+ this.context.onViewportContentChange(
+ this.contentContext.value,
+ this.contentContext.itemContext
+ );
+ });
+
+ onDestroyEffect(() => {
+ this.context.onViewportContentRemove(this.contentContext.value);
+ });
+ }
+}
+
+class NavigationMenuViewportState {
+ context: NavigationMenuProviderState;
+ open = $derived.by(() => Boolean(this.context.value.current));
+
+ constructor(context: NavigationMenuProviderState) {
+ this.context = context;
+ }
+}
+
+type NavigationMenuViewportImplStateProps = WithRefProps;
+
+class NavigationMenuViewportImplState {
+ id: NavigationMenuViewportImplStateProps["id"];
+ ref: NavigationMenuViewportImplStateProps["ref"];
+ context: NavigationMenuProviderState;
+ size = $state<{ width: number; height: number } | null>(null);
+ contentNode = $state(null);
+ viewportWidth = $derived.by(() => (this.size ? `${this.size.width}px` : undefined));
+ viewportHeight = $derived.by(() => (this.size ? `${this.size.height}px` : undefined));
+ open = $derived.by(() => Boolean(this.context.value.current));
+ // We persist the last active content value as the viewport may be animating out
+ // and we want the content to remain mounted for the lifecycle of the viewport.
+ activeContentValue = $derived.by(() =>
+ this.open ? this.context.value.current : this.context.previousValue.current
+ );
+
+ constructor(props: NavigationMenuViewportImplStateProps, context: NavigationMenuProviderState) {
+ this.id = props.id;
+ this.ref = props.ref;
+ this.context = context;
+
+ useRefById({
+ id: this.id,
+ ref: this.ref,
+ onRefChange: (node) => {
+ this.context.viewportRef.current = node;
+ },
+ });
+
+ /**
+ * Update viewport size to match the active content node.
+ * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform.
+ * For example, if content animates in from `scale(0.5)` the dimensions would be anything
+ * from `0.5` to `1` of the intended size.
+ */
+ const handleSizeChange = () => {
+ if (this.contentNode) {
+ this.size = {
+ width: this.contentNode.offsetWidth,
+ height: this.contentNode.offsetHeight,
+ };
+ }
+ };
+
+ useResizeObserver(() => this.contentNode, handleSizeChange);
+ }
+
+ props = $derived.by(
+ () =>
+ ({
+ id: this.id.current,
+ "data-state": getDataOpenClosed(this.open),
+ "data-orientation": getDataOrientation(this.context.orientation.current),
+ style: {
+ pointerEvents: !this.open && this.context.isRootMenu ? "none" : undefined,
+ "--bits-navigation-menu-viewport-width": this.viewportWidth,
+ "--bits-navigation-menu-viewport-height": this.viewportHeight,
+ },
+ onpointerenter: this.context.onContentEnter,
+ onpointerleave: this.context.onContentLeave,
+ }) as const
+ );
+}
+
+const NavigationMenuProviderContext = new Context(
+ "NavigationMenu.Root"
+);
+
+const NavigationMenuItemContext = new Context("NavigationMenu.Item");
+
+const NavigationMenuListContext = new Context("NavigationMenu.List");
+
+const NavigationMenuContentContext = new Context(
+ "NavigationMenu.Content"
+);
+
+export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) {
+ return new NavigationMenuRootState(props);
+}
+
+export function useNavigationMenuProvider(props: NavigationMenuProviderStateProps) {
+ return NavigationMenuProviderContext.set(new NavigationMenuProviderState(props));
+}
+
+export function useNavigationMenuSub(props: NavigationMenuSubStateProps) {
+ return new NavigationMenuSubState(props, NavigationMenuProviderContext.get());
+}
+
+export function useNavigationMenuList(props: NavigationMenuListStateProps) {
+ return NavigationMenuListContext.set(
+ new NavigationMenuListState(props, NavigationMenuProviderContext.get())
+ );
+}
+
+export function useNavigationMenuItem(props: NavigationMenuItemStateProps) {
+ return NavigationMenuItemContext.set(
+ new NavigationMenuItemState(props, NavigationMenuListContext.get())
+ );
+}
+
+export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) {
+ return new NavigationMenuIndicatorImplState(props, {
+ provider: NavigationMenuProviderContext.get(),
+ list: NavigationMenuListContext.get(),
+ });
+}
+
+export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) {
+ return new NavigationMenuTriggerState(props, {
+ provider: NavigationMenuProviderContext.get(),
+ item: NavigationMenuItemContext.get(),
+ list: NavigationMenuListContext.get(),
+ });
+}
+
+export function useNavigationMenuContent(props: NavigationMenuContentStateProps) {
+ return NavigationMenuContentContext.set(
+ new NavigationMenuContentState(props, {
+ provider: NavigationMenuProviderContext.get(),
+ item: NavigationMenuItemContext.get(),
+ list: NavigationMenuListContext.get(),
+ })
+ );
+}
+
+export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) {
+ return new NavigationMenuLinkState(props, NavigationMenuProviderContext.get());
+}
+
+export function useNavigationMenuContentImpl(
+ props: NavigationMenuContentImplStateProps,
+ itemState: NavigationMenuItemState = NavigationMenuItemContext.get()
+) {
+ return new NavigationMenuContentImplState(props, itemState);
+}
+
+export function useNavigationMenuViewport() {
+ return new NavigationMenuViewportState(NavigationMenuProviderContext.get());
+}
+
+export function useNavigationMenuViewportImpl(props: NavigationMenuViewportImplStateProps) {
+ return new NavigationMenuViewportImplState(props, NavigationMenuProviderContext.get());
+}
+
+export function useNavigationMenuViewportContentMounter(
+ props: NavigationMenuViewportContentMounterStateProps
+) {
+ return new NavigationMenuViewportContentMounterState(
+ props,
+ NavigationMenuProviderContext.get(),
+ NavigationMenuContentContext.get()
+ );
+}
+
+export function useNavigationMenuIndicator() {
+ return new NavigationMenuIndicatorState(NavigationMenuProviderContext.get());
+}
+
+//
+
+function focusFirst(candidates: HTMLElement[]) {
+ const previouslyFocusedElement = document.activeElement;
+ return candidates.some((candidate) => {
+ // if focus is already where we want to go, we don't want to keep going through the candidates
+ if (candidate === previouslyFocusedElement) return true;
+ candidate.focus();
+ return document.activeElement !== previouslyFocusedElement;
+ });
+}
+
+function removeFromTabOrder(candidates: HTMLElement[]) {
+ candidates.forEach((candidate) => {
+ candidate.dataset.tabindex = candidate.getAttribute("tabindex") || "";
+ candidate.setAttribute("tabindex", "-1");
+ });
+ return () => {
+ candidates.forEach((candidate) => {
+ const prevTabIndex = candidate.dataset.tabindex as string;
+ candidate.setAttribute("tabindex", prevTabIndex);
+ });
+ };
+}
+
+type BitsPointerEventHandler = (
+ e: BitsPointerEvent
+) => void;
+
+function whenMouse(
+ handler: BitsPointerEventHandler
+): BitsPointerEventHandler {
+ return (e) => (e.pointerType === "mouse" ? handler(e) : undefined);
+}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts
new file mode 100644
index 000000000..87b4c9c13
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts
@@ -0,0 +1,218 @@
+import type { EscapeBehaviorType } from "../utilities/escape-layer/types.js";
+import type { InteractOutsideBehaviorType } from "../utilities/dismissible-layer/types.js";
+import type {
+ OnChangeFn,
+ WithChild,
+ WithChildNoChildrenSnippetProps,
+ Without,
+} from "$lib/internal/types.js";
+import type {
+ BitsPrimitiveAnchorAttributes,
+ BitsPrimitiveButtonAttributes,
+ BitsPrimitiveDivAttributes,
+ BitsPrimitiveElementAttributes,
+ BitsPrimitiveLiAttributes,
+ BitsPrimitiveUListAttributes,
+} from "$lib/shared/attributes.js";
+import type { Direction, Orientation } from "$lib/shared/index.js";
+
+export type NavigationMenuRootPropsWithoutHTML = WithChild<{
+ /**
+ * The value of the currently open menu item.
+ *
+ * @bindable
+ */
+ value?: string;
+
+ /**
+ * The callback to call when a menu item is selected.
+ */
+ onValueChange?: OnChangeFn;
+
+ /**
+ * Whether or not the value state is controlled or not. If `true`, the component will not update
+ * the value state internally, instead it will call `onValueChange` when it would have
+ * otherwise, and it is up to you to update the `value` prop that is passed to the component.
+ */
+ controlledValue?: boolean;
+
+ /**
+ * The duration from when the mouse enters a trigger until the content opens.
+ *
+ * @defaultValue 200
+ */
+ delayDuration?: number;
+
+ /**
+ * How much time a user has to enter another trigger without incurring a delay again.
+ *
+ * @defaultValue 300
+ */
+ skipDelayDuration?: number;
+
+ /**
+ * The reading direction of the content.
+ *
+ * @defaultValue "ltr"
+ */
+ dir?: Direction;
+
+ /**
+ * The orientation of the menu.
+ */
+ orientation?: Orientation;
+}>;
+
+export type NavigationMenuRootProps = NavigationMenuRootPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuSubPropsWithoutHTML = WithChild<{
+ /**
+ * The value of the currently open menu item within the menu.
+ *
+ * @bindable
+ */
+ value?: string;
+
+ /**
+ * A callback fired when the active menu item changes.
+ */
+ onValueChange?: OnChangeFn;
+
+ /**
+ * Whether or not the value state is controlled or not. If `true`, the component will not update
+ * the value state internally, instead it will call `onValueChange` when it would have
+ * otherwise, and it is up to you to update the `value` prop that is passed to the component.
+ */
+ controlledValue?: boolean;
+
+ /**
+ * The orientation of the menu.
+ */
+ orientation?: Orientation;
+}>;
+
+export type NavigationMenuSubProps = NavigationMenuSubPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuListPropsWithoutHTML = WithChildNoChildrenSnippetProps<
+ {},
+ {
+ /**
+ * Attributes to spread onto a wrapper element around the content.
+ * Do not style the wrapper element, its styles are computed by Floating UI.
+ */
+ wrapperProps: Record;
+ }
+>;
+
+export type NavigationMenuListProps = NavigationMenuListPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuItemPropsWithoutHTML = WithChild<{
+ /**
+ * The value of the menu item.
+ */
+ value?: string;
+}>;
+
+export type NavigationMenuItemProps = NavigationMenuItemPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuTriggerPropsWithoutHTML = WithChild<{
+ /**
+ * Whether the trigger is disabled.
+ * @defaultValue false
+ */
+ disabled?: boolean | null | undefined;
+}>;
+
+export type NavigationMenuTriggerProps = NavigationMenuTriggerPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuContentPropsWithoutHTML = WithChild<{
+ /**
+ * Callback fired when an interaction occurs outside the content.
+ * Default behavior can be prevented with `event.preventDefault()`
+ *
+ */
+ onInteractOutside?: (event: PointerEvent) => void;
+
+ /**
+ * Callback fired when a focus event occurs outside the content.
+ * Default behavior can be prevented with `event.preventDefault()`
+ */
+ onFocusOutside?: (event: FocusEvent) => void;
+
+ /**
+ * Callback fires when an escape keydown event occurs.
+ * Default behavior can be prevented with `event.preventDefault()`
+ */
+ onEscapeKeydown?: (event: KeyboardEvent) => void;
+
+ /**
+ * Behavior when the escape key is pressed while the menu content is open.
+ */
+ escapeKeydownBehavior?: EscapeBehaviorType;
+
+ /**
+ * Behavior when an interaction occurs outside the content.
+ */
+ interactOutsideBehavior?: InteractOutsideBehaviorType;
+
+ /**
+ * Whether to forcefully mount the content, regardless of the open state.
+ * This is useful when wanting to use more custom transition and animation
+ * libraries.
+ *
+ * @defaultValue false
+ */
+ forceMount?: boolean;
+}>;
+
+export type NavigationMenuContentProps = NavigationMenuContentPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuLinkPropsWithoutHTML = WithChild<{
+ /**
+ * Whether the link is the current active page
+ */
+ active?: boolean;
+
+ /**
+ * A callback fired when the link is clicked.
+ * Default behavior can be prevented with `event.preventDefault()`
+ */
+ onSelect?: (e: Event) => void;
+}>;
+
+export type NavigationMenuLinkProps = NavigationMenuLinkPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuIndicatorPropsWithoutHTML = WithChild<{
+ /**
+ * Whether to forcefully mount the content, regardless of the open state.
+ * This is useful when wanting to use more custom transition and animation
+ * libraries.
+ *
+ * @defaultValue false
+ */
+ forceMount?: boolean;
+}>;
+
+export type NavigationMenuIndicatorProps = NavigationMenuIndicatorPropsWithoutHTML &
+ Without;
+
+export type NavigationMenuViewportPropsWithoutHTML = WithChild<{
+ /**
+ * Whether to forcefully mount the content, regardless of the open state.
+ * This is useful when wanting to use more custom transition and animation
+ * libraries.
+ *
+ * @defaultValue false
+ */
+ forceMount?: boolean;
+}>;
+
+export type NavigationMenuViewportProps = NavigationMenuViewportPropsWithoutHTML &
+ Without;
diff --git a/packages/bits-ui/src/lib/index.ts b/packages/bits-ui/src/lib/index.ts
index 1f8d5f74c..6bf9c60c7 100644
--- a/packages/bits-ui/src/lib/index.ts
+++ b/packages/bits-ui/src/lib/index.ts
@@ -20,6 +20,7 @@ export {
LinkPreview,
Menubar,
NavigationMenu,
+ NavMenu,
Pagination,
PinInput,
Popover,
diff --git a/packages/bits-ui/src/lib/internal/events.ts b/packages/bits-ui/src/lib/internal/events.ts
index 3c157495c..e6dd3e2b5 100644
--- a/packages/bits-ui/src/lib/internal/events.ts
+++ b/packages/bits-ui/src/lib/internal/events.ts
@@ -75,11 +75,15 @@ export class CustomEventDispatcher {
return event;
}
- listen(element: EventTarget, callback: (event: CustomEvent) => void) {
+ listen(
+ element: EventTarget,
+ callback: (event: CustomEvent) => void,
+ options?: AddEventListenerOptions
+ ) {
const handler = (event: Event) => {
callback(event as CustomEvent);
};
- return on(element, this.eventName, handler);
+ return on(element, this.eventName, handler, options);
}
}
diff --git a/packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts b/packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts
new file mode 100644
index 000000000..57dc7ed95
--- /dev/null
+++ b/packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts
@@ -0,0 +1,26 @@
+import type { Getter } from "svelte-toolbelt";
+
+/**
+ * Holds the previous value of a getter, with the initial value being
+ * the value of the getter when the instance is created, rather than
+ * `undefined`.
+ */
+export class PreviousWithInit {
+ #previous = $state(null!);
+ #curr: T;
+
+ constructor(getter: Getter) {
+ const init = getter();
+ this.#previous = init;
+ this.#curr = init;
+
+ $effect(() => {
+ this.#previous = this.#curr;
+ this.#curr = getter();
+ });
+ }
+
+ get current(): T {
+ return this.#previous;
+ }
+}
diff --git a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte
index c82793b0e..e4495fc24 100644
--- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte
+++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte
@@ -1,5 +1,5 @@