This commit is contained in:
eewing
2026-02-17 14:10:16 -06:00
parent 2bca5834c5
commit cf73cd3b4c
11246 changed files with 1690552 additions and 0 deletions
@@ -0,0 +1,94 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import { untrack, type Snippet } from "svelte";
import type { NavigationMenuContentProps } from "../types.js";
import {
NavigationMenuItemContext,
NavigationMenuItemState,
NavigationMenuContentImplState,
} from "../navigation-menu.svelte.js";
import { noop } from "../../../internal/noop.js";
import { createId } from "../../../internal/create-id.js";
import DismissibleLayer from "../../utilities/dismissible-layer/dismissible-layer.svelte";
import EscapeLayer from "../../utilities/escape-layer/escape-layer.svelte";
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
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 = NavigationMenuContentImplState.create(
{
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => {
ref = v;
untrack(() => onRefChange?.(v));
}
),
},
itemState
);
if (itemState) {
NavigationMenuItemContext.set(itemState);
}
const mergedProps = $derived(mergeProps(restProps, contentImplState.props));
</script>
<DismissibleLayer
{id}
ref={contentImplState.opts.ref}
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}
ref={contentImplState.opts.ref}
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>
@@ -0,0 +1,13 @@
import { type Snippet } from "svelte";
import type { NavigationMenuContentProps } from "../types.js";
import { NavigationMenuItemState } from "../navigation-menu.svelte.js";
type $$ComponentProps = Omit<NavigationMenuContentProps, "child"> & {
itemState?: NavigationMenuItemState;
onRefChange?: (ref: HTMLElement | null) => void;
child?: Snippet<[{
props: Record<string, unknown>;
}]>;
};
declare const NavigationMenuContentImpl: import("svelte").Component<$$ComponentProps, {}, "ref">;
type NavigationMenuContentImpl = ReturnType<typeof NavigationMenuContentImpl>;
export default NavigationMenuContentImpl;
@@ -0,0 +1,46 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import { NavigationMenuContentState } from "../navigation-menu.svelte.js";
import NavigationMenuContentImpl from "./navigation-menu-content-impl.svelte";
import { createId } from "../../../internal/create-id.js";
import type { NavigationMenuContentProps } from "../../../types.js";
import Portal from "../../utilities/portal/portal.svelte";
import PresenceLayer from "../../utilities/presence-layer/presence-layer.svelte";
import Mounted from "../../utilities/mounted.svelte";
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
children,
child,
forceMount = false,
...restProps
}: NavigationMenuContentProps = $props();
const contentState = NavigationMenuContentState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, contentState.props));
</script>
<Portal
to={contentState.context.viewportRef.current || undefined}
disabled={!contentState.context.viewportRef.current}
>
<PresenceLayer
open={forceMount || contentState.open || contentState.isLastActiveValue}
ref={contentState.opts.ref}
>
{#snippet presence()}
<NavigationMenuContentImpl {...mergedProps} {children} {child} />
<Mounted bind:mounted={contentState.mounted} />
{/snippet}
</PresenceLayer>
</Portal>
@@ -0,0 +1,4 @@
import type { NavigationMenuContentProps } from "../../../types.js";
declare const NavigationMenuContent: import("svelte").Component<NavigationMenuContentProps, {}, "ref">;
type NavigationMenuContent = ReturnType<typeof NavigationMenuContent>;
export default NavigationMenuContent;
@@ -0,0 +1,34 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuIndicatorProps } from "../types.js";
import { NavigationMenuIndicatorImplState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
let {
id = createId(uid),
ref = $bindable(null),
children,
child,
...restProps
}: NavigationMenuIndicatorProps = $props();
const indicatorState = NavigationMenuIndicatorImplState.create({
id: boxWith(() => id),
ref: boxWith(
() => 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}
@@ -0,0 +1,4 @@
import type { NavigationMenuIndicatorProps } from "../types.js";
declare const NavigationMenuIndicatorImpl: import("svelte").Component<NavigationMenuIndicatorProps, {}, "ref">;
type NavigationMenuIndicatorImpl = ReturnType<typeof NavigationMenuIndicatorImpl>;
export default NavigationMenuIndicatorImpl;
@@ -0,0 +1,33 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuIndicatorProps } from "../types.js";
import { NavigationMenuIndicatorState } from "../navigation-menu.svelte.js";
import NavigationMenuIndicatorImpl from "./navigation-menu-indicator-impl.svelte";
import { createId } from "../../../internal/create-id.js";
import PresenceLayer from "../../utilities/presence-layer/presence-layer.svelte";
import Portal from "../../utilities/portal/portal.svelte";
const uid = $props.id();
let {
id = createId(uid),
ref = $bindable(null),
children,
child,
forceMount = false,
...restProps
}: NavigationMenuIndicatorProps = $props();
const indicatorState = NavigationMenuIndicatorState.create();
const mergedProps = $derived(mergeProps(restProps));
</script>
{#if indicatorState.context.indicatorTrackRef.current}
<Portal to={indicatorState.context.indicatorTrackRef.current}>
<PresenceLayer open={forceMount || indicatorState.isVisible} ref={boxWith(() => ref)}>
{#snippet presence()}
<NavigationMenuIndicatorImpl {...mergedProps} {children} {child} {id} bind:ref />
{/snippet}
</PresenceLayer>
</Portal>
{/if}
@@ -0,0 +1,4 @@
import type { NavigationMenuIndicatorProps } from "../types.js";
declare const NavigationMenuIndicator: import("svelte").Component<NavigationMenuIndicatorProps, {}, "ref">;
type NavigationMenuIndicator = ReturnType<typeof NavigationMenuIndicator>;
export default NavigationMenuIndicator;
@@ -0,0 +1,39 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuItemProps } from "../types.js";
import { NavigationMenuItemState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
const uid = $props.id();
const defaultId = createId(uid);
let {
id = defaultId,
value = defaultId,
ref = $bindable(null),
child,
children,
openOnHover = true,
...restProps
}: NavigationMenuItemProps = $props();
const itemState = NavigationMenuItemState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
value: boxWith(() => value),
openOnHover: boxWith(() => openOnHover),
});
const mergedProps = $derived(mergeProps(restProps, itemState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<li {...mergedProps}>
{@render children?.()}
</li>
{/if}
@@ -0,0 +1,4 @@
import type { NavigationMenuItemProps } from "../types.js";
declare const NavigationMenuItem: import("svelte").Component<NavigationMenuItemProps, {}, "ref">;
type NavigationMenuItem = ReturnType<typeof NavigationMenuItem>;
export default NavigationMenuItem;
@@ -0,0 +1,40 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuLinkProps } from "../types.js";
import { NavigationMenuLinkState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
import { noop } from "../../../internal/noop.js";
const uid = $props.id();
let {
id = createId(uid),
ref = $bindable(null),
child,
children,
active = false,
onSelect = noop,
tabindex = 0,
...restProps
}: NavigationMenuLinkProps = $props();
const linkState = NavigationMenuLinkState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
active: boxWith(() => active),
onSelect: boxWith(() => onSelect),
});
const mergedProps = $derived(mergeProps(restProps, linkState.props, { tabindex }));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a {...mergedProps}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,4 @@
import type { NavigationMenuLinkProps } from "../types.js";
declare const NavigationMenuLink: import("svelte").Component<NavigationMenuLinkProps, {}, "ref">;
type NavigationMenuLink = ReturnType<typeof NavigationMenuLink>;
export default NavigationMenuLink;
@@ -0,0 +1,40 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuListProps } from "../types.js";
import { NavigationMenuListState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
import Mounted from "../../utilities/mounted.svelte";
const uid = $props.id();
let {
id = createId(uid),
children,
child,
ref = $bindable(null),
...restProps
}: NavigationMenuListProps = $props();
const listState = NavigationMenuListState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, listState.props));
const wrapperProps = $derived(mergeProps(listState.wrapperProps));
</script>
{#if child}
{@render child({ props: mergedProps, wrapperProps })}
<Mounted bind:mounted={listState.wrapperMounted} />
{:else}
<div {...wrapperProps}>
<ul {...mergedProps}>
{@render children?.()}
</ul>
</div>
<Mounted bind:mounted={listState.wrapperMounted} />
{/if}
@@ -0,0 +1,4 @@
import type { NavigationMenuListProps } from "../types.js";
declare const NavigationMenuList: import("svelte").Component<NavigationMenuListProps, {}, "ref">;
type NavigationMenuList = ReturnType<typeof NavigationMenuList>;
export default NavigationMenuList;
@@ -0,0 +1,46 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuSubProps } from "../types.js";
import { NavigationMenuSubState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
import { noop } from "../../../internal/noop.js";
const uid = $props.id();
let {
child,
children,
id = createId(uid),
ref = $bindable(null),
value = $bindable(""),
onValueChange = noop,
orientation = "horizontal",
...restProps
}: NavigationMenuSubProps = $props();
const rootState = NavigationMenuSubState.create({
id: boxWith(() => id),
value: boxWith(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
orientation: boxWith(() => orientation),
ref: boxWith(
() => 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}
@@ -0,0 +1,4 @@
import type { NavigationMenuSubProps } from "../types.js";
declare const NavigationMenuSub: import("svelte").Component<NavigationMenuSubProps, {}, "value" | "ref">;
type NavigationMenuSub = ReturnType<typeof NavigationMenuSub>;
export default NavigationMenuSub;
@@ -0,0 +1,47 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuTriggerProps } from "../types.js";
import { NavigationMenuTriggerState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
import VisuallyHidden from "../../utilities/visually-hidden/visually-hidden.svelte";
import Mounted from "../../utilities/mounted.svelte";
const uid = $props.id();
let {
id = createId(uid),
disabled = false,
children,
child,
ref = $bindable(null),
tabindex = 0,
...restProps
}: NavigationMenuTriggerProps = $props();
const triggerState = NavigationMenuTriggerState.create({
id: boxWith(() => id),
disabled: boxWith(() => disabled ?? false),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, triggerState.props, { tabindex }));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}
{#if triggerState.open}
<VisuallyHidden {...triggerState.focusProxyProps} />
<Mounted bind:mounted={triggerState.focusProxyMounted} />
{#if triggerState.context.viewportRef.current}
<span aria-owns={triggerState.itemContext.contentId ?? undefined}></span>
{/if}
{/if}
@@ -0,0 +1,4 @@
import type { NavigationMenuTriggerProps } from "../types.js";
declare const NavigationMenuTrigger: import("svelte").Component<NavigationMenuTriggerProps, {}, "ref">;
type NavigationMenuTrigger = ReturnType<typeof NavigationMenuTrigger>;
export default NavigationMenuTrigger;
@@ -0,0 +1,42 @@
<script lang="ts">
import type { NavigationMenuViewportProps } from "../types.js";
import { NavigationMenuViewportState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
import PresenceLayer from "../../utilities/presence-layer/presence-layer.svelte";
import { boxWith, mergeProps } from "svelte-toolbelt";
import { Mounted } from "../../utilities/index.js";
const uid = $props.id();
let {
id = createId(uid),
ref = $bindable(null),
forceMount = false,
child,
children,
...restProps
}: NavigationMenuViewportProps = $props();
const viewportState = NavigationMenuViewportState.create({
id: boxWith(() => id),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps(restProps, viewportState.props));
</script>
<PresenceLayer open={forceMount || viewportState.open} ref={viewportState.opts.ref}>
{#snippet presence()}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
<Mounted bind:mounted={viewportState.mounted} />
{/snippet}
</PresenceLayer>
@@ -0,0 +1,4 @@
import type { NavigationMenuViewportProps } from "../types.js";
declare const NavigationMenuViewport: import("svelte").Component<NavigationMenuViewportProps, {}, "ref">;
type NavigationMenuViewport = ReturnType<typeof NavigationMenuViewport>;
export default NavigationMenuViewport;
@@ -0,0 +1,52 @@
<script lang="ts">
import { boxWith, mergeProps } from "svelte-toolbelt";
import type { NavigationMenuRootProps } from "../types.js";
import { NavigationMenuRootState } from "../navigation-menu.svelte.js";
import { createId } from "../../../internal/create-id.js";
import { noop } from "../../../internal/noop.js";
const uid = $props.id();
let {
child,
children,
id = createId(uid),
ref = $bindable(null),
value = $bindable(""),
onValueChange = noop,
delayDuration = 200,
skipDelayDuration = 300,
dir = "ltr",
orientation = "horizontal",
...restProps
}: NavigationMenuRootProps = $props();
const rootState = NavigationMenuRootState.create({
id: boxWith(() => id),
value: boxWith(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
delayDuration: boxWith(() => delayDuration),
skipDelayDuration: boxWith(() => skipDelayDuration),
dir: boxWith(() => dir),
orientation: boxWith(() => orientation),
ref: boxWith(
() => ref,
(v) => (ref = v)
),
});
const mergedProps = $derived(mergeProps({ "aria-label": "main" }, restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<nav {...mergedProps}>
{@render children?.()}
</nav>
{/if}
@@ -0,0 +1,4 @@
import type { NavigationMenuRootProps } from "../types.js";
declare const NavigationMenu: import("svelte").Component<NavigationMenuRootProps, {}, "value" | "ref">;
type NavigationMenu = ReturnType<typeof NavigationMenu>;
export default NavigationMenu;
+10
View File
@@ -0,0 +1,10 @@
export { default as Root } from "./components/navigation-menu.svelte";
export { default as Content } from "./components/navigation-menu-content.svelte";
export { default as Indicator } from "./components/navigation-menu-indicator.svelte";
export { default as Item } from "./components/navigation-menu-item.svelte";
export { default as Link } from "./components/navigation-menu-link.svelte";
export { default as List } from "./components/navigation-menu-list.svelte";
export { default as Trigger } from "./components/navigation-menu-trigger.svelte";
export { default as Viewport } from "./components/navigation-menu-viewport.svelte";
export { default as Sub } from "./components/navigation-menu-sub.svelte";
export type { NavigationMenuRootProps as RootProps, NavigationMenuItemProps as ItemProps, NavigationMenuListProps as ListProps, NavigationMenuTriggerProps as TriggerProps, NavigationMenuViewportProps as ViewportProps, NavigationMenuIndicatorProps as IndicatorProps, NavigationMenuContentProps as ContentProps, NavigationMenuLinkProps as LinkProps, NavigationMenuSubProps as SubProps, } from "./types.js";
+9
View File
@@ -0,0 +1,9 @@
export { default as Root } from "./components/navigation-menu.svelte";
export { default as Content } from "./components/navigation-menu-content.svelte";
export { default as Indicator } from "./components/navigation-menu-indicator.svelte";
export { default as Item } from "./components/navigation-menu-item.svelte";
export { default as Link } from "./components/navigation-menu-link.svelte";
export { default as List } from "./components/navigation-menu-list.svelte";
export { default as Trigger } from "./components/navigation-menu-trigger.svelte";
export { default as Viewport } from "./components/navigation-menu-viewport.svelte";
export { default as Sub } from "./components/navigation-menu-sub.svelte";
+1
View File
@@ -0,0 +1 @@
export * as NavigationMenu from "./exports.js";
+1
View File
@@ -0,0 +1 @@
export * as NavigationMenu from "./exports.js";
@@ -0,0 +1,364 @@
/**
* Based on Radix UI's Navigation Menu
* https://www.radix-ui.com/docs/primitives/components/navigation-menu
*/
import { type AnyFn, type ReadableBox, type ReadableBoxedValues, type WithRefProps, type WritableBox, type WritableBoxedValues, DOMContext } from "svelte-toolbelt";
import { Context } from "runed";
import { type Snippet } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { type Direction, type Orientation } from "../../shared/index.js";
import type { BitsFocusEvent, BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, RefAttachment } from "../../internal/types.js";
import type { FocusEventHandler, KeyboardEventHandler, MouseEventHandler, PointerEventHandler } from "svelte/elements";
import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
export declare const NavigationMenuItemContext: Context<NavigationMenuItemState>;
interface NavigationMenuProviderStateOpts extends ReadableBoxedValues<{
dir: Direction;
orientation: Orientation;
}>, WritableBoxedValues<{
rootNavigationMenuRef: HTMLElement | null;
value: string;
previousValue: string;
}> {
isRootMenu: boolean;
onTriggerEnter: (itemValue: string, itemState: NavigationMenuItemState | null) => void;
onTriggerLeave?: () => void;
onContentEnter?: () => void;
onContentLeave?: () => void;
onItemSelect: (itemValue: string, itemState: NavigationMenuItemState | null) => void;
onItemDismiss: () => void;
}
declare class NavigationMenuProviderState {
static create(opts: NavigationMenuProviderStateOpts): NavigationMenuProviderState;
readonly opts: NavigationMenuProviderStateOpts;
indicatorTrackRef: WritableBox<HTMLElement | null>;
viewportRef: WritableBox<HTMLElement | null>;
viewportContent: SvelteMap<string, NavigationMenuItemState>;
onTriggerEnter: NavigationMenuProviderStateOpts["onTriggerEnter"];
onTriggerLeave: () => void;
onContentEnter: () => void;
onContentLeave: () => void;
onItemSelect: NavigationMenuProviderStateOpts["onItemSelect"];
onItemDismiss: NavigationMenuProviderStateOpts["onItemDismiss"];
activeItem: NavigationMenuItemState | null;
prevActiveItem: NavigationMenuItemState | null;
constructor(opts: NavigationMenuProviderStateOpts);
setActiveItem: (item: NavigationMenuItemState | null) => void;
}
interface NavigationMenuRootStateOpts extends WithRefProps, WritableBoxedValues<{
value: string;
}>, ReadableBoxedValues<{
dir: Direction;
orientation: Orientation;
delayDuration: number;
skipDelayDuration: number;
}> {
}
export declare class NavigationMenuRootState {
#private;
static create(opts: NavigationMenuRootStateOpts): NavigationMenuRootState;
readonly opts: NavigationMenuRootStateOpts;
readonly attachment: RefAttachment;
provider: NavigationMenuProviderState;
previousValue: WritableBox<string>;
isDelaySkipped: WritableBox<boolean>;
constructor(opts: NavigationMenuRootStateOpts);
setValue: (newValue: string, itemState: NavigationMenuItemState | null) => void;
readonly props: {
readonly id: string;
readonly "data-orientation": Orientation;
readonly dir: Direction;
};
}
interface NavigationMenuSubStateOpts extends WithRefProps, WritableBoxedValues<{
value: string;
}>, ReadableBoxedValues<{
orientation: Orientation;
}> {
}
export declare class NavigationMenuSubState {
static create(opts: NavigationMenuSubStateOpts): NavigationMenuSubState;
readonly opts: NavigationMenuSubStateOpts;
readonly context: NavigationMenuProviderState;
previousValue: WritableBox<string>;
readonly subProvider: NavigationMenuProviderState;
readonly attachment: RefAttachment;
constructor(opts: NavigationMenuSubStateOpts, context: NavigationMenuProviderState);
setValue: (newValue: string, itemState: NavigationMenuItemState | null) => void;
readonly props: {
readonly id: string;
readonly "data-orientation": Orientation;
};
}
interface NavigationMenuListStateOpts extends WithRefProps {
}
export declare class NavigationMenuListState {
static create(opts: NavigationMenuListStateOpts): NavigationMenuListState;
wrapperId: WritableBox<string>;
wrapperRef: WritableBox<HTMLElement | null>;
readonly opts: NavigationMenuListStateOpts;
readonly context: NavigationMenuProviderState;
readonly attachment: RefAttachment;
readonly wrapperAttachment: RefAttachment;
listTriggers: HTMLElement[];
readonly rovingFocusGroup: RovingFocusGroup;
wrapperMounted: boolean;
constructor(opts: NavigationMenuListStateOpts, context: NavigationMenuProviderState);
registerTrigger(trigger: HTMLElement | null): () => void;
readonly wrapperProps: {
readonly id: string;
};
readonly props: {
readonly id: string;
readonly "data-orientation": Orientation;
};
}
interface NavigationMenuItemStateOpts extends WithRefProps, ReadableBoxedValues<{
value: string;
openOnHover: boolean;
}> {
}
export declare class NavigationMenuItemState {
#private;
static create(opts: NavigationMenuItemStateOpts): NavigationMenuItemState;
readonly opts: NavigationMenuItemStateOpts;
readonly attachment: RefAttachment;
readonly listContext: NavigationMenuListState;
contentNode: HTMLElement | null;
triggerNode: HTMLElement | null;
focusProxyNode: HTMLElement | null;
restoreContentTabOrder: AnyFn;
wasEscapeClose: boolean;
readonly contentId: string | undefined;
readonly triggerId: string | undefined;
contentChildren: ReadableBox<Snippet | undefined>;
contentChild: ReadableBox<Snippet<[{
props: Record<string, unknown>;
}]> | undefined>;
contentProps: ReadableBox<Record<string, unknown>>;
domContext: DOMContext;
constructor(opts: NavigationMenuItemStateOpts, listContext: NavigationMenuListState);
onEntryKeydown: (side?: "start" | "end") => void;
onFocusProxyEnter: (side?: "start" | "end") => void;
onRootContentClose: () => void;
onContentFocusOutside: () => void;
props: {
readonly id: string;
};
}
interface NavigationMenuTriggerStateOpts extends WithRefProps, ReadableBoxedValues<{
disabled: boolean | null | undefined;
}> {
}
export declare class NavigationMenuTriggerState {
static create(opts: NavigationMenuTriggerStateOpts): NavigationMenuTriggerState;
readonly opts: NavigationMenuTriggerStateOpts;
readonly attachment: RefAttachment;
focusProxyId: WritableBox<string>;
focusProxyRef: WritableBox<HTMLElement | null>;
readonly focusProxyAttachment: RefAttachment;
context: NavigationMenuProviderState;
itemContext: NavigationMenuItemState;
listContext: NavigationMenuListState;
hasPointerMoveOpened: WritableBox<boolean>;
wasClickClose: boolean;
focusProxyMounted: boolean;
readonly open: boolean;
constructor(opts: NavigationMenuTriggerStateOpts, context: {
provider: NavigationMenuProviderState;
item: NavigationMenuItemState;
list: NavigationMenuListState;
sub: NavigationMenuSubState | null;
});
onpointerenter: (_: BitsPointerEvent<HTMLButtonElement>) => void;
onpointermove: PointerEventHandler<HTMLElement>;
onpointerleave: PointerEventHandler<HTMLElement>;
onclick: MouseEventHandler<HTMLButtonElement>;
onkeydown: KeyboardEventHandler<HTMLButtonElement>;
focusProxyOnFocus: FocusEventHandler<HTMLElement>;
readonly props: {
readonly id: string;
readonly disabled: boolean | null | undefined;
readonly "data-disabled": "" | undefined;
readonly "data-state": "open" | "closed";
readonly "data-value": string;
readonly "aria-expanded": "true" | "false";
readonly "aria-controls": string | undefined;
readonly onpointermove: PointerEventHandler<HTMLElement>;
readonly onpointerleave: PointerEventHandler<HTMLElement>;
readonly onpointerenter: (_: BitsPointerEvent<HTMLButtonElement>) => void;
readonly onclick: MouseEventHandler<HTMLButtonElement>;
readonly onkeydown: KeyboardEventHandler<HTMLButtonElement>;
};
readonly focusProxyProps: {
readonly id: string;
readonly tabindex: 0;
readonly onfocus: FocusEventHandler<HTMLElement>;
};
}
interface NavigationMenuLinkStateOpts extends WithRefProps, ReadableBoxedValues<{
active: boolean;
onSelect: (e: Event) => void;
}> {
}
export declare class NavigationMenuLinkState {
#private;
static create(opts: NavigationMenuLinkStateOpts): NavigationMenuLinkState;
readonly opts: NavigationMenuLinkStateOpts;
readonly context: {
provider: NavigationMenuProviderState;
item: NavigationMenuItemState;
};
readonly attachment: RefAttachment;
isFocused: boolean;
constructor(opts: NavigationMenuLinkStateOpts, context: {
provider: NavigationMenuProviderState;
item: NavigationMenuItemState;
});
onclick: (e: BitsMouseEvent<HTMLAnchorElement>) => void;
onkeydown: (e: BitsKeyboardEvent) => void;
onfocus: (_: BitsFocusEvent) => void;
onblur: (_: BitsFocusEvent) => void;
onpointerenter: PointerEventHandler<HTMLAnchorElement>;
onpointermove: PointerEventHandler<HTMLElement>;
readonly props: {
readonly id: string;
readonly "data-active": "" | undefined;
readonly "aria-current": "page" | undefined;
readonly "data-focused": "" | undefined;
readonly onclick: (e: BitsMouseEvent<HTMLAnchorElement>) => void;
readonly onkeydown: (e: BitsKeyboardEvent) => void;
readonly onfocus: (_: BitsFocusEvent) => void;
readonly onblur: (_: BitsFocusEvent) => void;
readonly onpointerenter: PointerEventHandler<HTMLAnchorElement>;
readonly onpointermove: PointerEventHandler<HTMLElement>;
};
}
interface NavigationMenuIndicatorStateOpts extends WithRefProps {
}
export declare class NavigationMenuIndicatorState {
static create(): NavigationMenuIndicatorState;
readonly context: NavigationMenuProviderState;
readonly isVisible: boolean;
constructor(context: NavigationMenuProviderState);
}
export declare class NavigationMenuIndicatorImplState {
static create(opts: NavigationMenuIndicatorStateOpts): NavigationMenuIndicatorImplState;
readonly opts: NavigationMenuIndicatorStateOpts;
readonly attachment: RefAttachment;
context: NavigationMenuProviderState;
listContext: NavigationMenuListState;
position: {
size: number;
offset: number;
} | null;
readonly isHorizontal: boolean;
readonly isVisible: boolean;
readonly activeTrigger: HTMLElement | null;
readonly shouldRender: boolean;
constructor(opts: NavigationMenuIndicatorStateOpts, context: {
provider: NavigationMenuProviderState;
list: NavigationMenuListState;
});
handlePositionChange: () => void;
readonly props: {
readonly id: string;
readonly "data-state": "hidden" | "visible";
readonly "data-orientation": Orientation;
readonly style: {
readonly left: number;
readonly width: string;
readonly transform: string;
readonly position: "absolute";
} | {
readonly top: number;
readonly height: string;
readonly transform: string;
readonly position: "absolute";
};
};
}
interface NavigationMenuContentStateOpts extends WithRefProps {
}
export declare class NavigationMenuContentState {
static create(opts: NavigationMenuContentStateOpts): NavigationMenuContentState;
readonly opts: NavigationMenuContentStateOpts;
readonly context: NavigationMenuProviderState;
readonly itemContext: NavigationMenuItemState;
readonly listContext: NavigationMenuListState;
readonly attachment: RefAttachment;
mounted: boolean;
readonly open: boolean;
readonly value: string;
readonly isLastActiveValue: boolean;
constructor(opts: NavigationMenuContentStateOpts, context: {
provider: NavigationMenuProviderState;
item: NavigationMenuItemState;
list: NavigationMenuListState;
});
onpointerenter: (_: BitsPointerEvent) => void;
onpointerleave: PointerEventHandler<HTMLElement>;
readonly props: {
readonly id: string;
readonly onpointerenter: (_: BitsPointerEvent) => void;
readonly onpointerleave: PointerEventHandler<HTMLElement>;
};
}
type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end";
interface NavigationMenuContentImplStateOpts extends WithRefProps {
}
export declare class NavigationMenuContentImplState {
static create(opts: NavigationMenuContentImplStateOpts, itemState?: NavigationMenuItemState): NavigationMenuContentImplState;
readonly opts: NavigationMenuContentImplStateOpts;
readonly itemContext: NavigationMenuItemState;
readonly context: NavigationMenuProviderState;
readonly listContext: NavigationMenuListState;
readonly attachment: RefAttachment;
prevMotionAttribute: MotionAttribute | null;
readonly motionAttribute: MotionAttribute | null;
domContext: DOMContext;
constructor(opts: NavigationMenuContentImplStateOpts, itemContext: NavigationMenuItemState);
onFocusOutside: (e: Event) => void;
onInteractOutside: (e: PointerEvent) => void;
onkeydown: (e: BitsKeyboardEvent) => void;
onEscapeKeydown: (_: KeyboardEvent) => void;
readonly props: {
readonly id: string;
readonly "aria-labelledby": string | undefined;
readonly "data-motion": MotionAttribute | undefined;
readonly "data-orientation": Orientation;
readonly "data-state": "open" | "closed";
readonly onkeydown: (e: BitsKeyboardEvent) => void;
};
}
interface NavigationMenuViewportStateOpts extends WithRefProps {
}
export declare class NavigationMenuViewportState {
static create(opts: NavigationMenuViewportStateOpts): NavigationMenuViewportState;
readonly opts: NavigationMenuViewportStateOpts;
readonly context: NavigationMenuProviderState;
readonly attachment: RefAttachment;
readonly open: boolean;
readonly viewportWidth: string | undefined;
readonly viewportHeight: string | undefined;
readonly activeContentValue: string;
size: {
width: number;
height: number;
} | null;
contentNode: HTMLElement | null;
mounted: boolean;
constructor(opts: NavigationMenuViewportStateOpts, context: NavigationMenuProviderState);
readonly props: {
readonly id: string;
readonly "data-state": "open" | "closed";
readonly "data-orientation": Orientation;
readonly style: {
readonly pointerEvents: "none" | undefined;
readonly "--bits-navigation-menu-viewport-width": string | undefined;
readonly "--bits-navigation-menu-viewport-height": string | undefined;
};
readonly onpointerenter: () => void;
readonly onpointerleave: () => void;
};
}
export {};
@@ -0,0 +1,888 @@
/**
* Based on Radix UI's Navigation Menu
* https://www.radix-ui.com/docs/primitives/components/navigation-menu
*/
import { afterSleep, afterTick, attachRef, DOMContext, getWindow, simpleBox, boxWith, } from "svelte-toolbelt";
import { Context, useDebounce, watch } from "runed";
import { untrack } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { useId } from "../../shared/index.js";
import { createBitsAttrs, boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, } from "../../internal/attrs.js";
import { noop } from "../../internal/noop.js";
import { getTabbableCandidates } from "../../internal/focus.js";
import { kbd } from "../../internal/kbd.js";
import { CustomEventDispatcher } from "../../internal/events.js";
import { useArrowNavigation } from "../../internal/use-arrow-navigation.js";
import { boxAutoReset } from "../../internal/box-auto-reset.svelte.js";
import { isElement } from "../../internal/is.js";
import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
import { SvelteResizeObserver } from "../../internal/svelte-resize-observer.svelte.js";
const navigationMenuAttrs = createBitsAttrs({
component: "navigation-menu",
parts: [
"root",
"sub",
"item",
"list",
"trigger",
"content",
"link",
"viewport",
"menu",
"indicator",
],
});
const NavigationMenuProviderContext = new Context("NavigationMenu.Root");
export const NavigationMenuItemContext = new Context("NavigationMenu.Item");
const NavigationMenuListContext = new Context("NavigationMenu.List");
const NavigationMenuContentContext = new Context("NavigationMenu.Content");
const NavigationMenuSubContext = new Context("NavigationMenu.Sub");
class NavigationMenuProviderState {
static create(opts) {
return NavigationMenuProviderContext.set(new NavigationMenuProviderState(opts));
}
opts;
indicatorTrackRef = simpleBox(null);
viewportRef = simpleBox(null);
viewportContent = new SvelteMap();
onTriggerEnter;
onTriggerLeave = noop;
onContentEnter = noop;
onContentLeave = noop;
onItemSelect;
onItemDismiss;
activeItem = null;
prevActiveItem = null;
constructor(opts) {
this.opts = opts;
this.onTriggerEnter = opts.onTriggerEnter;
this.onTriggerLeave = opts.onTriggerLeave ?? noop;
this.onContentEnter = opts.onContentEnter ?? noop;
this.onContentLeave = opts.onContentLeave ?? noop;
this.onItemDismiss = opts.onItemDismiss;
this.onItemSelect = opts.onItemSelect;
}
setActiveItem = (item) => {
this.prevActiveItem = this.activeItem;
this.activeItem = item;
};
}
export class NavigationMenuRootState {
static create(opts) {
return new NavigationMenuRootState(opts);
}
opts;
attachment;
provider;
previousValue = simpleBox("");
isDelaySkipped;
#derivedDelay = $derived.by(() => {
const isOpen = this.opts?.value?.current !== "";
if (isOpen || this.isDelaySkipped.current) {
// 150 for user to switch trigger or move into content view
return 100;
}
else {
return this.opts.delayDuration.current;
}
});
constructor(opts) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref);
this.isDelaySkipped = boxAutoReset(false, {
afterMs: this.opts.skipDelayDuration.current,
getWindow: () => getWindow(opts.ref.current),
});
this.provider = NavigationMenuProviderState.create({
value: this.opts.value,
previousValue: this.previousValue,
dir: this.opts.dir,
orientation: this.opts.orientation,
rootNavigationMenuRef: this.opts.ref,
isRootMenu: true,
onTriggerEnter: (itemValue, itemState) => {
this.#onTriggerEnter(itemValue, itemState);
},
onTriggerLeave: this.#onTriggerLeave,
onContentEnter: this.#onContentEnter,
onContentLeave: this.#onContentLeave,
onItemSelect: this.#onItemSelect,
onItemDismiss: this.#onItemDismiss,
});
}
#debouncedFn = useDebounce((val, itemState) => {
// passing `undefined` meant to reset the debounce timer
if (typeof val === "string") {
this.setValue(val, itemState);
}
}, () => this.#derivedDelay);
#onTriggerEnter = (itemValue, itemState) => {
this.#debouncedFn(itemValue, itemState);
};
#onTriggerLeave = () => {
this.isDelaySkipped.current = false;
this.#debouncedFn("", null);
};
#onContentEnter = () => {
this.#debouncedFn(undefined, null);
};
#onContentLeave = () => {
if (this.provider.activeItem &&
this.provider.activeItem.opts.openOnHover.current === false) {
return;
}
this.#debouncedFn("", null);
};
#onItemSelect = (itemValue, itemState) => {
this.setValue(itemValue, itemState);
};
#onItemDismiss = () => {
this.setValue("", null);
};
setValue = (newValue, itemState) => {
this.previousValue.current = this.opts.value.current;
this.opts.value.current = newValue;
this.provider.setActiveItem(itemState);
// When all menus are closed, we want to reset previousValue to prevent
// weird transitions from old positions when opening fresh
if (newValue === "") {
this.previousValue.current = "";
}
};
props = $derived.by(() => ({
id: this.opts.id.current,
"data-orientation": this.opts.orientation.current,
dir: this.opts.dir.current,
[navigationMenuAttrs.root]: "",
[navigationMenuAttrs.menu]: "",
...this.attachment,
}));
}
export class NavigationMenuSubState {
static create(opts) {
return new NavigationMenuSubState(opts, NavigationMenuProviderContext.get());
}
opts;
context;
previousValue = simpleBox("");
subProvider;
attachment;
constructor(opts, context) {
this.opts = opts;
this.context = context;
this.attachment = attachRef(this.opts.ref);
this.subProvider = NavigationMenuProviderState.create({
isRootMenu: false,
value: this.opts.value,
dir: this.context.opts.dir,
orientation: this.opts.orientation,
rootNavigationMenuRef: this.opts.ref,
onTriggerEnter: this.setValue,
onItemSelect: this.setValue,
onItemDismiss: () => this.setValue("", null),
previousValue: this.previousValue,
});
}
setValue = (newValue, itemState) => {
this.previousValue.current = this.opts.value.current;
this.opts.value.current = newValue;
this.subProvider.setActiveItem(itemState);
// When all menus are closed, we want to reset previousValue to prevent
// weird transitions from old positions when opening fresh
if (newValue === "") {
this.previousValue.current = "";
}
};
props = $derived.by(() => ({
id: this.opts.id.current,
"data-orientation": this.opts.orientation.current,
[navigationMenuAttrs.sub]: "",
[navigationMenuAttrs.menu]: "",
...this.attachment,
}));
}
export class NavigationMenuListState {
static create(opts) {
return NavigationMenuListContext.set(new NavigationMenuListState(opts, NavigationMenuProviderContext.get()));
}
wrapperId = simpleBox(useId());
wrapperRef = simpleBox(null);
opts;
context;
attachment;
wrapperAttachment = attachRef(this.wrapperRef, (v) => (this.context.indicatorTrackRef.current = v));
listTriggers = $state.raw([]);
rovingFocusGroup;
wrapperMounted = $state(false);
constructor(opts, context) {
this.opts = opts;
this.context = context;
this.attachment = attachRef(this.opts.ref);
this.rovingFocusGroup = new RovingFocusGroup({
rootNode: opts.ref,
candidateSelector: `${navigationMenuAttrs.selector("trigger")}:not([data-disabled]), ${navigationMenuAttrs.selector("link")}:not([data-disabled])`,
loop: boxWith(() => false),
orientation: this.context.opts.orientation,
});
}
registerTrigger(trigger) {
if (trigger)
this.listTriggers.push(trigger);
return () => {
this.listTriggers = this.listTriggers.filter((t) => t.id !== trigger.id);
};
}
wrapperProps = $derived.by(() => ({
id: this.wrapperId.current,
...this.wrapperAttachment,
}));
props = $derived.by(() => ({
id: this.opts.id.current,
"data-orientation": this.context.opts.orientation.current,
[navigationMenuAttrs.list]: "",
...this.attachment,
}));
}
export class NavigationMenuItemState {
static create(opts) {
return NavigationMenuItemContext.set(new NavigationMenuItemState(opts, NavigationMenuListContext.get()));
}
opts;
attachment;
listContext;
contentNode = $state(null);
triggerNode = $state(null);
focusProxyNode = $state(null);
restoreContentTabOrder = noop;
wasEscapeClose = false;
contentId = $derived.by(() => this.contentNode?.id);
triggerId = $derived.by(() => this.triggerNode?.id);
contentChildren = simpleBox(undefined);
contentChild = simpleBox(undefined);
contentProps = simpleBox({});
domContext;
constructor(opts, listContext) {
this.opts = opts;
this.listContext = listContext;
this.domContext = new DOMContext(opts.ref);
this.attachment = attachRef(this.opts.ref);
}
#handleContentEntry = (side = "start") => {
if (!this.contentNode)
return;
this.restoreContentTabOrder();
const candidates = getTabbableCandidates(this.contentNode);
if (candidates.length)
focusFirst(side === "start" ? candidates : candidates.reverse(), () => this.domContext.getActiveElement());
};
#handleContentExit = () => {
if (!this.contentNode)
return;
const candidates = getTabbableCandidates(this.contentNode);
if (candidates.length)
this.restoreContentTabOrder = removeFromTabOrder(candidates);
};
onEntryKeydown = this.#handleContentEntry;
onFocusProxyEnter = this.#handleContentEntry;
onRootContentClose = this.#handleContentExit;
onContentFocusOutside = this.#handleContentExit;
props = $derived.by(() => ({
id: this.opts.id.current,
[navigationMenuAttrs.item]: "",
...this.attachment,
}));
}
export class NavigationMenuTriggerState {
static create(opts) {
return new NavigationMenuTriggerState(opts, {
provider: NavigationMenuProviderContext.get(),
item: NavigationMenuItemContext.get(),
list: NavigationMenuListContext.get(),
sub: NavigationMenuSubContext.getOr(null),
});
}
opts;
attachment;
focusProxyId = simpleBox(useId());
focusProxyRef = simpleBox(null);
focusProxyAttachment = attachRef(this.focusProxyRef, (v) => (this.itemContext.focusProxyNode = v));
context;
itemContext;
listContext;
hasPointerMoveOpened = simpleBox(false);
wasClickClose = false;
focusProxyMounted = $state(false);
open = $derived.by(() => this.itemContext.opts.value.current === this.context.opts.value.current);
constructor(opts, context) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref, (v) => (this.itemContext.triggerNode = v));
this.hasPointerMoveOpened = boxAutoReset(false, {
afterMs: 300,
getWindow: () => getWindow(opts.ref.current),
});
this.context = context.provider;
this.itemContext = context.item;
this.listContext = context.list;
watch(() => this.opts.ref.current, () => {
const node = this.opts.ref.current;
if (!node)
return;
return this.listContext.registerTrigger(node);
});
}
onpointerenter = (_) => {
this.wasClickClose = false;
this.itemContext.wasEscapeClose = false;
};
onpointermove = whenMouse(() => {
if (this.opts.disabled.current ||
this.wasClickClose ||
this.itemContext.wasEscapeClose ||
this.hasPointerMoveOpened.current ||
!this.itemContext.opts.openOnHover.current) {
return;
}
this.context.onTriggerEnter(this.itemContext.opts.value.current, this.itemContext);
this.hasPointerMoveOpened.current = true;
});
onpointerleave = whenMouse(() => {
if (this.opts.disabled.current || !this.itemContext.opts.openOnHover.current)
return;
this.context.onTriggerLeave();
this.hasPointerMoveOpened.current = false;
});
onclick = () => {
// if opened via pointer move, we prevent the click event
if (this.hasPointerMoveOpened.current)
return;
const shouldClose = this.open &&
(!this.itemContext.opts.openOnHover.current || this.context.opts.isRootMenu);
if (shouldClose) {
this.context.onItemSelect("", null);
}
else if (!this.open) {
this.context.onItemSelect(this.itemContext.opts.value.current, this.itemContext);
}
this.wasClickClose = shouldClose;
};
onkeydown = (e) => {
const verticalEntryKey = this.context.opts.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT;
const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[this.context.opts.orientation.current];
if (this.open && e.key === entryKey) {
this.itemContext.onEntryKeydown();
// prevent focus group from handling the event
e.preventDefault();
return;
}
this.itemContext.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e);
};
focusProxyOnFocus = (e) => {
const content = this.itemContext.contentNode;
const prevFocusedElement = e.relatedTarget;
const wasTriggerFocused = this.opts.ref.current && prevFocusedElement === this.opts.ref.current;
const wasFocusFromContent = content?.contains(prevFocusedElement);
if (wasTriggerFocused || !wasFocusFromContent) {
this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end");
}
};
props = $derived.by(() => ({
id: this.opts.id.current,
disabled: this.opts.disabled.current,
"data-disabled": boolToEmptyStrOrUndef(Boolean(this.opts.disabled.current)),
"data-state": getDataOpenClosed(this.open),
"data-value": this.itemContext.opts.value.current,
"aria-expanded": boolToStr(this.open),
"aria-controls": this.itemContext.contentId,
[navigationMenuAttrs.trigger]: "",
onpointermove: this.onpointermove,
onpointerleave: this.onpointerleave,
onpointerenter: this.onpointerenter,
onclick: this.onclick,
onkeydown: this.onkeydown,
...this.attachment,
}));
focusProxyProps = $derived.by(() => ({
id: this.focusProxyId.current,
tabindex: 0,
onfocus: this.focusProxyOnFocus,
...this.focusProxyAttachment,
}));
}
const LINK_SELECT_EVENT = new CustomEventDispatcher("bitsLinkSelect", {
bubbles: true,
cancelable: true,
});
const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDismiss", {
cancelable: true,
bubbles: true,
});
export class NavigationMenuLinkState {
static create(opts) {
return new NavigationMenuLinkState(opts, {
provider: NavigationMenuProviderContext.get(),
item: NavigationMenuItemContext.get(),
});
}
opts;
context;
attachment;
isFocused = $state(false);
constructor(opts, context) {
this.opts = opts;
this.context = context;
this.attachment = attachRef(this.opts.ref);
}
onclick = (e) => {
const currTarget = e.currentTarget;
LINK_SELECT_EVENT.listen(currTarget, (e) => this.opts.onSelect.current(e), { once: true });
const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget);
if (!linkSelectEvent.defaultPrevented && !e.metaKey) {
ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget);
}
};
onkeydown = (e) => {
if (this.context.item.contentNode)
return;
this.context.item.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e);
};
onfocus = (_) => {
this.isFocused = true;
};
onblur = (_) => {
this.isFocused = false;
};
#handlePointerDismiss = () => {
// only close submenu if this link is not inside the currently open submenu content
const currentlyOpenValue = this.context.provider.opts.value.current;
const isInsideOpenSubmenu = this.context.item.opts.value.current === currentlyOpenValue;
const activeItem = this.context.item.listContext.context.activeItem;
if (activeItem && !activeItem.opts.openOnHover.current)
return;
if (currentlyOpenValue && !isInsideOpenSubmenu) {
this.context.provider.onItemDismiss();
}
};
onpointerenter = () => {
this.#handlePointerDismiss();
};
onpointermove = whenMouse(() => {
this.#handlePointerDismiss();
});
props = $derived.by(() => ({
id: this.opts.id.current,
"data-active": this.opts.active.current ? "" : undefined,
"aria-current": this.opts.active.current ? "page" : undefined,
"data-focused": this.isFocused ? "" : undefined,
onclick: this.onclick,
onkeydown: this.onkeydown,
onfocus: this.onfocus,
onblur: this.onblur,
onpointerenter: this.onpointerenter,
onpointermove: this.onpointermove,
[navigationMenuAttrs.link]: "",
...this.attachment,
}));
}
export class NavigationMenuIndicatorState {
static create() {
return new NavigationMenuIndicatorState(NavigationMenuProviderContext.get());
}
context;
isVisible = $derived.by(() => Boolean(this.context.opts.value.current));
constructor(context) {
this.context = context;
}
}
export class NavigationMenuIndicatorImplState {
static create(opts) {
return new NavigationMenuIndicatorImplState(opts, {
provider: NavigationMenuProviderContext.get(),
list: NavigationMenuListContext.get(),
});
}
opts;
attachment;
context;
listContext;
position = $state.raw(null);
isHorizontal = $derived.by(() => this.context.opts.orientation.current === "horizontal");
isVisible = $derived.by(() => !!this.context.opts.value.current);
activeTrigger = $derived.by(() => {
const items = this.listContext.listTriggers;
const triggerNode = items.find((item) => item.getAttribute("data-value") === this.context.opts.value.current);
return triggerNode ?? null;
});
shouldRender = $derived.by(() => this.position !== null);
constructor(opts, context) {
this.opts = opts;
this.context = context.provider;
this.listContext = context.list;
this.attachment = attachRef(this.opts.ref);
new SvelteResizeObserver(() => this.activeTrigger, this.handlePositionChange);
new SvelteResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange);
}
handlePositionChange = () => {
if (!this.activeTrigger)
return;
this.position = {
size: this.isHorizontal
? this.activeTrigger.offsetWidth
: this.activeTrigger.offsetHeight,
offset: this.isHorizontal
? this.activeTrigger.offsetLeft
: this.activeTrigger.offsetTop,
};
};
props = $derived.by(() => ({
id: this.opts.id.current,
"data-state": this.isVisible ? "visible" : "hidden",
"data-orientation": this.context.opts.orientation.current,
style: {
position: "absolute",
...(this.isHorizontal
? {
left: 0,
width: `${this.position?.size}px`,
transform: `translateX(${this.position?.offset}px)`,
}
: {
top: 0,
height: `${this.position?.size}px`,
transform: `translateY(${this.position?.offset}px)`,
}),
},
[navigationMenuAttrs.indicator]: "",
...this.attachment,
}));
}
export class NavigationMenuContentState {
static create(opts) {
return NavigationMenuContentContext.set(new NavigationMenuContentState(opts, {
provider: NavigationMenuProviderContext.get(),
item: NavigationMenuItemContext.get(),
list: NavigationMenuListContext.get(),
}));
}
opts;
context;
itemContext;
listContext;
attachment;
mounted = $state(false);
open = $derived.by(() => this.itemContext.opts.value.current === this.context.opts.value.current);
value = $derived.by(() => this.itemContext.opts.value.current);
// We persist the last active content value as the viewport may be animating out
// and we want the content to remain mounted for the lifecycle of the viewport.
isLastActiveValue = $derived.by(() => {
if (this.context.viewportRef.current) {
if (!this.context.opts.value.current && this.context.opts.previousValue.current) {
return (this.context.opts.previousValue.current === this.itemContext.opts.value.current);
}
}
return false;
});
constructor(opts, context) {
this.opts = opts;
this.context = context.provider;
this.itemContext = context.item;
this.listContext = context.list;
this.attachment = attachRef(this.opts.ref, (v) => (this.itemContext.contentNode = v));
}
onpointerenter = (_) => {
this.context.onContentEnter();
};
onpointerleave = whenMouse(() => {
if (!this.itemContext.opts.openOnHover.current)
return;
this.context.onContentLeave();
});
props = $derived.by(() => ({
id: this.opts.id.current,
onpointerenter: this.onpointerenter,
onpointerleave: this.onpointerleave,
...this.attachment,
}));
}
export class NavigationMenuContentImplState {
static create(opts, itemState) {
return new NavigationMenuContentImplState(opts, itemState ?? NavigationMenuItemContext.get());
}
opts;
itemContext;
context;
listContext;
attachment;
prevMotionAttribute = $state(null);
motionAttribute = $derived.by(() => {
const items = this.listContext.listTriggers;
const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean);
if (this.context.opts.dir.current === "rtl")
values.reverse();
const index = values.indexOf(this.context.opts.value.current);
const prevIndex = values.indexOf(this.context.opts.previousValue.current);
const isSelected = this.itemContext.opts.value.current === this.context.opts.value.current;
const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.value.current);
// When all menus are closed, we want to reset motion state to prevent residual animations
if (!this.context.opts.value.current && !this.context.opts.previousValue.current) {
untrack(() => (this.prevMotionAttribute = null));
return null;
}
// We only want to update selected and the last selected content
// this avoids animations being interrupted outside of that range
if (!isSelected && !wasSelected)
return untrack(() => this.prevMotionAttribute);
const attribute = (() => {
// Don't provide a direction on the initial open
if (index !== prevIndex) {
// If we're moving to this item from another
if (isSelected && prevIndex !== -1)
return index > prevIndex ? "from-end" : "from-start";
// If we're leaving this item for another
if (wasSelected && index !== -1)
return index > prevIndex ? "to-start" : "to-end";
}
// Otherwise we're entering from closed or leaving the list
// entirely and should not animate in any direction
return null;
})();
untrack(() => (this.prevMotionAttribute = attribute));
return attribute;
});
domContext;
constructor(opts, itemContext) {
this.opts = opts;
this.attachment = attachRef(this.opts.ref);
this.itemContext = itemContext;
this.listContext = itemContext.listContext;
this.context = itemContext.listContext.context;
this.domContext = new DOMContext(opts.ref);
watch([
() => this.itemContext.opts.value.current,
() => this.itemContext.triggerNode,
() => this.opts.ref.current,
], () => {
const content = this.opts.ref.current;
if (!(content && this.context.opts.isRootMenu))
return;
const handleClose = () => {
this.context.onItemDismiss();
this.itemContext.onRootContentClose();
if (content.contains(this.domContext.getActiveElement())) {
this.itemContext.triggerNode?.focus();
}
};
const removeListener = ROOT_CONTENT_DISMISS_EVENT.listen(content, handleClose);
return () => {
removeListener();
};
});
}
onFocusOutside = (e) => {
this.itemContext.onContentFocusOutside();
const target = e.target;
// only dismiss content when focus moves outside of the menu
if (this.context.opts.rootNavigationMenuRef.current?.contains(target)) {
e.preventDefault();
return;
}
this.context.onItemDismiss();
};
onInteractOutside = (e) => {
const target = e.target;
const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target));
const isRootViewport = this.context.opts.isRootMenu && this.context.viewportRef.current?.contains(target);
if (!this.context.opts.isRootMenu && !isTrigger) {
this.context.onItemDismiss();
return;
}
if (isTrigger || isRootViewport) {
e.preventDefault();
return;
}
if (!this.itemContext.opts.openOnHover.current) {
this.context.onItemSelect("", null);
}
};
onkeydown = (e) => {
// prevent parent menus handling sub-menu keydown events
const target = e.target;
if (!isElement(target))
return;
if (target.closest(navigationMenuAttrs.selector("menu")) !==
this.context.opts.rootNavigationMenuRef.current)
return;
const isMetaKey = e.altKey || e.ctrlKey || e.metaKey;
const isTabKey = e.key === kbd.TAB && !isMetaKey;
const candidates = getTabbableCandidates(e.currentTarget);
if (isTabKey) {
const focusedElement = this.domContext.getActiveElement();
const index = candidates.findIndex((candidate) => candidate === focusedElement);
const isMovingBackwards = e.shiftKey;
const nextCandidates = isMovingBackwards
? candidates.slice(0, index).reverse()
: candidates.slice(index + 1, candidates.length);
if (focusFirst(nextCandidates, () => this.domContext.getActiveElement())) {
// prevent browser tab keydown because we've handled focus
e.preventDefault();
return;
}
else {
// If we can't focus that means we're at the edges
// so focus the proxy and let browser handle
// tab/shift+tab keypress on the proxy instead
handleProxyFocus(this.itemContext.focusProxyNode);
return;
}
}
let activeEl = this.domContext.getActiveElement();
if (this.itemContext.contentNode) {
const focusedNode = this.itemContext.contentNode.querySelector("[data-focused]");
if (focusedNode) {
activeEl = focusedNode;
}
}
if (activeEl === this.itemContext.triggerNode)
return;
const newSelectedElement = useArrowNavigation(e, activeEl, undefined, {
itemsArray: candidates,
candidateSelector: navigationMenuAttrs.selector("link"),
loop: false,
enableIgnoredElement: true,
});
newSelectedElement?.focus();
};
onEscapeKeydown = (_) => {
this.context.onItemDismiss();
this.itemContext.triggerNode?.focus();
// prevent the dropdown from reopening after the escape key has been pressed
this.itemContext.wasEscapeClose = true;
};
props = $derived.by(() => ({
id: this.opts.id.current,
"aria-labelledby": this.itemContext.triggerId,
"data-motion": this.motionAttribute ?? undefined,
"data-orientation": this.context.opts.orientation.current,
"data-state": getDataOpenClosed(this.context.opts.value.current === this.itemContext.opts.value.current),
onkeydown: this.onkeydown,
[navigationMenuAttrs.content]: "",
...this.attachment,
}));
}
export class NavigationMenuViewportState {
static create(opts) {
return new NavigationMenuViewportState(opts, NavigationMenuProviderContext.get());
}
opts;
context;
attachment;
open = $derived.by(() => !!this.context.opts.value.current);
viewportWidth = $derived.by(() => (this.size ? `${this.size.width}px` : undefined));
viewportHeight = $derived.by(() => (this.size ? `${this.size.height}px` : undefined));
activeContentValue = $derived.by(() => this.context.opts.value.current);
size = $state(null);
contentNode = $state(null);
mounted = $state(false);
constructor(opts, context) {
this.opts = opts;
this.context = context;
this.attachment = attachRef(this.opts.ref, (v) => (this.context.viewportRef.current = v));
watch([() => this.activeContentValue, () => this.open], () => {
afterTick(() => {
const currNode = this.context.viewportRef.current;
if (!currNode)
return;
const el = currNode.querySelector("[data-state=open]")
?.children?.[0] ?? null;
this.contentNode = el;
});
});
/**
* Update viewport size to match the active content node.
* We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform.
* For example, if content animates in from `scale(0.5)` the dimensions would be anything
* from `0.5` to `1` of the intended size.
*/
new SvelteResizeObserver(() => this.contentNode, () => {
if (this.contentNode) {
this.size = {
width: this.contentNode.offsetWidth,
height: this.contentNode.offsetHeight,
};
}
});
// reset size when viewport closes to prevent residual size animations
watch(() => this.mounted, () => {
if (!this.mounted && this.size) {
this.size = null;
}
});
}
props = $derived.by(() => ({
id: this.opts.id.current,
"data-state": getDataOpenClosed(this.open),
"data-orientation": this.context.opts.orientation.current,
style: {
pointerEvents: !this.open && this.context.opts.isRootMenu ? "none" : undefined,
"--bits-navigation-menu-viewport-width": this.viewportWidth,
"--bits-navigation-menu-viewport-height": this.viewportHeight,
},
[navigationMenuAttrs.viewport]: "",
onpointerenter: this.context.onContentEnter,
onpointerleave: this.context.onContentLeave,
...this.attachment,
}));
}
//
function focusFirst(candidates, getActiveElement) {
const previouslyFocusedElement = getActiveElement();
return candidates.some((candidate) => {
// if focus is already where we want to go, we don't want to keep going through the candidates
if (candidate === previouslyFocusedElement)
return true;
candidate.focus();
return getActiveElement() !== previouslyFocusedElement;
});
}
function removeFromTabOrder(candidates) {
candidates.forEach((candidate) => {
candidate.dataset.tabindex = candidate.getAttribute("tabindex") || "";
candidate.setAttribute("tabindex", "-1");
});
return () => {
candidates.forEach((candidate) => {
const prevTabIndex = candidate.dataset.tabindex;
candidate.setAttribute("tabindex", prevTabIndex);
});
};
}
function whenMouse(handler) {
return (e) => (e.pointerType === "mouse" ? handler(e) : undefined);
}
/**
*
* We apply the `aria-hidden` attribute to elements that should not be visible to screen readers
* under specific circumstances, mostly when in a "modal" context or when they are strictly for
* utility purposes, like the focus guards.
*
* When these elements receive focus before we can remove the aria-hidden attribute, we need to
* handle the focus in a way that does not cause an error to be logged.
*
* This function handles the focus of the guard element first by momentary removing the
* `aria-hidden` attribute, focusing the guard (which will cause something else to focus), and then
* restoring the attribute.
*/
function handleProxyFocus(guard, focusOptions) {
if (!guard)
return;
const ariaHidden = guard.getAttribute("aria-hidden");
guard.removeAttribute("aria-hidden");
guard.focus(focusOptions);
afterSleep(0, () => {
if (ariaHidden === null) {
guard.setAttribute("aria-hidden", "");
}
else {
guard.setAttribute("aria-hidden", ariaHidden);
}
});
}
+156
View File
@@ -0,0 +1,156 @@
import type { EscapeBehaviorType } from "../utilities/escape-layer/types.js";
import type { InteractOutsideBehaviorType } from "../utilities/dismissible-layer/types.js";
import type { OnChangeFn, WithChild, WithChildNoChildrenSnippetProps, Without } from "../../internal/types.js";
import type { BitsPrimitiveAnchorAttributes, BitsPrimitiveButtonAttributes, BitsPrimitiveDivAttributes, BitsPrimitiveElementAttributes, BitsPrimitiveLiAttributes, BitsPrimitiveUListAttributes } from "../../shared/attributes.js";
import type { Direction, Orientation } from "../../shared/index.js";
export type NavigationMenuRootPropsWithoutHTML = WithChild<{
/**
* The value of the currently open menu item.
*
* @bindable
*/
value?: string;
/**
* The callback to call when a menu item is selected.
*/
onValueChange?: OnChangeFn<string>;
/**
* The amount of time in ms from when the mouse enters a trigger until the content opens.
*
* @default 200
*/
delayDuration?: number;
/**
* The amount of time in ms that a user has to enter another trigger without
* incurring a delay again.
*
* @default 300
*/
skipDelayDuration?: number;
/**
* The reading direction of the content.
*
* @default "ltr"
*/
dir?: Direction;
/**
* The orientation of the menu.
*/
orientation?: Orientation;
}>;
export type NavigationMenuRootProps = NavigationMenuRootPropsWithoutHTML & Without<BitsPrimitiveElementAttributes, NavigationMenuRootPropsWithoutHTML>;
export type NavigationMenuSubPropsWithoutHTML = WithChild<{
/**
* The value of the currently open menu item within the menu.
*
* @bindable
*/
value?: string;
/**
* A callback fired when the active menu item changes.
*/
onValueChange?: OnChangeFn<string>;
/**
* The orientation of the menu.
*/
orientation?: Orientation;
}>;
export type NavigationMenuSubProps = NavigationMenuSubPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, NavigationMenuSubPropsWithoutHTML>;
export type NavigationMenuListPropsWithoutHTML = WithChildNoChildrenSnippetProps<{}, {
/**
* Attributes to spread onto a wrapper element around the content.
* Do not style the wrapper element, its styles are computed by Floating UI.
*/
wrapperProps: Record<string, unknown>;
}>;
export type NavigationMenuListProps = NavigationMenuListPropsWithoutHTML & Without<BitsPrimitiveUListAttributes, NavigationMenuListPropsWithoutHTML>;
export type NavigationMenuItemPropsWithoutHTML = WithChild<{
/**
* The value of the menu item.
*/
value?: string;
/**
* Whether to open the menu associated with the item when the item's trigger
* is hovered.
*
* @default true
*/
openOnHover?: boolean;
}>;
export type NavigationMenuItemProps = NavigationMenuItemPropsWithoutHTML & Without<BitsPrimitiveLiAttributes, NavigationMenuItemPropsWithoutHTML>;
export type NavigationMenuTriggerPropsWithoutHTML = WithChild<{
/**
* Whether the trigger is disabled.
* @defaultValue false
*/
disabled?: boolean | null | undefined;
}>;
export type NavigationMenuTriggerProps = NavigationMenuTriggerPropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, NavigationMenuTriggerPropsWithoutHTML>;
export type NavigationMenuContentPropsWithoutHTML = WithChild<{
/**
* Callback fired when an interaction occurs outside the content.
* Default behavior can be prevented with `event.preventDefault()`
*/
onInteractOutside?: (event: PointerEvent) => void;
/**
* Callback fired when a focus event occurs outside the content.
* Default behavior can be prevented with `event.preventDefault()`
*/
onFocusOutside?: (event: FocusEvent) => void;
/**
* Callback fires when an escape keydown event occurs.
* Default behavior can be prevented with `event.preventDefault()`
*/
onEscapeKeydown?: (event: KeyboardEvent) => void;
/**
* Behavior when the escape key is pressed while the menu content is open.
*/
escapeKeydownBehavior?: EscapeBehaviorType;
/**
* Behavior when an interaction occurs outside the content.
*/
interactOutsideBehavior?: InteractOutsideBehaviorType;
/**
* Whether to forcefully mount the content, regardless of the open state.
* This is useful when wanting to use more custom transition and animation
* libraries.
*
* @default false
*/
forceMount?: boolean;
}>;
export type NavigationMenuContentProps = NavigationMenuContentPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, NavigationMenuContentPropsWithoutHTML>;
export type NavigationMenuLinkPropsWithoutHTML = WithChild<{
/**
* Whether the link is the current active page
*/
active?: boolean;
/**
* A callback fired when the link is clicked.
* Default behavior can be prevented with `event.preventDefault()`
*/
onSelect?: (e: Event) => void;
}>;
export type NavigationMenuLinkProps = NavigationMenuLinkPropsWithoutHTML & Without<BitsPrimitiveAnchorAttributes, NavigationMenuLinkPropsWithoutHTML>;
export type NavigationMenuIndicatorPropsWithoutHTML = WithChild<{
/**
* Whether to forcefully mount the content, regardless of the open state.
* This is useful when wanting to use more custom transition and animation
* libraries.
*
* @defaultValue false
*/
forceMount?: boolean;
}>;
export type NavigationMenuIndicatorProps = NavigationMenuIndicatorPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, NavigationMenuIndicatorPropsWithoutHTML>;
export type NavigationMenuViewportPropsWithoutHTML = WithChild<{
/**
* Whether to forcefully mount the content, regardless of the open state.
* This is useful when wanting to use more custom transition and animation
* libraries.
*
* @defaultValue false
*/
forceMount?: boolean;
}>;
export type NavigationMenuViewportProps = NavigationMenuViewportPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, NavigationMenuViewportPropsWithoutHTML>;
+1
View File
@@ -0,0 +1 @@
export {};