Skip to content

feat: NavigationMenu.Item - openOnHover prop #1223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-onions-do.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 5 additions & 5 deletions docs/content/components/navigation-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,12 @@ You may wish for the links in the Navigation Menu to persist in the DOM, regardl
<NavigationMenu.Viewport forceMount></NavigationMenu.Viewport>
```

<ComponentPreviewV2 name="navigation-menu-demo-force-mount" comp="Navigation Menu">
### Disable Open on Hover

{#snippet preview()}
<NavigationMenuDemoForceMount />
{/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.

</ComponentPreviewV2>
```svelte /openOnHover={false}/
<NavigationMenu.Item openOnHover={false}></NavigationMenu.Item>
```

<APISection {schemas} />
4 changes: 4 additions & 0 deletions docs/src/lib/content/api-reference/navigation-menu.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export const item = createApiSchema<NavigationMenuItemPropsWithoutHTML>({
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" }),
},
});
Expand Down
143 changes: 143 additions & 0 deletions docs/src/routes/(main)/sink/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<script lang="ts">
import { NavigationMenu } from "bits-ui";
</script>

<div class="nav-menu">
<NavigationMenu.Root orientation="vertical">
<NavigationMenu.List>
<NavigationMenu.Item>
<NavigationMenu.Link href="/">Link 1</NavigationMenu.Link>
</NavigationMenu.Item>

<NavigationMenu.Item openOnHover={false}>
<NavigationMenu.Trigger type="button">Trigger A</NavigationMenu.Trigger>
<NavigationMenu.Content class="bg-blue-200">
<ul class="m-0 ml-3 flex list-none flex-col">
<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">A - Link 1</NavigationMenu.Link>
</li>

<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">A - Link 2</NavigationMenu.Link>
</li>

<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Sub>
<NavigationMenu.Item openOnHover={false}>
<NavigationMenu.Trigger type="button">
Sub-Trigger A
</NavigationMenu.Trigger>
<NavigationMenu.Content class="bg-green-200">
<ul class="m-0 ml-3 flex list-none flex-col">
<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">
Sub A - Link 1
</NavigationMenu.Link>
</li>

<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">
Sub A - Link 2
</NavigationMenu.Link>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
</NavigationMenu.Sub>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>

<NavigationMenu.Item>
<NavigationMenu.Link href="/">Link 2</NavigationMenu.Link>
</NavigationMenu.Item>

<NavigationMenu.Item openOnHover={false}>
<NavigationMenu.Trigger type="button">Trigger B</NavigationMenu.Trigger>
<NavigationMenu.Content class="bg-blue-200 p-3">
<ul class="m-0 ml-3 flex list-none flex-col">
<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">B - Link 1</NavigationMenu.Link>
</li>

<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">B - Link 2</NavigationMenu.Link>
</li>

<li>
<NavigationMenu.Sub>
<NavigationMenu.Item openOnHover={false}>
<NavigationMenu.Trigger type="button">
Sub-Trigger B
</NavigationMenu.Trigger>
<NavigationMenu.Content class="bg-green-200">
<ul class="m-0 ml-3 flex list-none flex-col">
<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">
Sub B - Link 1
</NavigationMenu.Link>
</li>

<li class="row-span-3 mb-2 sm:mb-0">
<NavigationMenu.Link href="/">
Sub B - Link 2
</NavigationMenu.Link>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
</NavigationMenu.Sub>
</li>
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>

<NavigationMenu.Item>
<NavigationMenu.Link href="/">Link 3</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
<!-- <NavigationMenu.Viewport /> -->
</NavigationMenu.Root>
</div>

<style>
.nav-menu {
width: 200px;
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.1);
background-color: white;
}

:global {
[data-navigation-menu-list] {
list-style-type: none;
padding: 0;
margin: 0;
}

[data-navigation-menu-link],
[data-navigation-menu-trigger] {
display: block;
width: 100%;
text-align: left;
line-height: 1rem;
padding: 10px;
font-weight: normal;
text-decoration: none;

&,
&:visited {
color: black;
}

&:hover {
background-color: #ddd;
}
}

[data-navigation-menu-sub] {
background-color: #f5f5f5;
border-block: 1px solid #ccc;
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,11 @@
{/snippet}
</PresenceLayer>
</Portal>
{:else}
<PresenceLayer {id} present={forceMount || contentState.open || contentState.isLastActiveValue}>
{#snippet presence()}
<NavigationMenuContentImpl {...mergedProps} {children} {child} />
<Mounted bind:mounted={contentState.mounted} />
{/snippet}
</PresenceLayer>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
id = useId(),
value = useId(),
ref = $bindable(null),
openOnHover = true,
child,
children,
...restProps
Expand All @@ -20,6 +21,7 @@
(v) => (ref = v)
),
value: box.with(() => value),
openOnHover: box.with(() => openOnHover),
});

const mergedProps = $derived(mergeProps(restProps, itemState.props));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,19 @@ 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;
};

class NavigationMenuProviderState {
indicatorTrackRef = box<HTMLElement | null>(null);
viewportRef = box<HTMLElement | null>(null);
viewportContent = new SvelteMap<string, NavigationMenuItemState>();
activeItem: NavigationMenuItemState | null = null;
onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"];
onTriggerLeave: () => void = noop;
onContentEnter: () => void = noop;
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -296,6 +304,7 @@ class NavigationMenuListState {
type NavigationMenuItemStateProps = WithRefProps<
ReadableBoxedValues<{
value: string;
openOnHover: boolean;
}>
>;

Expand Down Expand Up @@ -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;
});
Expand All @@ -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;
};
Expand Down Expand Up @@ -699,6 +711,7 @@ class NavigationMenuContentState {
};

onpointerleave = whenMouse(() => {
if (!this.itemContext.opts.openOnHover.current) return;
this.context.onContentLeave();
});

Expand Down Expand Up @@ -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) => {
Expand Down
Loading