diff --git a/eslint.config.js b/eslint.config.js index 98b16da45..de0a168fc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,9 +2,8 @@ import config, { DEFAULT_IGNORES } from "@huntabyte/eslint-config"; const ignores = ["**/extended-types"]; -export default config({ svelte: true, ignores: [...DEFAULT_IGNORES, ...ignores] }).override( - "antfu/typescript/rules", - { +export default config({ svelte: true, ignores: [...DEFAULT_IGNORES, ...ignores] }) + .override("antfu/typescript/rules", { rules: { "ts/consistent-type-definitions": "off", "ts/ban-types": [ @@ -16,5 +15,9 @@ export default config({ svelte: true, ignores: [...DEFAULT_IGNORES, ...ignores] }, ], }, - } -); + }) + .override("antfu/js/rules", { + rules: { + "no-unused-expressions": "off", + }, + }); diff --git a/package.json b/package.json index 73fc05a59..0ec25dc1d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prettier": "^3.2.5", "prettier-plugin-svelte": "^3.2.2", "prettier-plugin-tailwindcss": "0.5.13", - "svelte": "5.0.0-next.143", + "svelte": "5.0.0-next.155", "svelte-eslint-parser": "^0.34.1", "wrangler": "^3.44.0" }, diff --git a/packages/bits-ui/package.json b/packages/bits-ui/package.json index 96313d71d..16aca21a5 100644 --- a/packages/bits-ui/package.json +++ b/packages/bits-ui/package.json @@ -45,7 +45,7 @@ "jsdom": "^24.0.0", "publint": "^0.2.7", "resize-observer-polyfill": "^1.5.1", - "svelte": "5.0.0-next.143", + "svelte": "5.0.0-next.155", "svelte-check": "^3.6.9", "tslib": "^2.6.2", "typescript": "^5.3.3", @@ -63,7 +63,7 @@ "clsx": "^2.1.0", "esm-env": "^1.0.0", "nanoid": "^5.0.5", - "runed": "^0.5.0", + "runed": "^0.12.1", "scule": "^1.3.0", "style-object-to-css-string": "^1.1.3", "style-to-object": "^1.0.6", diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index 17d09640a..2905b49d7 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -19,6 +19,7 @@ export * as DropdownMenu from "./dropdown-menu/index.js"; export * as Label from "./label/index.js"; export * as LinkPreview from "./link-preview/index.js"; export * as Menubar from "./menubar/index.js"; +export * as NavigationMenu from "./navigation-menu/index.js"; export * as Pagination from "./pagination/index.js"; export * as PinInput from "./pin-input/index.js"; export * as Popover from "./popover/index.js"; 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 new file mode 100644 index 000000000..78aabada4 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -0,0 +1,69 @@ + + + + + {#snippet presence({ present })} + contentState.onEscapeKeydown(e)} + > + + {#snippet children({ props: dismissableProps })} + {#if asChild} + + {@render child?.({ props: mergeProps(dismissableProps, mergedProps) })} + {:else} + +
+ {@render contentChildren?.()} +
+ {/if} + {/snippet} +
+
+ {/snippet} +
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte new file mode 100644 index 000000000..2f44da5cf --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte @@ -0,0 +1,45 @@ + + +{#if indicatorState.menu.indicatorTrackNode} + + + {#snippet presence()} + {#if asChild} + {@render child?.({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} + {/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 new file mode 100644 index 000000000..7731d29ba --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte @@ -0,0 +1,32 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} +
  • + {@render children?.()} +
  • +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte new file mode 100644 index 000000000..9aef7cf81 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte @@ -0,0 +1,35 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte new file mode 100644 index 000000000..c002aa01d --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte @@ -0,0 +1,38 @@ + + +
    + {#if asChild} + {@render child?.({ props: mergedProps })} + {:else} + + {/if} +
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-sub.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-sub.svelte new file mode 100644 index 000000000..283374245 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-sub.svelte @@ -0,0 +1,48 @@ + diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte new file mode 100644 index 000000000..1e195e1a6 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte @@ -0,0 +1,49 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} + +{/if} + +{#if triggerState.open} + + + {#if triggerState.menu.viewportNode} + + {/if} +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte new file mode 100644 index 000000000..68cbd9add --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte @@ -0,0 +1,40 @@ + + + + {#snippet presence({ present })} + {#if asChild} + {@render child?.({ props: mergedProps })} + {:else} +
    + {@render children?.()} +
    + {/if} + {/snippet} +
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu.svelte new file mode 100644 index 000000000..a468a6217 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu.svelte @@ -0,0 +1,54 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/index.ts b/packages/bits-ui/src/lib/bits/navigation-menu/index.ts new file mode 100644 index 000000000..edfa842a0 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/index.ts @@ -0,0 +1,19 @@ +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 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, +} from "./types.js"; 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 new file mode 100644 index 000000000..c200e72dd --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -0,0 +1,1125 @@ +import { untrack } from "svelte"; +import { box } from "svelte-toolbelt"; +import { Previous } from "runed"; +import { + watch, + type ReadableBoxedValues, + type WritableBoxedValues, +} from "$lib/internal/box.svelte.js"; +import type { Direction, Orientation } from "$lib/shared/index.js"; +import { + getAriaExpanded, + getAriaHidden, + getDataDisabled, + getDataOpenClosed, + getDataOrientation, + getDisabledAttr, +} from "$lib/internal/attrs.js"; +import { createContext } from "$lib/internal/createContext.js"; +import { useId } from "$lib/internal/useId.svelte.js"; +import { kbd } from "$lib/internal/kbd.js"; +import { useArrowNavigation } from "$lib/internal/useArrowNavigation.js"; +import { boxAutoReset } from "$lib/internal/boxAutoReset.svelte.js"; +import { useRefById } from "$lib/internal/useNodeById.svelte.js"; +import type { ElementRef } from "$lib/internal/types.js"; +import { afterTick } from "$lib/internal/afterTick.js"; +import { getTabbableCandidates } from "../utilities/focus-scope/utils.js"; +import { noop } from "$lib/internal/callbacks.js"; + +const [setNavigationMenuRootContext, getNavigationMenuRootContext] = + createContext("NavigationMenu.Root"); + +const [setNavigationMenuMenuContext, getNavigationMenuMenuContext] = createContext< + NavigationMenuMenuState | NavigationMenuSubState +>("NavigationMenu.Root or NavigationMenu.Sub"); + +const [setNavigationMenuItemContext, getNavigationMenuItemContext] = + createContext("NavigationMenu.Item"); + +const ROOT_ATTR = "data-navigation-menu-root"; +const SUB_ATTR = "data-navigation-menu-sub"; +const ITEM_ATTR = "data-navigation-menu-item"; +const INDICATOR_ATTR = "data-navigation-menu-indicator"; +const LIST_ATTR = "data-navigation-menu-list"; +const TRIGGER_ATTR = "data-navigation-menu-trigger"; +const CONTENT_ATTR = "data-navigation-menu-content"; +const LINK_ATTR = "data-navigation-menu-link"; + +type NavigationMenuRootStateProps = ReadableBoxedValues<{ + id: string; + delayDuration: number; + skipDelayDuration: number; + orientation: Orientation; + dir: Direction; +}> & + WritableBoxedValues<{ value: string; ref: HTMLElement | null }>; + +class NavigationMenuRootState { + id: NavigationMenuRootStateProps["id"]; + rootRef: NavigationMenuRootStateProps["ref"]; + delayDuration: NavigationMenuRootStateProps["delayDuration"]; + skipDelayDuration: NavigationMenuRootStateProps["skipDelayDuration"]; + orientation: NavigationMenuRootStateProps["orientation"]; + dir: NavigationMenuRootStateProps["dir"]; + value: NavigationMenuRootStateProps["value"]; + previousValue = new Previous(() => this.value.value); + openTimer = 0; + closeTimer = 0; + skipDelayTimer = 0; + isOpenDelayed = $state(false); + + setValue = (v: string) => { + this.value.value = v; + }; + + constructor(props: NavigationMenuRootStateProps) { + this.id = props.id; + this.delayDuration = props.delayDuration; + this.skipDelayDuration = props.skipDelayDuration; + this.orientation = props.orientation; + this.dir = props.dir; + this.value = props.value; + this.rootRef = props.ref; + + useRefById({ + id: this.id, + ref: this.rootRef, + }); + + watch(this.value, (curr) => { + const isOpen = curr !== ""; + const hasSkipDelayDuration = this.skipDelayDuration.value > 0; + + if (isOpen) { + window.clearTimeout(this.skipDelayTimer); + if (hasSkipDelayDuration) this.isOpenDelayed = false; + } else { + window.clearTimeout(this.skipDelayTimer); + this.skipDelayTimer = window.setTimeout( + () => (this.isOpenDelayed = true), + this.skipDelayDuration.value + ); + } + }); + + $effect(() => { + return () => { + window.clearTimeout(this.openTimer); + window.clearTimeout(this.closeTimer); + window.clearTimeout(this.skipDelayTimer); + }; + }); + } + + startCloseTimer = () => { + window.clearTimeout(this.closeTimer); + this.closeTimer = window.setTimeout(() => this.setValue(""), 150); + }; + + handleOpen = (itemValue: string) => { + window.clearTimeout(this.closeTimer); + this.setValue(itemValue); + }; + + handleClose = () => { + this.onItemDismiss(); + this.onContentLeave(); + }; + + handleDelayedOpen = (itemValue: string) => { + const isOpenItem = this.value.value === itemValue; + if (isOpenItem) { + // If the item is already open (e.g. we're transitioning from the content to the trigger) + // then we want to clear the close timer immediately. + window.clearTimeout(this.closeTimer); + } else { + this.openTimer = window.setTimeout(() => { + window.clearTimeout(this.closeTimer); + this.setValue(itemValue); + }, this.delayDuration.value); + } + }; + + onTriggerEnter = (itemValue: string) => { + window.clearTimeout(this.openTimer); + if (this.isOpenDelayed) { + this.handleDelayedOpen(itemValue); + } else { + this.handleOpen(itemValue); + } + }; + + onTriggerLeave = () => { + window.clearTimeout(this.openTimer); + this.startCloseTimer(); + }; + + onContentEnter = () => { + window.clearTimeout(this.closeTimer); + }; + + onContentLeave = () => { + this.startCloseTimer(); + }; + + onItemSelect = (itemValue: string) => { + const prevValue = this.value.value; + this.setValue(prevValue === itemValue ? "" : itemValue); + }; + + onItemDismiss = () => { + this.setValue(""); + }; + + props = $derived.by(() => ({ + id: this.id.value, + "aria-label": "Main", + "data-orientation": getDataOrientation(this.orientation.value), + dir: this.dir.value, + [ROOT_ATTR]: "", + })); + + createMenu(props: NavigationMenuMenuStateProps) { + return new NavigationMenuMenuState(props, this); + } +} + +type NavigationMenuMenuStateProps = ReadableBoxedValues<{ + rootNavigationId: string; + dir: Direction; + orientation: Orientation; +}> & + WritableBoxedValues<{ + value: string; + }> & { + isRoot: boolean; + onTriggerEnter: (itemValue: string) => void; + onTriggerLeave?: () => void; + onContentEnter?: () => void; + onContentLeave?: () => void; + onItemSelect: (itemValue: string) => void; + onItemDismiss: () => void; + previousValue: Previous; + }; + +class NavigationMenuMenuState { + isRoot: NavigationMenuMenuStateProps["isRoot"] = $state(false); + rootNavigationId: NavigationMenuMenuStateProps["rootNavigationId"]; + dir: NavigationMenuMenuStateProps["dir"]; + orientation: NavigationMenuMenuStateProps["orientation"]; + value: NavigationMenuMenuStateProps["value"]; + previousValue: NavigationMenuMenuStateProps["previousValue"]; + onTriggerEnter: NavigationMenuMenuStateProps["onTriggerEnter"]; + onTriggerLeave: NavigationMenuMenuStateProps["onTriggerLeave"]; + onContentEnter: NavigationMenuMenuStateProps["onContentEnter"]; + onContentLeave: NavigationMenuMenuStateProps["onContentLeave"]; + onItemSelect: NavigationMenuMenuStateProps["onItemSelect"]; + onItemDismiss: NavigationMenuMenuStateProps["onItemDismiss"]; + viewportNode = $state(null); + indicatorTrackNode = $state(null); + viewportContentId = box.with(() => undefined); + root: NavigationMenuRootState; + triggerRefs = new Set(); + + constructor(props: NavigationMenuMenuStateProps, root: NavigationMenuRootState) { + this.isRoot = props.isRoot; + this.rootNavigationId = props.rootNavigationId; + this.dir = props.dir; + this.orientation = props.orientation; + this.value = props.value; + this.onTriggerEnter = props.onTriggerEnter; + this.onTriggerLeave = props.onTriggerLeave; + this.onContentEnter = props.onContentEnter; + this.onContentLeave = props.onContentLeave; + this.onItemSelect = props.onItemSelect; + this.onItemDismiss = props.onItemDismiss; + this.root = root; + this.previousValue = props.previousValue; + } + + registerTrigger = (ref: ElementRef) => { + this.triggerRefs.add(ref); + }; + + deRegisterTrigger = (ref: ElementRef) => { + this.triggerRefs.delete(ref); + }; + + getTriggerNodes = () => { + return Array.from(this.triggerRefs) + .map((ref) => ref.value) + .filter((node): node is HTMLElement => Boolean(node)); + }; + + createList(props: NavigationMenuListStateProps) { + return new NavigationMenuListState(props, this); + } + + createItem(props: NavigationMenuItemStateProps) { + return new NavigationMenuItemState(props, this); + } + + createIndicator(props: NavigationMenuIndicatorStateProps) { + return new NavigationMenuIndicatorState(props, this); + } + + createViewport(props: NavigationMenuViewportStateProps) { + return new NavigationMenuViewportState(props, this); + } + + createSubMenu(props: NavigationMenuSubStateProps) { + return new NavigationMenuSubState(props, this.root); + } +} + +type NavigationMenuSubStateProps = ReadableBoxedValues<{ + id: string; + orientation: Orientation; +}> & + WritableBoxedValues<{ + value: string; + ref: HTMLElement | null; + }>; + +class NavigationMenuSubState { + id: NavigationMenuSubStateProps["id"]; + isRoot = false; + rootNavigationId: NavigationMenuMenuStateProps["rootNavigationId"]; + dir: NavigationMenuMenuStateProps["dir"]; + orientation: NavigationMenuMenuStateProps["orientation"]; + value: NavigationMenuMenuStateProps["value"]; + previousValue = new Previous(() => this.value.value); + onTriggerLeave: NavigationMenuMenuStateProps["onTriggerLeave"]; + onContentEnter: NavigationMenuMenuStateProps["onContentEnter"]; + onContentLeave: NavigationMenuMenuStateProps["onContentLeave"]; + viewportNode = $state(null); + indicatorTrackNode = $state(null); + viewportContentId = box.with(() => undefined); + root: NavigationMenuRootState; + triggerRefs = new Set(); + ref: NavigationMenuSubStateProps["ref"]; + + constructor(props: NavigationMenuSubStateProps, root: NavigationMenuRootState) { + this.id = props.id; + this.rootNavigationId = root.id; + this.dir = root.dir; + this.orientation = props.orientation; + this.value = props.value; + this.root = root; + this.ref = props.ref; + + useRefById({ + id: this.id, + ref: this.ref, + }); + } + + onTriggerEnter = (itemValue: string) => { + this.value.value = itemValue; + }; + + onItemSelect = (itemValue: string) => { + this.value.value = itemValue; + }; + + onItemDismiss = () => { + this.value.value = ""; + }; + + registerTrigger = (ref: ElementRef) => { + this.triggerRefs.add(ref); + }; + + deRegisterTrigger = (ref: ElementRef) => { + this.triggerRefs.delete(ref); + }; + + getTriggerNodes = () => { + return Array.from(this.triggerRefs) + .map((ref) => ref.value) + .filter((node): node is HTMLElement => Boolean(node)); + }; + + createList(props: NavigationMenuListStateProps) { + return new NavigationMenuListState(props, this); + } + + createItem(props: NavigationMenuItemStateProps) { + return new NavigationMenuItemState(props, this); + } + + createIndicator(props: NavigationMenuIndicatorStateProps) { + return new NavigationMenuIndicatorState(props, this); + } + + createViewport(props: NavigationMenuViewportStateProps) { + return new NavigationMenuViewportState(props, this); + } + + props = $derived.by(() => ({ + id: this.id.value, + "data-orientation": getDataOrientation(this.orientation.value), + [SUB_ATTR]: "", + })); +} + +type NavigationMenuListStateProps = ReadableBoxedValues<{ + id: string; +}> & + WritableBoxedValues<{ + ref: HTMLElement | null; + indicatorTrackRef: HTMLElement | null; + }>; + +class NavigationMenuListState { + id: NavigationMenuListStateProps["id"]; + listRef: NavigationMenuListStateProps["ref"]; + indicatorTrackRef: NavigationMenuListStateProps["indicatorTrackRef"]; + indicatorTrackId = box(useId()); + + constructor( + props: NavigationMenuListStateProps, + private menu: NavigationMenuMenuState | NavigationMenuSubState + ) { + this.id = props.id; + this.listRef = props.ref; + this.indicatorTrackRef = props.indicatorTrackRef; + + useRefById({ + id: this.id, + ref: this.listRef, + }); + + useRefById({ + id: this.indicatorTrackId, + ref: this.indicatorTrackRef, + onRefChange: (node) => { + this.menu.indicatorTrackNode = node; + }, + }); + } + + indicatorTrackProps = $derived.by( + () => + ({ + id: this.indicatorTrackId.value, + style: { + position: "relative", + }, + }) as const + ); + + props = $derived.by( + () => + ({ + "data-orientation": getDataOrientation(this.menu.orientation.value), + [LIST_ATTR]: "", + }) as const + ); +} + +type NavigationMenuItemStateProps = ReadableBoxedValues<{ + id: string; + value: string; +}>; + +class NavigationMenuItemState { + id: NavigationMenuItemStateProps["id"]; + value: NavigationMenuItemStateProps["value"]; + contentNode = $state(null); + triggerNode = $state(null); + focusProxyRef = box(null); + focusProxyNode = $state(null); + focusProxyId = box(useId()); + restoreContentTabOrder = noop; + wasEscapeClose = $state(false); + menu: NavigationMenuMenuState | NavigationMenuSubState; + + constructor( + props: NavigationMenuItemStateProps, + menu: NavigationMenuMenuState | NavigationMenuSubState + ) { + this.id = props.id; + this.value = props.value; + this.menu = menu; + } + + #handleContentEntry = (side: "start" | "end" = "start") => { + if (!this.contentNode) return; + this.restoreContentTabOrder(); + const candidates = getTabbableCandidates(this.contentNode); + if (candidates.length) { + if (side === "start") { + candidates[0]?.focus(); + } else { + candidates[candidates.length - 1]?.focus(); + } + } + }; + + #handleContentExit = () => { + if (!this.contentNode) return; + const candidates = getTabbableCandidates(this.contentNode); + if (candidates.length) { + this.restoreContentTabOrder = removeFromTabOrder(candidates); + } + }; + + onEntryKeydown = this.#handleContentEntry; + onFocusProxyEnter = this.#handleContentEntry; + onContentFocusOutside = this.#handleContentExit; + onRootContentClose = this.#handleContentExit; + + props = $derived.by( + () => + ({ + id: this.id.value, + [ITEM_ATTR]: "", + }) as const + ); + + createTrigger(props: NavigationMenuTriggerStateProps) { + return new NavigationMenuTriggerState(props, this); + } + + createContent(props: NavigationMenuContentStateProps) { + return new NavigationMenuContentState(props, this); + } + + createLink(props: NavigationMenuLinkStateProps) { + return new NavigationMenuLinkState(props); + } +} + +type NavigationMenuTriggerStateProps = ReadableBoxedValues<{ + id: string; + disabled: boolean; + focusProxyMounted: boolean; +}> & + WritableBoxedValues<{ + ref: HTMLElement | null; + }>; + +class NavigationMenuTriggerState { + id: NavigationMenuTriggerStateProps["id"]; + focusProxyMounted: NavigationMenuTriggerStateProps["focusProxyMounted"]; + menu: NavigationMenuMenuState | NavigationMenuSubState; + item: NavigationMenuItemState; + disabled: NavigationMenuTriggerStateProps["disabled"]; + hasPointerMoveOpened = boxAutoReset(false, 150); + wasClickClose = $state(false); + open = $derived.by(() => this.item.value.value === this.menu.value.value); + triggerRef: NavigationMenuTriggerStateProps["ref"]; + + constructor(props: NavigationMenuTriggerStateProps, item: NavigationMenuItemState) { + this.id = props.id; + this.triggerRef = props.ref; + this.item = item; + this.menu = item.menu; + this.disabled = props.disabled; + this.focusProxyMounted = props.focusProxyMounted; + + useRefById({ + id: this.id, + ref: this.triggerRef, + onRefChange: (node) => { + this.item.triggerNode = node; + }, + }); + + useRefById({ + id: this.item.focusProxyId, + ref: this.item.focusProxyRef, + onRefChange: (node) => { + this.item.focusProxyNode = node; + }, + condition: () => this.focusProxyMounted.value, + }); + + $effect(() => { + this.menu.registerTrigger(this.triggerRef); + return () => { + this.menu.deRegisterTrigger(this.triggerRef); + }; + }); + } + + #onpointerenter = () => { + this.wasClickClose = false; + this.item.wasEscapeClose = false; + }; + + #onpointermove = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + if ( + this.disabled.value || + this.wasClickClose || + this.item.wasEscapeClose || + this.hasPointerMoveOpened.value + ) + return; + this.menu.onTriggerEnter(this.item.value.value); + this.hasPointerMoveOpened.value = true; + }; + + #onpointerleave = (e: PointerEvent) => { + if (e.pointerType !== "mouse" || this.disabled.value) return; + this.menu.onTriggerLeave?.(); + this.hasPointerMoveOpened.value = false; + }; + + #onclick = (e: PointerEvent) => { + // if opened via pointer move, we prevent clicke event + if (this.hasPointerMoveOpened.value) return; + if (this.open) { + this.menu.onItemSelect(""); + } else { + this.menu.onItemSelect(this.item.value.value); + } + this.wasClickClose = this.open; + }; + + #onkeydown = (e: KeyboardEvent) => { + const verticalEntryKey = this.menu.dir.value === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; + const entryKey = { + horizontal: kbd.ARROW_DOWN, + vertical: verticalEntryKey, + }[this.menu.orientation.value]; + if (this.open && e.key === entryKey) { + this.item.onEntryKeydown(); + e.preventDefault(); + } + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + disabled: getDisabledAttr(this.disabled.value), + "data-disabled": getDataDisabled(this.disabled.value), + "data-state": getDataOpenClosed(this.open), + "aria-expanded": getAriaExpanded(this.open), + "aria-controls": this.item.contentNode ? this.item.contentNode.id : undefined, + "data-value": this.item.value.value, + onpointerenter: this.#onpointerenter, + onpointermove: this.#onpointermove, + onpointerleave: this.#onpointerleave, + onclick: this.#onclick, + onkeydown: this.#onkeydown, + [TRIGGER_ATTR]: "", + }) as const + ); + + visuallyHiddenProps = $derived.by( + () => + ({ + id: this.item.focusProxyId.value, + "aria-hidden": "true", + tabIndex: 0, + onfocus: (e: FocusEvent) => { + const prevFocusedElement = e.relatedTarget as HTMLElement | null; + const wasTriggerFocused = prevFocusedElement === this.item.triggerNode; + const wasFocusFromContent = this.item.contentNode?.contains(prevFocusedElement); + + if (wasTriggerFocused || !wasFocusFromContent) { + e.preventDefault(); + this.item.onFocusProxyEnter(wasTriggerFocused ? "start" : "end"); + } + }, + }) as const + ); +} + +type NavigationMenuLinkStateProps = ReadableBoxedValues<{ + id: string; + active: boolean; + onSelect: (e: Event) => void; +}>; + +class NavigationMenuLinkState { + id: NavigationMenuItemState["id"]; + active: NavigationMenuLinkStateProps["active"]; + onSelect: NavigationMenuLinkStateProps["onSelect"]; + + constructor(props: NavigationMenuLinkStateProps) { + this.id = props.id; + this.active = props.active; + this.onSelect = props.onSelect; + } + + #onclick = (e: MouseEvent) => { + const linkSelectEvent = new CustomEvent("navigationMenu.linkSelect", { + bubbles: true, + cancelable: true, + }); + + this.onSelect.value(linkSelectEvent); + + if (!linkSelectEvent.defaultPrevented && !e.metaKey) { + } + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-active": this.active.value ? "" : undefined, + "aria-current": this.active.value ? "page" : undefined, + onclick: this.#onclick, + onfocus: (e: FocusEvent) => {}, + }) as const + ); +} + +type NavigationMenuIndicatorStateProps = ReadableBoxedValues<{ + id: string; +}> & + WritableBoxedValues<{ + ref: HTMLElement | null; + }>; + +class NavigationMenuIndicatorState { + id: NavigationMenuIndicatorStateProps["id"]; + menu: NavigationMenuMenuState | NavigationMenuSubState; + activeTrigger = $state(null); + position = $state<{ size: number; offset: number } | null>(null); + isHorizontal = $derived.by(() => this.menu.orientation.value === "horizontal"); + isVisible = $derived.by(() => Boolean(this.menu.value.value)); + indicatorRef: NavigationMenuIndicatorStateProps["ref"]; + + constructor( + props: NavigationMenuIndicatorStateProps, + menu: NavigationMenuMenuState | NavigationMenuSubState + ) { + this.id = props.id; + this.indicatorRef = props.ref; + this.menu = menu; + + useRefById({ + id: this.id, + ref: this.indicatorRef, + onRefChange: (node) => { + this.menu.viewportNode = node; + }, + }); + + $effect(() => { + const triggerNodes = this.menu.getTriggerNodes(); + const triggerNode = triggerNodes.find( + (node) => node.dataset.value === this.menu.value.value + ); + if (triggerNode) { + untrack(() => { + this.activeTrigger = triggerNode; + }); + } + }); + + useResizeObserver(() => this.activeTrigger, this.handlePositionChange); + useResizeObserver(() => this.menu.indicatorTrackNode, 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( + () => + ({ + "aria-hidden": getAriaHidden(true), + "data-state": this.isVisible ? "visible" : "hidden", + "data-orientation": getDataOrientation(this.menu.orientation.value), + style: { + position: "absolute", + ...(this.isHorizontal + ? { + left: 0, + width: this.position ? `${this.position.size}px` : undefined, + transform: this.position + ? `translateX(${this.position.offset}px)` + : undefined, + } + : { + top: 0, + height: this.position ? `${this.position.size}px` : undefined, + transform: this.position + ? `translateY(${this.position.offset}px)` + : undefined, + }), + }, + }) as const + ); +} + +type NavigationMenuContentStateProps = ReadableBoxedValues<{ + id: string; + forceMount: boolean; + isMounted: boolean; +}> & + WritableBoxedValues<{ + ref: HTMLElement | null; + }>; + +type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; + +class NavigationMenuContentState { + id: NavigationMenuContentStateProps["id"]; + forceMount: NavigationMenuContentStateProps["forceMount"]; + isMounted: NavigationMenuContentStateProps["isMounted"]; + contentRef: NavigationMenuContentStateProps["ref"]; + menu: NavigationMenuMenuState | NavigationMenuSubState; + item: NavigationMenuItemState; + prevMotionAttribute = $state(null); + motionAttribute = $state(null); + open = $derived.by(() => this.menu.value.value === this.item.value.value); + isPresent = $derived.by(() => this.open || this.forceMount.value); + + constructor(props: NavigationMenuContentStateProps, item: NavigationMenuItemState) { + this.id = props.id; + this.forceMount = props.forceMount; + this.isMounted = props.isMounted; + this.item = item; + this.menu = item.menu; + this.contentRef = props.ref; + + useRefById({ + id: this.id, + ref: this.contentRef, + onRefChange: (node) => { + this.item.contentNode = node; + }, + condition: () => this.isMounted.value, + }); + + $effect(() => { + const items = this.menu.getTriggerNodes(); + const prev = this.menu.previousValue.current; + const values = items + .map((item) => item.dataset.value) + .filter((v): v is string => Boolean(v)); + if (this.menu.dir.value === "rtl") values.reverse(); + const index = values.indexOf(this.menu.value.value); + const prevIndex = values.indexOf(prev ?? ""); + const isSelected = this.item.value.value === this.menu.value.value; + const wasSelected = prevIndex === values.indexOf(this.item.value.value); + + // We only want to update selected and the last selected content + // this avoids animations being interrupted outside of that range + if (!isSelected && !wasSelected) { + this.motionAttribute = 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; + })(); + + this.prevMotionAttribute = attribute; + this.motionAttribute = attribute; + }); + } + + onFocusOutside = (e: Event) => { + this.item.onContentFocusOutside(); + const target = e.target as HTMLElement; + // only dismiss content when focus moves outside the menu + + if (this.menu.root.rootRef.value?.contains(target)) { + e.preventDefault(); + } else { + this.menu.root.handleClose(); + } + }; + + onInteractOutside = (e: Event) => { + if (e.defaultPrevented) return; + const target = e.target as HTMLElement; + const isTrigger = this.menu.getTriggerNodes().some((node) => node.contains(target)); + + const isRootViewport = this.menu.isRoot && this.menu.viewportNode?.contains(target); + + if (isTrigger || isRootViewport || !this.menu.isRoot) { + e.preventDefault(); + } + }; + + onEscapeKeydown = (e: KeyboardEvent) => { + this.menu.root.handleClose(); + const target = e.target as HTMLElement; + + if (this.contentRef.value?.contains(target)) { + this.item.triggerNode?.focus(); + } + this.item.wasEscapeClose = true; + }; + + #onkeydown = (e: KeyboardEvent) => { + const isMetaKey = e.altKey || e.ctrlKey || e.metaKey; + const isTabKey = e.key === kbd.TAB && !isMetaKey; + + const candidates = getTabbableCandidates(e.currentTarget as HTMLElement); + if (isTabKey) { + const focusedElement = document.activeElement; + 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)) { + // 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 + this.item.focusProxyNode?.focus(); + return; + } + } + const newSelectedElement = useArrowNavigation( + e, + document.activeElement as HTMLElement, + undefined, + { + itemsArray: candidates, + attributeName: `[${LINK_ATTR}]`, + loop: false, + enableIgnoredElement: true, + } + ); + + newSelectedElement?.focus(); + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + "aria-labelledby": this.item.triggerNode?.id ?? undefined, + "data-motion": this.motionAttribute, + "data-state": getDataOpenClosed(this.menu.value.value === this.item.value.value), + "data-orientation": getDataOrientation(this.menu.orientation.value), + [CONTENT_ATTR]: "", + style: { + pointerEvents: !this.open && this.menu.isRoot ? "none" : undefined, + }, + onkeydown: this.#onkeydown, + }) as const + ); +} + +type NavigationMenuViewportStateProps = ReadableBoxedValues<{ + id: string; +}> & + WritableBoxedValues<{ + ref: HTMLElement | null; + }>; + +class NavigationMenuViewportState { + id: NavigationMenuViewportStateProps["id"]; + menu: NavigationMenuMenuState | NavigationMenuSubState; + size = $state<{ width: number; height: number } | null>(null); + open = $derived.by(() => this.menu.value.value !== ""); + activeContentValue = $derived.by(() => this.menu.value.value); + viewportRef: NavigationMenuViewportStateProps["ref"]; + contentNode = $state(); + + constructor( + props: NavigationMenuViewportStateProps, + menu: NavigationMenuMenuState | NavigationMenuSubState + ) { + this.id = props.id; + this.menu = menu; + this.viewportRef = props.ref; + + useRefById({ + id: this.id, + ref: this.viewportRef, + onRefChange: (node) => { + this.menu.viewportNode = node; + }, + condition: () => this.open, + }); + + $effect(() => { + this.open; + this.activeContentValue; + const currentNode = untrack(() => this.viewportRef.value); + if (!currentNode) return; + afterTick(() => { + const contentNode = currentNode.querySelector("[data-state=open]") + ?.children?.[0] as HTMLElement; + this.contentNode = contentNode; + }); + }); + + useResizeObserver( + () => this.contentNode, + () => { + if (this.contentNode) { + this.size = { + width: this.contentNode.offsetWidth, + height: this.contentNode.offsetHeight, + }; + } + } + ); + } + + #onpointerenter = () => { + this.menu.onContentEnter?.(); + }; + + #onpointerleave = (e: PointerEvent) => { + if (e.pointerType !== "mouse") return; + this.menu.onContentLeave?.(); + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-state": getDataOpenClosed(this.open), + "data-orientation": getDataOrientation(this.menu.orientation.value), + style: { + pointerEvents: !this.open && this.menu.isRoot ? "none" : undefined, + "--bits-navigation-menu-viewport-width": this.size + ? `${this.size.width}px` + : undefined, + "--bits-navigation-menu-viewport-height": this.size + ? `${this.size.height}px` + : undefined, + }, + onpointerenter: this.#onpointerenter, + onpointerleave: this.#onpointerleave, + }) as const + ); +} + +export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { + const rootState = new NavigationMenuRootState(props); + const menuState = rootState.createMenu({ + rootNavigationId: rootState.id, + dir: rootState.dir, + orientation: rootState.orientation, + value: rootState.value, + isRoot: true, + onTriggerEnter: rootState.onTriggerEnter, + onItemSelect: rootState.onItemSelect, + onItemDismiss: rootState.onItemDismiss, + onContentEnter: rootState.onContentEnter, + onContentLeave: rootState.onContentLeave, + onTriggerLeave: rootState.onTriggerLeave, + previousValue: rootState.previousValue, + }); + + setNavigationMenuMenuContext(menuState); + return setNavigationMenuRootContext(rootState); +} + +export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { + const parentMenu = getNavigationMenuMenuContext(); + if (parentMenu instanceof NavigationMenuMenuState) { + return setNavigationMenuMenuContext( + parentMenu.createSubMenu(props) + ) as NavigationMenuSubState; + } + throw new Error("useNavigationMenuSub must be used within a NavigationMenuMenu"); +} + +export function useNavigationMenuList(props: NavigationMenuListStateProps) { + const menuState = getNavigationMenuMenuContext(); + return menuState.createList(props); +} + +export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { + const menuState = getNavigationMenuMenuContext(); + return setNavigationMenuItemContext(menuState.createItem(props)); +} + +export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) { + return getNavigationMenuItemContext().createTrigger(props); +} + +export function useNavigationMenuContent(props: NavigationMenuContentStateProps) { + return getNavigationMenuItemContext().createContent(props); +} + +export function useNavigationMenuViewport(props: NavigationMenuViewportStateProps) { + return getNavigationMenuMenuContext().createViewport(props); +} + +export function useNavigationMenuIndicator(props: NavigationMenuIndicatorStateProps) { + return getNavigationMenuMenuContext().createIndicator(props); +} + +export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { + return getNavigationMenuItemContext().createLink(props); +} + +/// Utils + +function focusFirst(candidates: HTMLElement[]) { + const previouslyFocusedElement = document.activeElement; + 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 document.activeElement !== previouslyFocusedElement; + }); +} + +function removeFromTabOrder(candidates: HTMLElement[]) { + candidates.forEach((candidate) => { + candidate.dataset.tabindex = candidate.getAttribute("tabindex") || ""; + candidate.setAttribute("tabindex", "-1"); + }); + return () => { + candidates.forEach((candidate) => { + const prevTabIndex = candidate.dataset.tabindex as string; + candidate.setAttribute("tabindex", prevTabIndex); + }); + }; +} + +function useResizeObserver(element: () => HTMLElement | null | undefined, onResize: () => void) { + $effect(() => { + let rAF = 0; + const node = element(); + if (node) { + const resizeObserver = new ResizeObserver(() => { + cancelAnimationFrame(rAF); + rAF = window.requestAnimationFrame(onResize); + }); + + resizeObserver.observe(node); + + return () => { + window.cancelAnimationFrame(rAF); + resizeObserver.unobserve(node); + }; + } + }); +} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/types.ts b/packages/bits-ui/src/lib/bits/navigation-menu/types.ts new file mode 100644 index 000000000..e1ff842de --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/types.ts @@ -0,0 +1,175 @@ +import type { + OnChangeFn, + PrimitiveAnchorAttributes, + PrimitiveButtonAttributes, + PrimitiveDivAttributes, + PrimitiveElementAttributes, + PrimitiveLiAttributes, + PrimitiveUListAttributes, + WithAsChild, + Without, +} from "$lib/internal/types.js"; +import type { Direction, Orientation } from "$lib/shared/index.js"; +import type { InteractOutsideEvent } from "@melt-ui/svelte"; + +export type NavigationMenuRootPropsWithoutHTML = WithAsChild<{ + /** + * The value of the currently open menu item. + * + * @bindable + */ + value?: string; + + /** + * The callback to call when a menu item is selected. + */ + onValueChange?: OnChangeFn; + + /** + * The duration from when the mouse enters a trigger until the content opens. + */ + delayDuration?: number; + + /** + * How much time a user has to enter another trigger without incurring a delay again. + */ + skipDelayDuration?: number; + + /** + * The reading direction of the content. + */ + dir?: Direction; + + /** + * The orientation of the menu. + */ + orientation?: Orientation; +}>; + +export type NavigationMenuRootProps = NavigationMenuRootPropsWithoutHTML & + Without; + +export type NavigationMenuSubPropsWithoutHTML = WithAsChild<{ + /** + * 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; + + /** + * The orientation of the menu. + */ + orientation?: Orientation; +}>; + +export type NavigationMenuSubProps = NavigationMenuSubPropsWithoutHTML & + Without; + +export type NavigationMenuListPropsWithoutHTML = WithAsChild<{}>; + +export type NavigationMenuListProps = NavigationMenuListPropsWithoutHTML & + Without; + +export type NavigationMenuItemPropsWithoutHTML = WithAsChild<{ + /** + * The value of the menu item. + */ + value?: string; +}>; + +export type NavigationMenuItemProps = NavigationMenuItemPropsWithoutHTML & + Without; + +export type NavigationMenuTriggerPropsWithoutHTML = WithAsChild<{ + /** + * Whether the trigger is disabled. + * @defaultValue false + */ + disabled?: boolean; +}>; + +export type NavigationMenuTriggerProps = NavigationMenuTriggerPropsWithoutHTML & + Without; + +export type NavigationMenuContentPropsWithoutHTML = WithAsChild<{ + /** + * Callback fired when an interaction occurs outside the content. + * Default behavior can be prevented with `event.preventDefault()` + * + */ + onInteractOutside?: (event: InteractOutsideEvent) => 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; + + /** + * 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 NavigationMenuContentProps = NavigationMenuContentPropsWithoutHTML & + Without; + +export type NavigationMenuLinkPropsWithoutHTML = WithAsChild<{ + /** + * 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; + +export type NavigationMenuIndicatorPropsWithoutHTML = WithAsChild<{ + /** + * 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; + +export type NavigationMenuViewportPropsWithoutHTML = WithAsChild<{ + /** + * 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; diff --git a/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/useDismissableLayer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/useDismissableLayer.svelte.ts index 6c95a2692..3dec0630d 100644 --- a/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/useDismissableLayer.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/useDismissableLayer.svelte.ts @@ -1,5 +1,5 @@ import { untrack } from "svelte"; -import type { ReadableBox } from "svelte-toolbelt"; +import { box, type ReadableBox, type WritableBox } from "svelte-toolbelt"; import type { DismissableLayerImplProps, InteractOutsideBehaviorType, @@ -19,9 +19,8 @@ import { isElement, isOrContainsTarget, noop, - useNodeById, + useRefById, } from "$lib/internal/index.js"; -import { eventLogs } from "$lib/bits/index.js"; const layers = new Map>(); @@ -54,40 +53,57 @@ export class DismissableLayerState { }; #isPointerDownOutside = false; #isResponsibleLayer = false; - node: Box; + node: WritableBox = box(null); #documentObj = undefined as unknown as Document; #enabled: ReadableBox; #isFocusInsideDOMTree = $state(false); #onFocusOutside: DismissableLayerStateProps["onFocusOutside"]; + currNode = $state(null); constructor(props: DismissableLayerStateProps) { - this.node = useNodeById(props.id); + this.#enabled = props.enabled; + + useRefById({ + id: props.id, + ref: this.node, + condition: () => this.#enabled.value, + onRefChange: (node) => { + this.currNode = node; + }, + }); + this.#behaviorType = props.interactOutsideBehavior; this.#interactOutsideStartProp = props.onInteractOutsideStart; this.#interactOutsideProp = props.onInteractOutside; - this.#enabled = props.enabled; this.#onFocusOutside = props.onFocusOutside; $effect(() => { - this.#documentObj = getOwnerDocument(this.node.value); + this.#documentObj = getOwnerDocument(this.currNode); }); let unsubEvents = noop; + const cleanup = () => { + this.#resetState(); + layers.delete(this); + this.#onInteractOutsideStart.destroy(); + this.#onInteractOutside.destroy(); + unsubEvents(); + }; + $effect(() => { if (this.#enabled.value) { layers.set( this, untrack(() => this.#behaviorType) ); - unsubEvents = this.#addEventListeners(); + untrack(() => { + unsubEvents(); + unsubEvents = this.#addEventListeners(); + }); } return () => { - this.#resetState(); - layers.delete(this); - this.#onInteractOutsideStart.destroy(); - this.#onInteractOutside.destroy(); - unsubEvents(); + cleanup(); }; }); @@ -106,9 +122,10 @@ export class DismissableLayerState { } #handleFocus = (event: FocusEvent) => { - if (!this.node.value) return; + if (event.defaultPrevented) return; + if (!this.currNode) return; afterTick(() => { - if (!this.node.value || this.#isTargetWithinLayer(event.target as HTMLElement)) return; + if (!this.currNode || this.#isTargetWithinLayer(event.target as HTMLElement)) return; if (event.target && !this.#isFocusInsideDOMTree) { this.#onFocusOutside.value?.(event); @@ -174,11 +191,11 @@ export class DismissableLayerState { } #onInteractOutsideStart = debounce((e: InteractOutsideEvent) => { - if (!this.node.value) return; + if (!this.currNode) return; if ( !this.#isResponsibleLayer || this.#isAnyEventIntercepted() || - !isValidEvent(e, this.node.value) + !isValidEvent(e, this.currNode) ) return; this.#interactOutsideStartProp.value(e); @@ -187,13 +204,13 @@ export class DismissableLayerState { }, 10); #onInteractOutside = debounce((e: InteractOutsideEvent) => { - if (!this.node.value) return; + if (!this.currNode) return; const behaviorType = this.#behaviorType.value; if ( !this.#isResponsibleLayer || this.#isAnyEventIntercepted() || - !isValidEvent(e, this.node.value) + !isValidEvent(e, this.currNode) ) { return; } diff --git a/packages/bits-ui/src/lib/bits/utilities/index.ts b/packages/bits-ui/src/lib/bits/utilities/index.ts index b2f2161c6..8db904f17 100644 --- a/packages/bits-ui/src/lib/bits/utilities/index.ts +++ b/packages/bits-ui/src/lib/bits/utilities/index.ts @@ -1 +1,2 @@ export { default as WithTransition } from "./with-transition.svelte"; +export { default as Mounted } from "./mounted.svelte"; diff --git a/packages/bits-ui/src/lib/bits/utilities/mounted.svelte b/packages/bits-ui/src/lib/bits/utilities/mounted.svelte new file mode 100644 index 000000000..bee1fb215 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/mounted.svelte @@ -0,0 +1,10 @@ + diff --git a/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte index a7992f7f5..33b5828d5 100644 --- a/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte @@ -8,19 +8,19 @@ import { FocusScope } from "$lib/bits/utilities/focus-scope/index.js"; import { mergeProps } from "$lib/internal/mergeProps.js"; - let { popper, ...restProps }: PopperLayerImplProps = $props(); + let { popper, present, ...restProps }: PopperLayerImplProps = $props(); - - {#snippet presence({ present })} - + + {#snippet presence()} + {#snippet content({ props: floatingProps })} {#snippet focusScope({ props: focusScopeProps })} - - + + {#snippet children({ props: dismissableProps })} - + {@render popper?.({ props: mergeProps( restProps, diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte b/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte index 32a2b55d0..9a277779c 100644 --- a/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte @@ -1,5 +1,5 @@ diff --git a/packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts index 19330c8ae..f2d4cb6c1 100644 --- a/packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts @@ -7,7 +7,7 @@ export function usePresence(present: ReadableBox, id: ReadableBox(null); - $effect.pre(() => { + $effect(() => { if (!id.value) return; if (!present.value) return; @@ -112,10 +112,9 @@ export function usePresence(present: ReadableBox, id: ReadableBox { - if (!node) return; - node.removeEventListener("animationstart", handleAnimationStart); - node.removeEventListener("animationcancel", handleAnimationEnd); - node.removeEventListener("animationend", handleAnimationEnd); + node?.removeEventListener("animationstart", handleAnimationStart); + node?.removeEventListener("animationcancel", handleAnimationEnd); + node?.removeEventListener("animationend", handleAnimationEnd); }; }); diff --git a/packages/bits-ui/src/lib/internal/types.ts b/packages/bits-ui/src/lib/internal/types.ts index aebf3ad28..520c10e53 100644 --- a/packages/bits-ui/src/lib/internal/types.ts +++ b/packages/bits-ui/src/lib/internal/types.ts @@ -7,6 +7,7 @@ import type { HTMLImgAttributes, HTMLInputAttributes, HTMLLabelAttributes, + HTMLLiAttributes, SVGAttributes, } from "svelte/elements"; import type { TransitionConfig } from "svelte/transition"; @@ -133,12 +134,15 @@ export type PrimitiveHeadingAttributes = Primitive; export type PrimitiveLabelAttributes = Primitive; export type PrimitiveSVGAttributes = Primitive>; export type PrimitiveAnchorAttributes = Primitive; +export type PrimitiveLiAttributes = Primitive; +export type PrimitiveElementAttributes = Primitive>; +export type PrimitiveUListAttributes = Primitive>; export type AsChildProps = { child: Snippet<[SnippetProps & { props: Record }]>; children?: never; asChild: true; - ref?: Ref; + ref?: Ref | null; style?: StyleProperties; } & Omit; @@ -150,7 +154,7 @@ export type DefaultProps = { style?: StyleProperties; } & Omit; -export type ElementRef = Box; +export type ElementRef = Box; export type WithAsChild< Props, diff --git a/packages/bits-ui/src/lib/internal/useArrowNavigation.ts b/packages/bits-ui/src/lib/internal/useArrowNavigation.ts new file mode 100644 index 000000000..1d88751eb --- /dev/null +++ b/packages/bits-ui/src/lib/internal/useArrowNavigation.ts @@ -0,0 +1,168 @@ +import type { Direction } from "$lib/shared/index.js"; + +type ArrowKeyOptions = "horizontal" | "vertical" | "both"; + +interface ArrowNavigationOptions { + /** + * The arrow key options to allow navigation + * + * @defaultValue "both" + */ + arrowKeyOptions?: ArrowKeyOptions; + + /** + * The attribute name to find the collection items in the parent element. + */ + attributeName: string; + + /** + * The parent element where contains all the collection items, this will collect every item to be used when nav + * It will be ignored if attributeName is provided + * + * @defaultValue [] + */ + itemsArray?: HTMLElement[]; + + /** + * Allow loop navigation. If false, it will stop at the first and last element + * + * @defaultValue true + */ + loop?: boolean; + + /** + * The orientation of the collection + * + * @defaultValue "ltr" + */ + dir?: Direction; + + /** + * Prevent the scroll when navigating. This happens when the direction of the + * key matches the scroll direction of any ancestor scrollable elements. + * + * @defaultValue true + */ + preventScroll?: boolean; + + /** + * By default all currentElement would trigger navigation. If `true`, currentElement nodeName in the ignore list will return null + * + * @defaultValue false + */ + enableIgnoredElement?: boolean; + + /** + * Focus the element after navigation + * + * @defaultValue false + */ + focus?: boolean; +} + +const ignoredElement = ["INPUT", "TEXTAREA"]; + +/** + * Allow arrow navigation for every html element with data-radix-vue-collection-item tag + * + * @param e Keyboard event + * @param currentElement Event initiator element or any element that wants to handle the navigation + * @param parentElement Parent element where contains all the collection items, this will collect every item to be used when nav + * @param options further options + * @returns the navigated html element or null if none + */ +export function useArrowNavigation( + e: KeyboardEvent, + currentElement: HTMLElement, + parentElement: HTMLElement | undefined, + options: ArrowNavigationOptions +): HTMLElement | null { + if ( + !currentElement || + (options.enableIgnoredElement && ignoredElement.includes(currentElement.nodeName)) + ) + return null; + + const { + arrowKeyOptions = "both", + attributeName, + itemsArray = [], + loop = true, + dir = "ltr", + preventScroll = true, + focus = false, + } = options; + + const [right, left, up, down, home, end] = [ + e.key === "ArrowRight", + e.key === "ArrowLeft", + e.key === "ArrowUp", + e.key === "ArrowDown", + e.key === "Home", + e.key === "End", + ]; + const goingVertical = up || down; + const goingHorizontal = right || left; + if ( + !home && + !end && + ((!goingVertical && !goingHorizontal) || + (arrowKeyOptions === "vertical" && goingHorizontal) || + (arrowKeyOptions === "horizontal" && goingVertical)) + ) + return null; + + const allCollectionItems: HTMLElement[] = parentElement + ? Array.from(parentElement.querySelectorAll(attributeName)) + : itemsArray; + + if (!allCollectionItems.length) return null; + + if (preventScroll) e.preventDefault(); + + let item: HTMLElement | null = null; + + if (goingHorizontal || goingVertical) { + const goForward = goingVertical ? down : dir === "ltr" ? right : left; + item = findNextFocusableElement(allCollectionItems, currentElement, { + goForward, + loop, + }); + } else if (home) { + item = allCollectionItems.at(0) || null; + } else if (end) { + item = allCollectionItems.at(-1) || null; + } + + if (focus) item?.focus(); + + return item; +} + +/** + * Recursive function to find the next focusable element to avoid disabled elements + */ +function findNextFocusableElement( + elements: HTMLElement[], + currentElement: HTMLElement, + { goForward, loop }: { goForward: boolean; loop?: boolean }, + iterations = elements.length +): HTMLElement | null { + if (--iterations === 0) return null; + + const index = elements.indexOf(currentElement); + const newIndex = goForward ? index + 1 : index - 1; + + if (!loop && (newIndex < 0 || newIndex >= elements.length)) return null; + + const adjustedNewIndex = (newIndex + elements.length) % elements.length; + const candidate = elements[adjustedNewIndex]; + if (!candidate) return null; + + const isDisabled = + candidate.hasAttribute("disabled") && candidate.getAttribute("disabled") !== "false"; + if (isDisabled) { + return findNextFocusableElement(elements, candidate, { goForward, loop }, iterations); + } + return candidate; +} diff --git a/packages/bits-ui/src/lib/internal/useNodeById.svelte.ts b/packages/bits-ui/src/lib/internal/useNodeById.svelte.ts index 52e3a3559..45b574b69 100644 --- a/packages/bits-ui/src/lib/internal/useNodeById.svelte.ts +++ b/packages/bits-ui/src/lib/internal/useNodeById.svelte.ts @@ -1,5 +1,8 @@ -import { type ReadableBox, type WritableBox, box } from "svelte-toolbelt"; +import { type Getter, type ReadableBox, type WritableBox, box } from "svelte-toolbelt"; import { afterTick } from "./afterTick.js"; +import type { Box } from "./box.svelte.js"; +import { untrack } from "svelte"; +import { noop } from "./callbacks.js"; /** * Finds the node with that ID and sets it to the boxed node. @@ -34,3 +37,51 @@ export function useNodeById( return node; } + +type UseRefByIdProps = { + /** + * The ID of the node to find. + */ + id: Box; + + /** + * The ref to set the node to. + */ + ref: WritableBox; + + /** + * A condition that determines whether the ref should be set or not. + */ + condition?: Getter; + + /** + * A callback fired when the ref changes. + */ + onRefChange?: (node: HTMLElement | null) => void; +}; + +/** + * Finds the node with that ID and sets it to the boxed node. + * Reactive using `$effect` to ensure when the ID or condition changes, + * an update is triggered and new node is found. + * + * @param id The boxed ID of the node to find. + */ +export function useRefById({ + id, + ref, + condition = () => true, + onRefChange = noop, +}: UseRefByIdProps) { + $effect(() => { + // re-run when the ID changes. + id.value; + condition(); + // re-run when the condition changes. + untrack(() => { + const node = document.getElementById(id.value); + ref.value = node; + onRefChange(ref.value); + }); + }); +} diff --git a/packages/bits-ui/src/lib/types.ts b/packages/bits-ui/src/lib/types.ts index a4654b463..7a5675f6f 100644 --- a/packages/bits-ui/src/lib/types.ts +++ b/packages/bits-ui/src/lib/types.ts @@ -18,6 +18,7 @@ export type * from "$lib/bits/dropdown-menu/types.js"; export type * from "$lib/bits/label/types.js"; export type * from "$lib/bits/link-preview/types.js"; export type * from "$lib/bits/menubar/types.js"; +export type * from "$lib/bits/navigation-menu/types.js"; export type * from "$lib/bits/pagination/types.js"; export type * from "$lib/bits/pin-input/types.js"; export type * from "$lib/bits/popover/types.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fec03738e..c852d9515 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 2.27.5 '@huntabyte/eslint-config': specifier: ^0.3.1 - version: 0.3.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)(typescript@5.4.5)(vitest@1.6.0) + version: 0.3.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)(typescript@5.4.5)(vitest@1.6.0) '@huntabyte/eslint-plugin': specifier: ^0.1.0 version: 0.1.0(eslint@9.3.0) @@ -25,22 +25,22 @@ importers: version: 9.3.0 eslint-plugin-svelte: specifier: ^2.37.0 - version: 2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143) + version: 2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155) prettier: specifier: ^3.2.5 version: 3.2.5 prettier-plugin-svelte: specifier: ^3.2.2 - version: 3.2.3(prettier@3.2.5)(svelte@5.0.0-next.143) + version: 3.2.3(prettier@3.2.5)(svelte@5.0.0-next.155) prettier-plugin-tailwindcss: specifier: 0.5.13 - version: 0.5.13(prettier-plugin-svelte@3.2.3(prettier@3.2.5)(svelte@5.0.0-next.143))(prettier@3.2.5) + version: 0.5.13(prettier-plugin-svelte@3.2.3(prettier@3.2.5)(svelte@5.0.0-next.155))(prettier@3.2.5) svelte: - specifier: 5.0.0-next.143 - version: 5.0.0-next.143 + specifier: 5.0.0-next.155 + version: 5.0.0-next.155 svelte-eslint-parser: specifier: ^0.34.1 - version: 0.34.1(svelte@5.0.0-next.143) + version: 0.34.1(svelte@5.0.0-next.155) wrangler: specifier: ^3.44.0 version: 3.57.2(@cloudflare/workers-types@4.20240524.0) @@ -58,7 +58,7 @@ importers: version: 3.5.4 '@melt-ui/svelte': specifier: 0.76.2 - version: 0.76.2(svelte@5.0.0-next.143) + version: 0.76.2(svelte@5.0.0-next.155) clsx: specifier: ^2.1.0 version: 2.1.1 @@ -69,8 +69,8 @@ importers: specifier: ^5.0.5 version: 5.0.7 runed: - specifier: ^0.5.0 - version: 0.5.0(svelte@5.0.0-next.143) + specifier: ^0.12.1 + version: 0.12.1(svelte@5.0.0-next.155) scule: specifier: ^1.3.0 version: 1.3.0 @@ -82,20 +82,20 @@ importers: version: 1.0.6 svelte-toolbelt: specifier: ^0.0.2 - version: 0.0.2(svelte@5.0.0-next.143) + version: 0.0.2(svelte@5.0.0-next.155) devDependencies: '@melt-ui/pp': specifier: ^0.3.0 - version: 0.3.2(@melt-ui/svelte@0.76.2(svelte@5.0.0-next.143))(svelte@5.0.0-next.143) + version: 0.3.2(@melt-ui/svelte@0.76.2(svelte@5.0.0-next.155))(svelte@5.0.0-next.155) '@sveltejs/kit': specifier: ^2.5.0 - version: 2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + version: 2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) '@sveltejs/package': specifier: ^2.2.7 - version: 2.3.1(svelte@5.0.0-next.143)(typescript@5.4.5) + version: 2.3.1(svelte@5.0.0-next.155)(typescript@5.4.5) '@sveltejs/vite-plugin-svelte': specifier: ^3.1.0 - version: 3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + version: 3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) '@testing-library/dom': specifier: ^10.0.0 version: 10.1.0 @@ -104,7 +104,7 @@ importers: version: 6.4.5(@types/jest@29.5.12)(vitest@1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0)) '@testing-library/svelte': specifier: ^5.0.1 - version: 5.1.0(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13))(vitest@1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0)) + version: 5.1.0(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13))(vitest@1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.1.0) @@ -139,11 +139,11 @@ importers: specifier: ^1.5.1 version: 1.5.1 svelte: - specifier: 5.0.0-next.143 - version: 5.0.0-next.143 + specifier: 5.0.0-next.155 + version: 5.0.0-next.155 svelte-check: specifier: ^3.6.9 - version: 3.8.0(postcss-load-config@5.1.0(jiti@1.21.0)(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.143) + version: 3.8.0(postcss-load-config@5.1.0(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.155) tslib: specifier: ^2.6.2 version: 2.6.2 @@ -164,26 +164,26 @@ importers: version: 3.5.4 '@melt-ui/svelte': specifier: 0.76.2 - version: 0.76.2(svelte@5.0.0-next.143) + version: 0.76.2(svelte@5.0.0-next.155) bits-ui: specifier: workspace:* version: link:../../packages/bits-ui devDependencies: '@melt-ui/pp': specifier: ^0.3.0 - version: 0.3.2(@melt-ui/svelte@0.76.2(svelte@5.0.0-next.143))(svelte@5.0.0-next.143) + version: 0.3.2(@melt-ui/svelte@0.76.2(svelte@5.0.0-next.155))(svelte@5.0.0-next.155) '@prettier/sync': specifier: 0.3.0 version: 0.3.0(prettier@3.2.5) '@sveltejs/adapter-cloudflare': specifier: ^4.2.0 - version: 4.4.0(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(wrangler@3.57.2(@cloudflare/workers-types@4.20240524.0)) + version: 4.4.0(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(wrangler@3.57.2(@cloudflare/workers-types@4.20240524.0)) '@sveltejs/kit': specifier: ^2.5.0 - version: 2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + version: 2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) '@sveltejs/vite-plugin-svelte': specifier: ^3.1.0 - version: 3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + version: 3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) '@tailwindcss/typography': specifier: ^0.5.10 version: 0.5.13(tailwindcss@3.4.3) @@ -216,13 +216,13 @@ importers: version: 3.0.1 mdsx: specifier: ^0.0.5 - version: 0.0.5(svelte@5.0.0-next.143) + version: 0.0.5(svelte@5.0.0-next.155) mode-watcher: specifier: ^0.2.0 - version: 0.2.2(svelte@5.0.0-next.143) + version: 0.2.2(svelte@5.0.0-next.155) phosphor-svelte: specifier: ^1.4.2 - version: 1.4.2(svelte@5.0.0-next.143) + version: 1.4.2(svelte@5.0.0-next.155) postcss: specifier: ^8.4.33 version: 8.4.38 @@ -242,11 +242,11 @@ importers: specifier: ^1.1.1 version: 1.6.1 svelte: - specifier: 5.0.0-next.143 - version: 5.0.0-next.143 + specifier: 5.0.0-next.155 + version: 5.0.0-next.155 svelte-check: specifier: ^3.6.9 - version: 3.8.0(postcss-load-config@5.1.0(jiti@1.21.0)(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.143) + version: 3.8.0(postcss-load-config@5.1.0(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.155) tailwind-merge: specifier: ^2.2.1 version: 2.3.0 @@ -4403,8 +4403,8 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - runed@0.5.0: - resolution: {integrity: sha512-nJ/36UhthXG1KNieQhxtvuoK0eHrgEesTkEwV/Tbo7HNka2QqQfoL5vq755ngisrufZl6oQVPJKPdan6msMDGw==} + runed@0.12.1: + resolution: {integrity: sha512-BlVXcGQ8+Rb7Klh2WO4y1em1Z15kYQMM0lG8bZhknAOKFUAyNHA5DgBhFNzKAUCCkgXf7GTmQng100Z5Xc7QwA==} peerDependencies: svelte: ^5.0.0 @@ -4762,8 +4762,8 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.0.0-next.143: - resolution: {integrity: sha512-hRm52FjYUfd24eUlkBS41JSmqHOx6wt0cV+wMzgwqhhxIpJoz96eiMcnvcLqXx+gTxM1m0Pt/+7xP3vlm2QvPg==} + svelte@5.0.0-next.155: + resolution: {integrity: sha512-4a4EZuiTmg4eQJuQ6LTyK+DxRAZCYm4mXgqSWcZ7TellzLfaC1Je5nxBl1aZP3xdNhvPFIstQ8c7I6d+99FdZQ==} engines: {node: '>=18'} symbol-tree@3.2.4: @@ -5345,7 +5345,7 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@antfu/eslint-config@2.19.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)(typescript@5.4.5)(vitest@1.6.0)': + '@antfu/eslint-config@2.19.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)(typescript@5.4.5)(vitest@1.6.0)': dependencies: '@antfu/install-pkg': 0.3.3 '@clack/prompts': 0.7.0 @@ -5365,7 +5365,7 @@ snapshots: eslint-plugin-markdown: 5.0.0(eslint@9.3.0) eslint-plugin-n: 17.7.0(eslint@9.3.0) eslint-plugin-no-only-tests: 3.1.0 - eslint-plugin-perfectionist: 2.10.0(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)(typescript@5.4.5)(vue-eslint-parser@9.4.2(eslint@9.3.0)) + eslint-plugin-perfectionist: 2.10.0(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)(typescript@5.4.5)(vue-eslint-parser@9.4.2(eslint@9.3.0)) eslint-plugin-regexp: 2.6.0(eslint@9.3.0) eslint-plugin-toml: 0.11.0(eslint@9.3.0) eslint-plugin-unicorn: 53.0.0(eslint@9.3.0) @@ -5384,8 +5384,8 @@ snapshots: yaml-eslint-parser: 1.2.3 yargs: 17.7.2 optionalDependencies: - eslint-plugin-svelte: 2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143) - svelte-eslint-parser: 0.34.1(svelte@5.0.0-next.143) + eslint-plugin-svelte: 2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155) + svelte-eslint-parser: 0.34.1(svelte@5.0.0-next.155) transitivePeerDependencies: - '@vue/compiler-sfc' - supports-color @@ -6050,9 +6050,9 @@ snapshots: '@humanwhocodes/retry@0.3.0': {} - '@huntabyte/eslint-config@0.3.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)(typescript@5.4.5)(vitest@1.6.0)': + '@huntabyte/eslint-config@0.3.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)(typescript@5.4.5)(vitest@1.6.0)': dependencies: - '@antfu/eslint-config': 2.19.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)(typescript@5.4.5)(vitest@1.6.0) + '@antfu/eslint-config': 2.19.1(@vue/compiler-sfc@3.4.27)(eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155))(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)(typescript@5.4.5)(vitest@1.6.0) '@antfu/install-pkg': 0.3.3 '@clack/prompts': 0.7.0 '@huntabyte/eslint-plugin': 0.1.0(eslint@9.3.0) @@ -6061,10 +6061,10 @@ snapshots: chalk: 5.3.0 eslint: 9.3.0 eslint-flat-config-utils: 0.2.5 - eslint-plugin-svelte: 2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143) + eslint-plugin-svelte: 2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155) local-pkg: 0.5.0 parse-gitignore: 2.0.0 - svelte-eslint-parser: 0.34.1(svelte@5.0.0-next.143) + svelte-eslint-parser: 0.34.1(svelte@5.0.0-next.155) yargs: 17.7.2 transitivePeerDependencies: - '@eslint-react/eslint-plugin' @@ -6201,14 +6201,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@melt-ui/pp@0.3.2(@melt-ui/svelte@0.76.2(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)': + '@melt-ui/pp@0.3.2(@melt-ui/svelte@0.76.2(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)': dependencies: - '@melt-ui/svelte': 0.76.2(svelte@5.0.0-next.143) + '@melt-ui/svelte': 0.76.2(svelte@5.0.0-next.155) estree-walker: 3.0.3 magic-string: 0.30.10 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 - '@melt-ui/svelte@0.76.2(svelte@5.0.0-next.143)': + '@melt-ui/svelte@0.76.2(svelte@5.0.0-next.155)': dependencies: '@floating-ui/core': 1.6.2 '@floating-ui/dom': 1.6.5 @@ -6216,7 +6216,7 @@ snapshots: dequal: 2.0.3 focus-trap: 7.5.4 nanoid: 5.0.7 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 '@nodelib/fs.scandir@2.1.5': dependencies: @@ -6478,17 +6478,17 @@ snapshots: - supports-color - typescript - '@sveltejs/adapter-cloudflare@4.4.0(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(wrangler@3.57.2(@cloudflare/workers-types@4.20240524.0))': + '@sveltejs/adapter-cloudflare@4.4.0(@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(wrangler@3.57.2(@cloudflare/workers-types@4.20240524.0))': dependencies: '@cloudflare/workers-types': 4.20240524.0 - '@sveltejs/kit': 2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + '@sveltejs/kit': 2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) esbuild: 0.20.2 worktop: 0.8.0-next.18 wrangler: 3.57.2(@cloudflare/workers-types@4.20240524.0) - '@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13))': + '@sveltejs/kit@2.5.10(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) '@types/cookie': 0.6.0 cookie: 0.6.0 devalue: 5.0.0 @@ -6500,39 +6500,39 @@ snapshots: sade: 1.8.1 set-cookie-parser: 2.6.0 sirv: 2.0.4 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 tiny-glob: 0.2.9 vite: 5.2.12(@types/node@20.12.13) - '@sveltejs/package@2.3.1(svelte@5.0.0-next.143)(typescript@5.4.5)': + '@sveltejs/package@2.3.1(svelte@5.0.0-next.155)(typescript@5.4.5)': dependencies: chokidar: 3.6.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.6.2 - svelte: 5.0.0-next.143 - svelte2tsx: 0.7.9(svelte@5.0.0-next.143)(typescript@5.4.5) + svelte: 5.0.0-next.155 + svelte2tsx: 0.7.9(svelte@5.0.0-next.155)(typescript@5.4.5) transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13))': + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + '@sveltejs/vite-plugin-svelte': 3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) debug: 4.3.4 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 vite: 5.2.12(@types/node@20.12.13) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13))': + '@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13)) + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)))(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13)) debug: 4.3.4 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.10 - svelte: 5.0.0-next.143 - svelte-hmr: 0.16.0(svelte@5.0.0-next.143) + svelte: 5.0.0-next.155 + svelte-hmr: 0.16.0(svelte@5.0.0-next.155) vite: 5.2.12(@types/node@20.12.13) vitefu: 0.2.5(vite@5.2.12(@types/node@20.12.13)) transitivePeerDependencies: @@ -6593,10 +6593,10 @@ snapshots: '@types/jest': 29.5.12 vitest: 1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0) - '@testing-library/svelte@5.1.0(svelte@5.0.0-next.143)(vite@5.2.12(@types/node@20.12.13))(vitest@1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0))': + '@testing-library/svelte@5.1.0(svelte@5.0.0-next.155)(vite@5.2.12(@types/node@20.12.13))(vitest@1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0))': dependencies: '@testing-library/dom': 9.3.4 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 optionalDependencies: vite: 5.2.12(@types/node@20.12.13) vitest: 1.6.0(@types/node@20.12.13)(@vitest/ui@1.6.0)(jsdom@24.1.0) @@ -7720,15 +7720,15 @@ snapshots: eslint-plugin-no-only-tests@3.1.0: {} - eslint-plugin-perfectionist@2.10.0(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143))(svelte@5.0.0-next.143)(typescript@5.4.5)(vue-eslint-parser@9.4.2(eslint@9.3.0)): + eslint-plugin-perfectionist@2.10.0(eslint@9.3.0)(svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155))(svelte@5.0.0-next.155)(typescript@5.4.5)(vue-eslint-parser@9.4.2(eslint@9.3.0)): dependencies: '@typescript-eslint/utils': 7.11.0(eslint@9.3.0)(typescript@5.4.5) eslint: 9.3.0 minimatch: 9.0.4 natural-compare-lite: 1.4.0 optionalDependencies: - svelte: 5.0.0-next.143 - svelte-eslint-parser: 0.34.1(svelte@5.0.0-next.143) + svelte: 5.0.0-next.155 + svelte-eslint-parser: 0.34.1(svelte@5.0.0-next.155) vue-eslint-parser: 9.4.2(eslint@9.3.0) transitivePeerDependencies: - supports-color @@ -7745,7 +7745,7 @@ snapshots: regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.143): + eslint-plugin-svelte@2.39.0(eslint@9.3.0)(svelte@5.0.0-next.155): dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.3.0) '@jridgewell/sourcemap-codec': 1.4.15 @@ -7759,9 +7759,9 @@ snapshots: postcss-safe-parser: 6.0.0(postcss@8.4.38) postcss-selector-parser: 6.1.0 semver: 7.6.2 - svelte-eslint-parser: 0.36.0(svelte@5.0.0-next.143) + svelte-eslint-parser: 0.36.0(svelte@5.0.0-next.155) optionalDependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 transitivePeerDependencies: - supports-color - ts-node @@ -9108,7 +9108,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdsx@0.0.5(svelte@5.0.0-next.143): + mdsx@0.0.5(svelte@5.0.0-next.155): dependencies: esrap: 1.2.2 hast-util-to-html: 9.0.1 @@ -9117,7 +9117,7 @@ snapshots: rehype-stringify: 10.0.0 remark-parse: 11.0.0 remark-rehype: 11.1.0 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 unified: 11.0.4 unist-util-visit: 5.0.0 vfile: 6.0.1 @@ -9645,9 +9645,9 @@ snapshots: pkg-types: 1.1.1 ufo: 1.5.3 - mode-watcher@0.2.2(svelte@5.0.0-next.143): + mode-watcher@0.2.2(svelte@5.0.0-next.155): dependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 mri@1.2.0: {} @@ -9884,9 +9884,9 @@ snapshots: estree-walker: 3.0.3 is-reference: 3.0.2 - phosphor-svelte@1.4.2(svelte@5.0.0-next.143): + phosphor-svelte@1.4.2(svelte@5.0.0-next.155): dependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 picocolors@1.0.1: {} @@ -9988,16 +9988,16 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-svelte@3.2.3(prettier@3.2.5)(svelte@5.0.0-next.143): + prettier-plugin-svelte@3.2.3(prettier@3.2.5)(svelte@5.0.0-next.155): dependencies: prettier: 3.2.5 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 - prettier-plugin-tailwindcss@0.5.13(prettier-plugin-svelte@3.2.3(prettier@3.2.5)(svelte@5.0.0-next.143))(prettier@3.2.5): + prettier-plugin-tailwindcss@0.5.13(prettier-plugin-svelte@3.2.3(prettier@3.2.5)(svelte@5.0.0-next.155))(prettier@3.2.5): dependencies: prettier: 3.2.5 optionalDependencies: - prettier-plugin-svelte: 3.2.3(prettier@3.2.5)(svelte@5.0.0-next.143) + prettier-plugin-svelte: 3.2.3(prettier@3.2.5)(svelte@5.0.0-next.155) prettier@2.8.8: {} @@ -10295,10 +10295,11 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.5.0(svelte@5.0.0-next.143): + runed@0.12.1(svelte@5.0.0-next.155): dependencies: + esm-env: 1.0.0 nanoid: 5.0.7 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 rxjs@7.8.1: dependencies: @@ -10595,7 +10596,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@3.8.0(postcss-load-config@5.1.0(jiti@1.21.0)(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.143): + svelte-check@3.8.0(postcss-load-config@5.1.0(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.155): dependencies: '@jridgewell/trace-mapping': 0.3.25 chokidar: 3.6.0 @@ -10603,8 +10604,8 @@ snapshots: import-fresh: 3.3.0 picocolors: 1.0.1 sade: 1.8.1 - svelte: 5.0.0-next.143 - svelte-preprocess: 5.1.4(postcss-load-config@5.1.0(jiti@1.21.0)(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.143)(typescript@5.4.5) + svelte: 5.0.0-next.155 + svelte-preprocess: 5.1.4(postcss-load-config@5.1.0(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.155)(typescript@5.4.5) typescript: 5.4.5 transitivePeerDependencies: - '@babel/core' @@ -10617,7 +10618,7 @@ snapshots: - stylus - sugarss - svelte-eslint-parser@0.34.1(svelte@5.0.0-next.143): + svelte-eslint-parser@0.34.1(svelte@5.0.0-next.155): dependencies: eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -10625,9 +10626,9 @@ snapshots: postcss: 8.4.38 postcss-scss: 4.0.9(postcss@8.4.38) optionalDependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 - svelte-eslint-parser@0.36.0(svelte@5.0.0-next.143): + svelte-eslint-parser@0.36.0(svelte@5.0.0-next.155): dependencies: eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -10635,37 +10636,37 @@ snapshots: postcss: 8.4.38 postcss-scss: 4.0.9(postcss@8.4.38) optionalDependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 - svelte-hmr@0.16.0(svelte@5.0.0-next.143): + svelte-hmr@0.16.0(svelte@5.0.0-next.155): dependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 - svelte-preprocess@5.1.4(postcss-load-config@5.1.0(jiti@1.21.0)(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.143)(typescript@5.4.5): + svelte-preprocess@5.1.4(postcss-load-config@5.1.0(postcss@8.4.38))(postcss@8.4.38)(svelte@5.0.0-next.155)(typescript@5.4.5): dependencies: '@types/pug': 2.0.10 detect-indent: 6.1.0 magic-string: 0.30.10 sorcery: 0.11.0 strip-indent: 3.0.0 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 optionalDependencies: postcss: 8.4.38 postcss-load-config: 5.1.0(jiti@1.21.0)(postcss@8.4.38) typescript: 5.4.5 - svelte-toolbelt@0.0.2(svelte@5.0.0-next.143): + svelte-toolbelt@0.0.2(svelte@5.0.0-next.155): dependencies: - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 - svelte2tsx@0.7.9(svelte@5.0.0-next.143)(typescript@5.4.5): + svelte2tsx@0.7.9(svelte@5.0.0-next.155)(typescript@5.4.5): dependencies: dedent-js: 1.0.1 pascal-case: 3.1.2 - svelte: 5.0.0-next.143 + svelte: 5.0.0-next.155 typescript: 5.4.5 - svelte@5.0.0-next.143: + svelte@5.0.0-next.155: dependencies: '@ampproject/remapping': 2.3.0 '@jridgewell/sourcemap-codec': 1.4.15 diff --git a/sites/docs/content/components/navigation-menu.md b/sites/docs/content/components/navigation-menu.md new file mode 100644 index 000000000..6e56a8482 --- /dev/null +++ b/sites/docs/content/components/navigation-menu.md @@ -0,0 +1,23 @@ +--- +title: Navigation Menu +description: A list of links that allow users to navigate between pages of a website. +--- + + + + + + + + + +## Structure + +```svelte + +``` diff --git a/sites/docs/package.json b/sites/docs/package.json index b95c165ef..b5e5c9a62 100644 --- a/sites/docs/package.json +++ b/sites/docs/package.json @@ -39,7 +39,7 @@ "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.0", "shiki": "^1.1.1", - "svelte": "5.0.0-next.143", + "svelte": "5.0.0-next.155", "svelte-check": "^3.6.9", "tailwind-merge": "^2.2.1", "tailwind-variants": "^0.1.20", diff --git a/sites/docs/src/lib/components/demos/index.ts b/sites/docs/src/lib/components/demos/index.ts index 77350037f..2d35e6c1e 100644 --- a/sites/docs/src/lib/components/demos/index.ts +++ b/sites/docs/src/lib/components/demos/index.ts @@ -17,6 +17,7 @@ export { default as DropdownMenuDemo } from "./dropdown-menu-demo.svelte"; export { default as LabelDemo } from "./label-demo.svelte"; export { default as LinkPreviewDemo } from "./link-preview-demo.svelte"; export { default as MenubarDemo } from "./menubar-demo.svelte"; +export { default as NavigationMenuDemo } from "./navigation-menu-demo.svelte"; export { default as PaginationDemo } from "./pagination-demo.svelte"; export { default as PinInputDemo } from "./pin-input-demo.svelte"; export { default as PopoverDemo } from "./popover-demo.svelte"; diff --git a/sites/docs/src/lib/components/demos/menubar-demo.svelte b/sites/docs/src/lib/components/demos/menubar-demo.svelte index af87c0289..dde2e1420 100644 --- a/sites/docs/src/lib/components/demos/menubar-demo.svelte +++ b/sites/docs/src/lib/components/demos/menubar-demo.svelte @@ -96,7 +96,7 @@ {#each views as view} {#snippet children({ checked })} diff --git a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte new file mode 100644 index 000000000..61f73bf65 --- /dev/null +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -0,0 +1,160 @@ + + +{#snippet ListItem({ className, title, content, href }: ListItemProps)} +
  • + +
    {title}
    +

    + {content} +

    +
    +
  • +{/snippet} + + + + + + Getting started + + +
      +
    • + + +
      Bits UI
      +

      + The headless components for Svelte. +

      +
      +
    • + + {@render ListItem({ + href: "/docs", + title: "Introduction", + content: "Headless components for Svelte and SvelteKit", + })} + {@render ListItem({ + href: "/docs/getting-started", + title: "Getting Started", + content: "How to install and use Bits UI", + })} + {@render ListItem({ + href: "/docs/styling", + title: "Styling", + content: "How to style Bits UI components", + })} +
    +
    +
    + + + Components + + +
      + {#each components as component (component.title)} + {@render ListItem({ + href: component.href, + title: component.title, + content: component.description, + })} + {/each} +
    +
    +
    + + + Documentation + + + +
    +
    +
    +
    + +
    +
    diff --git a/sites/docs/src/lib/config/navigation.ts b/sites/docs/src/lib/config/navigation.ts index c98094db7..4a4906e74 100644 --- a/sites/docs/src/lib/config/navigation.ts +++ b/sites/docs/src/lib/config/navigation.ts @@ -151,6 +151,11 @@ export const navigation: Navigation = { href: "/docs/components/menubar", items: [], }, + { + title: "Navigation Menu", + href: "/docs/components/navigation-menu", + items: [], + }, { title: "Pagination", href: "/docs/components/pagination", diff --git a/sites/docs/src/lib/content/api-reference/index.ts b/sites/docs/src/lib/content/api-reference/index.ts index a319d94c7..ee4181ab5 100644 --- a/sites/docs/src/lib/content/api-reference/index.ts +++ b/sites/docs/src/lib/content/api-reference/index.ts @@ -54,6 +54,7 @@ export const bits = [ "label", "link-preview", "menubar", + "navigation-menu", "pagination", "pin-input", "popover",