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 &