Skip to content
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

next: Nav Menu Improvements #1001

Merged
merged 34 commits into from
Feb 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
efb6836
init
huntabyte Dec 14, 2024
5da705d
progress
huntabyte Dec 14, 2024
d2314e1
more
huntabyte Dec 14, 2024
a1afad5
more
huntabyte Dec 15, 2024
b8299ed
Merge branch 'next' into next-nav-menu-rewrite
huntabyte Dec 15, 2024
e17518e
idk
huntabyte Dec 16, 2024
3f5ca3c
ok ok
huntabyte Dec 16, 2024
d23e2e7
Merge branch 'next' into next-nav-menu-rewrite
huntabyte Jan 7, 2025
a779f82
new apis
huntabyte Jan 7, 2025
c51679e
Merge branch 'next' into next-nav-menu-rewrite
huntabyte Feb 1, 2025
15e8665
fix: issue with transition out
huntabyte Feb 1, 2025
6d8cc22
Merge branch 'next' into next-nav-menu-rewrite
huntabyte Feb 8, 2025
1838a0f
broke
huntabyte Feb 8, 2025
f74a5f7
need to fix focus proxy behavior
huntabyte Feb 8, 2025
b2d61c9
focus is rolling
huntabyte Feb 8, 2025
0bf386c
focus within menus - check!
huntabyte Feb 8, 2025
49f84ab
cleanup
huntabyte Feb 8, 2025
a057835
some more
huntabyte Feb 8, 2025
dcc101b
some more
huntabyte Feb 8, 2025
753b630
remove unused props
huntabyte Feb 8, 2025
273ac69
before complete destruction
huntabyte Feb 9, 2025
f4a3c60
lfg
huntabyte Feb 9, 2025
54f840c
nav menu lets go
huntabyte Feb 9, 2025
aef2f57
handle escape keydown
huntabyte Feb 9, 2025
1d1ae52
cleanup
huntabyte Feb 9, 2025
6bf34de
indicator
huntabyte Feb 9, 2025
47af26b
submenu work
huntabyte Feb 9, 2025
6054e73
update nav menu
huntabyte Feb 9, 2025
725360a
remove old nav menu
huntabyte Feb 9, 2025
b1f4160
fix
huntabyte Feb 9, 2025
73fb68a
enable click to toggle
huntabyte Feb 9, 2025
30fcf66
nav menu docs
huntabyte Feb 9, 2025
1c65bc2
changeset
huntabyte Feb 9, 2025
2069491
remove comments
huntabyte Feb 9, 2025
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/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