diff --git a/.changeset/green-onions-do.md b/.changeset/green-onions-do.md
new file mode 100644
index 000000000..b940eae11
--- /dev/null
+++ b/.changeset/green-onions-do.md
@@ -0,0 +1,5 @@
+---
+"bits-ui": minor
+---
+
+feat: add `openOnHover` prop to `NavigationMenu.Item` to enable users to disable opening the item's `NavigationMenu.Content` on hover
diff --git a/docs/content/components/navigation-menu.md b/docs/content/components/navigation-menu.md
index 49ff7f703..689f8675d 100644
--- a/docs/content/components/navigation-menu.md
+++ b/docs/content/components/navigation-menu.md
@@ -262,12 +262,12 @@ You may wish for the links in the Navigation Menu to persist in the DOM, regardl
```
-
+### Disable Open on Hover
-{#snippet preview()}
-
-{/snippet}
+To prevent the menu from opening on hover, you can set the `openOnHover` prop to `false` on the `NavigationMenu.Item` component. When `openOnHover` is set to `false`, the menu will only open when the `NavigationMenu.Trigger` is clicked, and will not close when the mouse moves away from the menu/trigger area, instead expecting the user to either click the trigger again, click outside the menu, or use the `Escape` key to close the menu.
-
+```svelte /openOnHover={false}/
+
+```
diff --git a/docs/src/lib/content/api-reference/navigation-menu.api.ts b/docs/src/lib/content/api-reference/navigation-menu.api.ts
index d1f189863..8728271cb 100644
--- a/docs/src/lib/content/api-reference/navigation-menu.api.ts
+++ b/docs/src/lib/content/api-reference/navigation-menu.api.ts
@@ -71,6 +71,10 @@ export const item = createApiSchema({
value: createStringProp({
description: "The value of the item.",
}),
+ openOnHover: createBooleanProp({
+ default: C.TRUE,
+ description: "Whether or not the content belonging to the item should open on hover.",
+ }),
...withChildProps({ elType: "HTMLLiElement" }),
},
});
diff --git a/docs/src/routes/(main)/sink/+page.svelte b/docs/src/routes/(main)/sink/+page.svelte
index e69de29bb..ca08d367e 100644
--- a/docs/src/routes/(main)/sink/+page.svelte
+++ b/docs/src/routes/(main)/sink/+page.svelte
@@ -0,0 +1,143 @@
+
+
+
+
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte
index 745a11cc8..39ecdd5cc 100644
--- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte
+++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte
@@ -40,4 +40,11 @@
{/snippet}
+{:else}
+
+ {#snippet presence()}
+
+
+ {/snippet}
+
{/if}
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte
index 93eecb3e5..952aa1d80 100644
--- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte
+++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte
@@ -8,6 +8,7 @@
id = useId(),
value = useId(),
ref = $bindable(null),
+ openOnHover = true,
child,
children,
...restProps
@@ -20,6 +21,7 @@
(v) => (ref = v)
),
value: box.with(() => value),
+ openOnHover: box.with(() => openOnHover),
});
const mergedProps = $derived(mergeProps(restProps, itemState.props));
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts
index 2a7e20f94..b6b466d3a 100644
--- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts
@@ -61,11 +61,11 @@ type NavigationMenuProviderStateProps = ReadableBoxedValues<{
previousValue: string;
}> & {
isRootMenu: boolean;
- onTriggerEnter: (itemValue: string) => void;
+ onTriggerEnter: (itemValue: string, itemState: NavigationMenuItemState | null) => void;
onTriggerLeave?: () => void;
onContentEnter?: () => void;
onContentLeave?: () => void;
- onItemSelect: (itemValue: string) => void;
+ onItemSelect: (itemValue: string, itemState: NavigationMenuItemState | null) => void;
onItemDismiss: () => void;
};
@@ -73,6 +73,7 @@ class NavigationMenuProviderState {
indicatorTrackRef = box(null);
viewportRef = box(null);
viewportContent = new SvelteMap();
+ activeItem: NavigationMenuItemState | null = null;
onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"];
onTriggerLeave: () => void = noop;
onContentEnter: () => void = noop;
@@ -127,8 +128,8 @@ class NavigationMenuRootState {
orientation: this.opts.orientation,
rootNavigationMenuRef: this.opts.ref,
isRootMenu: true,
- onTriggerEnter: (itemValue) => {
- this.#onTriggerEnter(itemValue);
+ onTriggerEnter: (itemValue, itemState) => {
+ this.#onTriggerEnter(itemValue, itemState);
},
onTriggerLeave: this.#onTriggerLeave,
onContentEnter: this.#onContentEnter,
@@ -139,43 +140,50 @@ class NavigationMenuRootState {
}
#debouncedFn = useDebounce(
- (val?: string) => {
+ (val: string | undefined, itemState: NavigationMenuItemState | null) => {
// passing `undefined` meant to reset the debounce timer
if (typeof val === "string") {
- this.setValue(val);
+ this.setValue(val, itemState);
}
},
() => this.#derivedDelay
);
- #onTriggerEnter = (itemValue: string) => {
- this.#debouncedFn(itemValue);
+ #onTriggerEnter = (itemValue: string, itemState: NavigationMenuItemState | null) => {
+ this.#debouncedFn(itemValue, itemState);
};
#onTriggerLeave = () => {
this.isDelaySkipped.current = false;
- this.#debouncedFn("");
+ this.#debouncedFn("", null);
};
#onContentEnter = () => {
- this.#debouncedFn();
+ this.#debouncedFn(undefined, null);
};
#onContentLeave = () => {
- this.#debouncedFn("");
+ if (
+ this.provider.activeItem &&
+ this.provider.activeItem.opts.openOnHover.current === false
+ ) {
+ return;
+ }
+ this.#debouncedFn("", null);
};
- #onItemSelect = (itemValue: string) => {
- this.setValue(itemValue);
+ #onItemSelect = (itemValue: string, itemState: NavigationMenuItemState | null) => {
+ this.setValue(itemValue, itemState);
};
#onItemDismiss = () => {
- this.setValue("");
+ this.setValue("", null);
};
- setValue = (newValue: string) => {
+ setValue = (newValue: string, itemState: NavigationMenuItemState | null) => {
this.previousValue.current = this.opts.value.current;
this.opts.value.current = newValue;
+ this.provider.activeItem = itemState;
};
props = $derived.by(
@@ -296,6 +304,7 @@ class NavigationMenuListState {
type NavigationMenuItemStateProps = WithRefProps<
ReadableBoxedValues<{
value: string;
+ openOnHover: boolean;
}>
>;
@@ -411,16 +420,19 @@ class NavigationMenuTriggerState {
this.opts.disabled.current ||
this.wasClickClose ||
this.itemContext.wasEscapeClose ||
- this.hasPointerMoveOpened.current
+ this.hasPointerMoveOpened.current ||
+ !this.itemContext.opts.openOnHover.current
) {
return;
}
- this.context.onTriggerEnter(this.itemContext.opts.value.current);
+ this.context.onTriggerEnter(this.itemContext.opts.value.current, this.itemContext);
this.hasPointerMoveOpened.current = true;
});
onpointerleave = whenMouse(() => {
- if (this.opts.disabled.current) return;
+ if (this.opts.disabled.current || !this.itemContext.opts.openOnHover.current) {
+ return;
+ }
this.context.onTriggerLeave();
this.hasPointerMoveOpened.current = false;
});
@@ -429,9 +441,9 @@ class NavigationMenuTriggerState {
// if opened via pointer move, we prevent the click event
if (this.hasPointerMoveOpened.current) return;
if (this.open) {
- this.context.onItemSelect("");
+ this.context.onItemSelect("", null);
} else {
- this.context.onItemSelect(this.itemContext.opts.value.current);
+ this.context.onItemSelect(this.itemContext.opts.value.current, this.itemContext);
}
this.wasClickClose = this.open;
};
@@ -699,6 +711,7 @@ class NavigationMenuContentState {
};
onpointerleave = whenMouse(() => {
+ if (!this.itemContext.opts.openOnHover.current) return;
this.context.onContentLeave();
});
@@ -802,9 +815,32 @@ class NavigationMenuContentImplState {
onInteractOutside = (e: PointerEvent) => {
const target = e.target as HTMLElement;
const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target));
- const isRootViewport =
- this.context.opts.isRootMenu && this.context.viewportRef.current?.contains(target);
- if (isTrigger || isRootViewport || !this.context.opts.isRootMenu) e.preventDefault();
+
+ const isRootMenu = this.context.opts.isRootMenu;
+
+ // we handle interactions outside differently for submenus
+ if (!isRootMenu) {
+ const isInteractionInViewport = this.context.viewportRef.current?.contains(target);
+ if (isTrigger || isInteractionInViewport) {
+ e.preventDefault();
+ return;
+ }
+ if (!this.itemContext.opts.openOnHover.current) {
+ this.context.onItemSelect("", null);
+ return;
+ }
+ }
+
+ const isRootViewport = isRootMenu && this.context.viewportRef.current?.contains(target);
+
+ if (isTrigger || isRootViewport || !this.context.opts.isRootMenu) {
+ console.log("on interact outside, not root menu so keeping open");
+ e.preventDefault();
+ return;
+ }
+ if (!this.itemContext.opts.openOnHover.current) {
+ this.context.onItemSelect("", null);
+ }
};
onkeydown = (e: BitsKeyboardEvent) => {
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/types.ts b/packages/bits-ui/src/lib/bits/navigation-menu/types.ts
index 4696acd25..4c3526733 100644
--- a/packages/bits-ui/src/lib/bits/navigation-menu/types.ts
+++ b/packages/bits-ui/src/lib/bits/navigation-menu/types.ts
@@ -100,6 +100,16 @@ export type NavigationMenuItemPropsWithoutHTML = WithChild<{
* The value of the menu item.
*/
value?: string;
+
+ /**
+ * Whether the item's `NavigationMenu.Content` should open when the trigger is hovered.
+ * If `false`, the content will only open when the trigger is pressed, and will not close
+ * when the mouse leaves the content/trigger, requiring the user to either click the trigger,
+ * press escape, or click outside the content to close it.
+ *
+ * @default true
+ */
+ openOnHover?: boolean;
}>;
export type NavigationMenuItemProps = NavigationMenuItemPropsWithoutHTML &