Skip to content

Commit

Permalink
next: Navigation Menu Improvements (#1001)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Feb 9, 2025
1 parent 5b13e2a commit 0000391
Show file tree
Hide file tree
Showing 20 changed files with 1,481 additions and 869 deletions.
5 changes: 5 additions & 0 deletions .changeset/curly-islands-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

feat: Navigation Menu Submenu support
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import { untrack, type Snippet } from "svelte";
import type { NavigationMenuContentProps } from "../types.js";
import {
NavigationMenuItemContext,
NavigationMenuItemState,
useNavigationMenuContentImpl,
} from "../navigation-menu.svelte.js";
import { noop } from "$lib/internal/noop.js";
import { useId } from "$lib/internal/use-id.js";
import DismissibleLayer from "$lib/bits/utilities/dismissible-layer/dismissible-layer.svelte";
import EscapeLayer from "$lib/bits/utilities/escape-layer/escape-layer.svelte";
let {
ref = $bindable(null),
id = useId(),
child: childProp,
children: childrenProp,
onInteractOutside = noop,
onFocusOutside = noop,
onEscapeKeydown = noop,
escapeKeydownBehavior = "close",
interactOutsideBehavior = "close",
itemState,
onRefChange,
...restProps
}: Omit<NavigationMenuContentProps, "child"> & {
itemState?: NavigationMenuItemState;
onRefChange?: (ref: HTMLElement | null) => void;
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const contentImplState = useNavigationMenuContentImpl(
{
id: box.with(() => id),
ref: box.with(
() => ref,
(v) => {
ref = v;
untrack(() => onRefChange?.(v));
}
),
},
itemState
);
if (itemState) {
NavigationMenuItemContext.set(itemState);
}
const mergedProps = $derived(mergeProps(restProps, contentImplState.props));
</script>

<DismissibleLayer
{id}
enabled={true}
onInteractOutside={(e) => {
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 })}
<EscapeLayer
enabled={true}
onEscapeKeydown={(e) => {
onEscapeKeydown(e);
if (e.defaultPrevented) return;
contentImplState.onEscapeKeydown(e);
}}
{escapeKeydownBehavior}
>
{@const finalProps = mergeProps(mergedProps, dismissibleProps)}
{#if childProp}
{@render childProp({ props: finalProps })}
{:else}
<div {...finalProps}>
{@render childrenProp?.()}
</div>
{/if}
</EscapeLayer>
{/snippet}
</DismissibleLayer>
Original file line number Diff line number Diff line change
@@ -1,82 +1,43 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuContentProps } from "../types.js";
import { useNavigationMenuContent } from "../navigation-menu.svelte.js";
import NavigationMenuContentImpl from "./navigation-menu-content-impl.svelte";
import { useId } from "$lib/internal/use-id.js";
import type { NavigationMenuContentProps } from "$lib/types.js";
import Portal from "$lib/bits/utilities/portal/portal.svelte";
import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte";
import DismissibleLayer from "$lib/bits/utilities/dismissible-layer/dismissible-layer.svelte";
import EscapeLayer from "$lib/bits/utilities/escape-layer/escape-layer.svelte";
import Mounted from "$lib/bits/utilities/mounted.svelte";
let {
children: contentChildren,
child,
ref = $bindable(null),
id = useId(),
children,
child,
forceMount = false,
onEscapeKeydown,
onInteractOutside,
onFocusOutside,
...restProps
}: NavigationMenuContentProps = $props();
let isMounted = $state(false);
const contentState = useNavigationMenuContent({
id: box.with(() => id),
ref: box.with(
() => ref,
(v) => {
ref = v;
}
(v) => (ref = v)
),
forceMount: box.with(() => forceMount),
isMounted: box.with(() => isMounted),
});
const mergedProps = $derived(mergeProps(restProps, contentState.props));
const portalDisabled = $derived(!contentState.menu.viewportNode);
</script>

<Portal to={contentState.menu.viewportNode ?? undefined} disabled={portalDisabled}>
<PresenceLayer {id} present={contentState.isPresent}>
{#snippet presence()}
<EscapeLayer
enabled={contentState.isPresent}
onEscapeKeydown={(e) => {
onEscapeKeydown?.(e);
if (e.defaultPrevented) return;
contentState.onEscapeKeydown(e);
}}
>
<DismissibleLayer
enabled={contentState.isPresent}
{id}
onInteractOutside={(e) => {
onInteractOutside?.(e);
if (e.defaultPrevented) return;
contentState.onInteractOutside(e);
}}
onFocusOutside={(e) => {
onFocusOutside?.(e);
if (e.defaultPrevented) return;
contentState.onFocusOutside(e);
}}
>
{#snippet children({ props: dismissibleProps })}
{#if child}
<Mounted bind:mounted={isMounted} />
{@render child({ props: mergeProps(dismissibleProps, mergedProps) })}
{:else}
<Mounted bind:mounted={isMounted} />
<div {...mergeProps(dismissibleProps, mergedProps)}>
{@render contentChildren?.()}
</div>
{/if}
{/snippet}
</DismissibleLayer>
</EscapeLayer>
{/snippet}
</PresenceLayer>
</Portal>
{#if contentState.context.viewportRef.current}
<Portal to={contentState.context.viewportRef.current}>
<PresenceLayer
{id}
present={forceMount || contentState.open || contentState.isLastActiveValue}
>
{#snippet presence()}
<NavigationMenuContentImpl {...mergedProps} {children} {child} />
<Mounted bind:mounted={contentState.mounted} />
{/snippet}
</PresenceLayer>
</Portal>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuIndicatorProps } from "../types.js";
import { useNavigationMenuIndicatorImpl } from "../navigation-menu.svelte.js";
import { useId } from "$lib/internal/use-id.js";
let {
id = useId(),
ref = $bindable(null),
children,
child,
...restProps
}: NavigationMenuIndicatorProps = $props();
const indicatorState = useNavigationMenuIndicatorImpl({
id: box.with(() => id),
ref: box.with(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, indicatorState.props));
</script>

{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import { mergeProps } from "svelte-toolbelt";
import type { NavigationMenuIndicatorProps } from "../types.js";
import { useNavigationMenuIndicator } from "../navigation-menu.svelte.js";
import NavigationMenuIndicatorImpl from "./navigation-menu-indicator-impl.svelte";
import { useId } from "$lib/internal/use-id.js";
import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte";
import Portal from "$lib/bits/utilities/portal/portal.svelte";
Expand All @@ -15,28 +16,15 @@
...restProps
}: NavigationMenuIndicatorProps = $props();
const indicatorState = useNavigationMenuIndicator({
id: box.with(() => id),
ref: box.with(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, indicatorState.props));
const indicatorState = useNavigationMenuIndicator();
const mergedProps = $derived(mergeProps(restProps));
</script>

{#if indicatorState.menu.indicatorTrackNode}
<Portal to={indicatorState.menu.indicatorTrackNode}>
{#if indicatorState.context.indicatorTrackRef.current}
<Portal to={indicatorState.context.indicatorTrackRef.current}>
<PresenceLayer {id} present={forceMount || indicatorState.isVisible}>
{#snippet presence()}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
<NavigationMenuIndicatorImpl {...mergedProps} {children} {child} {id} bind:ref />
{/snippet}
</PresenceLayer>
</Portal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { NavigationMenuListProps } from "../types.js";
import { useNavigationMenuList } from "../navigation-menu.svelte.js";
import { useId } from "$lib/internal/use-id.js";
import Mounted from "$lib/bits/utilities/mounted.svelte";
let {
id = useId(),
Expand All @@ -18,19 +19,20 @@
() => ref,
(v) => (ref = v)
),
indicatorTrackRef: box(null),
});
const mergedProps = $derived(mergeProps(restProps, listState.props));
const indicatorTrackProps = $derived(mergeProps(listState.indicatorTrackProps, {}));
const wrapperProps = $derived(mergeProps(listState.wrapperProps));
</script>

<div {...indicatorTrackProps}>
{#if child}
{@render child({ props: mergedProps })}
{:else}
{#if child}
{@render child({ props: mergedProps, wrapperProps })}
<Mounted bind:mounted={listState.wrapperMounted} />
{:else}
<div {...wrapperProps}>
<ul {...mergedProps}>
{@render children?.()}
</ul>
{/if}
</div>
</div>
<Mounted bind:mounted={listState.wrapperMounted} />
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuSubProps } from "../types.js";
import { useNavigationMenuSub } from "../navigation-menu.svelte.js";
import { useId } from "$lib/internal/use-id.js";
import { noop } from "$lib/internal/noop.js";
let {
child,
children,
id = useId(),
ref = $bindable(null),
value = $bindable(""),
onValueChange = noop,
orientation = "horizontal",
...restProps
}: NavigationMenuSubProps = $props();
const rootState = useNavigationMenuSub({
id: box.with(() => id),
value: box.with(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
orientation: box.with(() => orientation),
ref: box.with(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>

{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
{/if}

{#if triggerState.open}
<VisuallyHidden {...triggerState.focusProxyProps} />
<Mounted bind:mounted={triggerState.focusProxyMounted} />
<VisuallyHidden {...triggerState.visuallyHiddenProps} />
{#if triggerState.menu.viewportNode}
<span aria-owns={triggerState.item.contentNode?.id ?? undefined}></span>
{#if triggerState.context.viewportRef.current}
<span aria-owns={triggerState.itemContext.contentId ?? undefined}></span>
{/if}
{/if}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<script lang="ts">
import { box, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuViewportProps } from "../types.js";
import { useNavigationMenuViewport } from "../navigation-menu.svelte.js";
import { useId } from "$lib/internal/use-id.js";
import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte";
import { box, mergeProps } from "svelte-toolbelt";
let {
id = useId(),
ref = $bindable(null),
children,
child,
forceMount = false,
child,
children,
...restProps
}: NavigationMenuViewportProps = $props();
Expand Down
Loading

0 comments on commit 0000391

Please sign in to comment.