From eb0537544a1e7e0d80f666781335c21e9babeb6f Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 12 Jun 2024 19:49:32 -0400 Subject: [PATCH 01/18] a --- .../components/navigation-menu.svelte | 0 .../src/lib/bits/navigation-menu/index.ts | 0 .../navigation-menu/navigation-menu.svelte.ts | 349 ++++++++++++++++++ .../src/lib/bits/navigation-menu/types.ts | 0 4 files changed, 349 insertions(+) create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/index.ts create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/types.ts 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..e69de29bb 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..e69de29bb 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..15567ad6c --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -0,0 +1,349 @@ +import { untrack } from "svelte"; +import { box } from "svelte-toolbelt"; +import { getTabbableCandidates } from "../utilities/focus-scope/utils.js"; +import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; +import type { Direction, Orientation } from "$lib/shared/index.js"; +import { getDataOrientation } from "$lib/internal/attrs.js"; +import { createContext } from "$lib/internal/createContext.js"; +import { useId } from "$lib/internal/useId.svelte.js"; + +const [setNavigationMenuRootContext, getNavigationMenuRootContext] = + createContext("NavigationMenu.Root"); + +const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = + createContext("NavigationMenu.Provider"); + +const ROOT_ATTR = "data-navigation-menu-root"; +const SUB_ATTR = "data-navigation-menu-sub"; +const ITEM_ATTR = "data-navigation-menu-item"; + +type NavigationMenuRootStateProps = ReadableBoxedValues<{ + id: string; + delayDuration: number; + skipDelayDuration: number; + orientation: Orientation; + dir: Direction; +}> & + WritableBoxedValues<{ value: string }>; + +class NavigationMenuRootState { + id: NavigationMenuRootStateProps["id"]; + delayDuration: NavigationMenuRootStateProps["delayDuration"]; + skipDelayDuration: NavigationMenuRootStateProps["skipDelayDuration"]; + orientation: NavigationMenuRootStateProps["orientation"]; + dir: NavigationMenuRootStateProps["dir"]; + value: NavigationMenuRootStateProps["value"]; + openTimer = $state(0); + closeTimer = $state(0); + skipDelayTimer = $state(0); + isOpenDelayed = $state(true); + + 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; + + $effect(() => { + const isOpen = this.value.value !== ""; + untrack(() => { + const hasSkipDelayDuration = this.skipDelayDuration.value > 0; + + if (isOpen) { + window.clearTimeout(this.skipDelayTimer); + if (hasSkipDelayDuration) this.isOpenDelayed = true; + } 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); + }; + }); + } + setValue(v: string) { + this.value.value = v; + } + + startCloseTimer() { + window.clearTimeout(this.closeTimer); + this.closeTimer = window.setTimeout(() => this.setValue(""), 150); + } + + handleOpen(itemValue: string) { + window.clearTimeout(this.closeTimer); + this.setValue(itemValue); + } + + handleDelayedOpen(itemValue: string) { + const isOpen = this.value.value === itemValue; + if (isOpen) { + // 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); + }); + } + } + + 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 newValue = this.value.value === itemValue ? "" : itemValue; + this.setValue(newValue); + } + + onItemDismiss() { + this.setValue(""); + } + + props = $derived.by(() => ({ + "aria-label": "Main", + "data-orientation": getDataOrientation(this.orientation.value), + dir: this.dir.value, + [ROOT_ATTR]: "", + })); + + createProvider(props: NavigationMenuProviderStateProps) { + return new NavigationMenuProviderState({ + ...props, + isRoot: false, + rootNavigationId: this.id, + }); + } +} + +type NavigationMenuSubStateProps = ReadableBoxedValues<{ + id: string; + orientation: Orientation; + dir: Direction; +}> & + WritableBoxedValues<{ value: string }>; + +class NavigationMenuSubState { + id: NavigationMenuSubStateProps["id"]; + orientation: NavigationMenuSubStateProps["orientation"]; + dir: NavigationMenuSubStateProps["dir"]; + value: NavigationMenuSubStateProps["value"]; + + constructor(props: NavigationMenuSubStateProps) { + this.id = props.id; + this.orientation = props.orientation; + this.dir = props.dir; + this.value = props.value; + } + setValue(v: string) { + this.value.value = v; + } + + onTriggerEnter(itemValue: string) { + this.setValue(itemValue); + } + + onItemSelect(itemValue: string) { + this.setValue(itemValue); + } + + onItemDismiss() { + this.setValue(""); + } + + props = $derived.by(() => ({ + "data-orientation": getDataOrientation(this.orientation.value), + [SUB_ATTR]: "", + })); +} + +type NavigationMenuProviderStateProps = 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; + }; + +class NavigationMenuProviderState { + isRoot: NavigationMenuProviderStateProps["isRoot"] = $state(false); + rootNavigationId: NavigationMenuProviderStateProps["rootNavigationId"]; + dir: NavigationMenuProviderStateProps["dir"]; + orientation: NavigationMenuProviderStateProps["orientation"]; + value: NavigationMenuProviderStateProps["value"]; + onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"]; + onTriggerLeave: NavigationMenuProviderStateProps["onTriggerLeave"]; + onContentEnter: NavigationMenuProviderStateProps["onContentEnter"]; + onContentLeave: NavigationMenuProviderStateProps["onContentLeave"]; + onItemSelect: NavigationMenuProviderStateProps["onItemSelect"]; + onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"]; + viewportId = box.with(() => undefined); + viewportContentId = box.with(() => undefined); + indicatorTrackId = box.with(() => undefined); + + constructor(props: NavigationMenuProviderStateProps) { + 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; + } +} + +type NavigationMenuListStateProps = ReadableBoxedValues<{ + id: string; +}>; + +class NavigationMenuListState { + id: NavigationMenuListStateProps["id"]; + indicatorId = box(useId()); + + constructor( + props: NavigationMenuListStateProps, + private provider: NavigationMenuProviderState + ) { + this.id = props.id; + } + + indicatorProps = $derived.by( + () => + ({ + id: this.indicatorId.value, + style: { + position: "relative", + }, + }) as const + ); + + props = $derived.by( + () => + ({ + "data-orientation": getDataOrientation(this.provider.orientation.value), + }) as const + ); +} + +type NavigationMenuItemStateProps = ReadableBoxedValues<{ + id: string; + value: string; +}>; + +class NavigationMenuItemState { + id: NavigationMenuItemStateProps["id"]; + value: NavigationMenuItemStateProps["value"]; + contentId = box.with(() => undefined); + triggerId = box.with(() => undefined); + focusProxyId = box.with(() => undefined); + restoreContentTabOrder = $state(() => {}); + wasEscapeClose = $state(false); + + constructor( + props: NavigationMenuItemStateProps, + private provider: NavigationMenuProviderState + ) { + this.id = props.id; + this.value = props.value; + } + + getContentNode() { + return document.getElementById(this.contentId.value ?? ""); + } + + getTriggerNode() { + return document.getElementById(this.triggerId.value ?? ""); + } + + getFocusProxyNode() { + return document.getElementById(this.focusProxyId.value ?? ""); + } + + handleContentEntry(side: "start" | "end" = "start") { + const contentNode = this.getContentNode(); + if (!contentNode) return; + this.restoreContentTabOrder(); + const candidates = getTabbableCandidates(contentNode); + if (candidates.length) { + focusFirst(side === "start" ? candidates : candidates.reverse()); + } + } + + handleContentExit() { + const contentNode = this.getContentNode(); + if (!contentNode) return; + const candidates = getTabbableCandidates(contentNode); + if (candidates.length) { + this.restoreContentTabOrder = removeFromTabOrder(candidates); + } + } + + props = $derived.by( + () => + ({ + id: this.id.value, + [ITEM_ATTR]: "", + }) as const + ); +} + +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); + }); + }; +} 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..e69de29bb From 34d7b99a2caac15bf7f4c0f0aa16c18044aace37 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 14 Jun 2024 22:06:11 -0400 Subject: [PATCH 02/18] idk --- eslint.config.js | 13 +- packages/bits-ui/package.json | 2 +- packages/bits-ui/src/lib/bits/index.ts | 1 + .../components/navigation-menu-content.svelte | 66 ++ .../navigation-menu-indicator.svelte | 41 + .../components/navigation-menu-item.svelte | 32 + .../components/navigation-menu-link.svelte | 35 + .../components/navigation-menu-list.svelte | 33 + .../components/navigation-menu-trigger.svelte | 40 + .../navigation-menu-viewport.svelte | 30 + .../components/navigation-menu.svelte | 50 + .../src/lib/bits/navigation-menu/index.ts | 20 + .../navigation-menu/navigation-menu.svelte.ts | 910 +++++++++++++++--- .../src/lib/bits/navigation-menu/types.ts | 175 ++++ packages/bits-ui/src/lib/internal/types.ts | 4 + .../src/lib/internal/useArrowNavigation.ts | 168 ++++ packages/bits-ui/src/lib/types.ts | 1 + pnpm-lock.yaml | 11 +- .../content/components/navigation-menu.md | 23 + sites/docs/src/lib/components/demos/index.ts | 1 + .../demos/navigation-menu-demo.svelte | 160 +++ sites/docs/src/lib/config/navigation.ts | 5 + .../src/lib/content/api-reference/index.ts | 1 + 23 files changed, 1688 insertions(+), 134 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-item.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-link.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte create mode 100644 packages/bits-ui/src/lib/internal/useArrowNavigation.ts create mode 100644 sites/docs/content/components/navigation-menu.md create mode 100644 sites/docs/src/lib/components/demos/navigation-menu-demo.svelte 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/packages/bits-ui/package.json b/packages/bits-ui/package.json index 96313d71d..0467998cc 100644 --- a/packages/bits-ui/package.json +++ b/packages/bits-ui/package.json @@ -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..1aac4a617 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -0,0 +1,66 @@ + + +{#if isMounted.current} + + + {#snippet presence({ present })} + + + {#snippet children({ props: dismissableProps })} + + {@const finalProps = mergeProps(mergedProps, dismissableProps)} + {#if asChild} + {@render child?.({ props: finalProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
+ {/snippet} +
+
+ {/snippet} +
+
+{/if} 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..34cbfcbe8 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte @@ -0,0 +1,41 @@ + + +{#if indicatorState.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..5d8df47a9 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte @@ -0,0 +1,33 @@ + + +
    + {#if asChild} + {@render child?.({ props: mergedProps })} + {:else} +
      + {@render children?.()} +
    + {/if} +
    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..0f34dc45d --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte @@ -0,0 +1,40 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} + +{/if} + +{#if triggerState.open} + + {#if triggerState.menu.viewportId.value} + + {/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..32cbd6ffd --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte @@ -0,0 +1,30 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} +
    + {@render children?.()} +
    +{/if} 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 index e69de29bb..ee0396f34 100644 --- 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 @@ -0,0 +1,50 @@ + + +{#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 index e69de29bb..eaddb3d25 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/index.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/index.ts @@ -0,0 +1,20 @@ +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, + NavigationMenuSubProps as SubProps, + 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 index 15567ad6c..af8da1fc8 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -1,21 +1,43 @@ import { untrack } from "svelte"; -import { box } from "svelte-toolbelt"; +import { box, type WritableBox } from "svelte-toolbelt"; +import { useDebounce } from "runed"; import { getTabbableCandidates } from "../utilities/focus-scope/utils.js"; import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; import type { Direction, Orientation } from "$lib/shared/index.js"; -import { getDataOrientation } from "$lib/internal/attrs.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 { isBrowser } from "$lib/internal/is.js"; +import { useArrowNavigation } from "$lib/internal/useArrowNavigation.js"; +import { boxAutoReset } from "$lib/internal/boxAutoReset.svelte.js"; const [setNavigationMenuRootContext, getNavigationMenuRootContext] = createContext("NavigationMenu.Root"); -const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = - createContext("NavigationMenu.Provider"); +const [setNavigationMenuMenuContext, getNavigationMenuMenuContext] = + createContext("NavigationMenu.Root or NavigationMenu.Sub"); + +const [setNavigationMenuItemContext, getNavigationMenuItemContext] = + createContext("NavigationMenu.Item"); + +const EVENT_ROOT_CONTENT_DISMISS = "navigationMenu.rootContentDismiss"; 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; @@ -33,10 +55,19 @@ class NavigationMenuRootState { orientation: NavigationMenuRootStateProps["orientation"]; dir: NavigationMenuRootStateProps["dir"]; value: NavigationMenuRootStateProps["value"]; - openTimer = $state(0); - closeTimer = $state(0); - skipDelayTimer = $state(0); + previousValue = box(""); isOpenDelayed = $state(true); + triggerIds = new Set(); + isDelaySkipped: WritableBox; + derivedDelay = $derived.by(() => { + const isOpen = this.value.value !== ""; + if (isOpen || this.isDelaySkipped.value) return 150; + return this.delayDuration.value; + }); + setValue = useDebounce((v: string) => { + this.previousValue.value = this.value.value; + this.value.value = v; + }, 1000); constructor(props: NavigationMenuRootStateProps) { this.id = props.id; @@ -45,85 +76,53 @@ class NavigationMenuRootState { this.orientation = props.orientation; this.dir = props.dir; this.value = props.value; - - $effect(() => { - const isOpen = this.value.value !== ""; - untrack(() => { - const hasSkipDelayDuration = this.skipDelayDuration.value > 0; - - if (isOpen) { - window.clearTimeout(this.skipDelayTimer); - if (hasSkipDelayDuration) this.isOpenDelayed = true; - } 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); - }; - }); - } - setValue(v: string) { - this.value.value = v; + this.isDelaySkipped = boxAutoReset(false, this.skipDelayDuration.value); } - startCloseTimer() { - window.clearTimeout(this.closeTimer); - this.closeTimer = window.setTimeout(() => this.setValue(""), 150); - } - - handleOpen(itemValue: string) { - window.clearTimeout(this.closeTimer); - this.setValue(itemValue); - } + getNode = () => { + return document.getElementById(this.id.value); + }; - handleDelayedOpen(itemValue: string) { - const isOpen = this.value.value === itemValue; - if (isOpen) { - // 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); - }); - } - } + registerTriggerId = (triggerId: string) => { + this.triggerIds.add(triggerId); + }; - onTriggerEnter(itemValue: string) { - window.clearTimeout(this.openTimer); - if (this.isOpenDelayed) this.handleDelayedOpen(itemValue); - else this.handleOpen(itemValue); - } + deRegisterTriggerId = (triggerId: string) => { + this.triggerIds.delete(triggerId); + }; - onTriggerLeave() { - window.clearTimeout(this.openTimer); - this.startCloseTimer(); - } + getTriggerNodes = () => { + return Array.from(this.triggerIds) + .map((triggerId) => document.getElementById(triggerId)) + .filter((node): node is HTMLElement => Boolean(node)); + }; - onContentEnter() { - window.clearTimeout(this.closeTimer); - } + onTriggerEnter = (itemValue: string) => { + this.setValue(itemValue); + }; - onContentLeave = this.startCloseTimer; + onTriggerLeave = () => { + this.isDelaySkipped.value = false; + this.setValue(""); + }; - onItemSelect(itemValue: string) { - const newValue = this.value.value === itemValue ? "" : itemValue; - this.setValue(newValue); - } + onContentEnter = (itemValue: string) => { + this.setValue(itemValue); + }; - onItemDismiss() { + onContentLeave = () => { this.setValue(""); - } + }; + + onItemSelect = (itemValue: string) => { + this.previousValue.value = this.value.value; + this.value.value = itemValue; + }; + + onItemDismiss = () => { + this.previousValue.value = this.value.value; + this.value.value = ""; + }; props = $derived.by(() => ({ "aria-label": "Main", @@ -132,12 +131,8 @@ class NavigationMenuRootState { [ROOT_ATTR]: "", })); - createProvider(props: NavigationMenuProviderStateProps) { - return new NavigationMenuProviderState({ - ...props, - isRoot: false, - rootNavigationId: this.id, - }); + createMenu(props: NavigationMenuMenuStateProps) { + return new NavigationMenuMenuState(props, this); } } @@ -153,28 +148,46 @@ class NavigationMenuSubState { orientation: NavigationMenuSubStateProps["orientation"]; dir: NavigationMenuSubStateProps["dir"]; value: NavigationMenuSubStateProps["value"]; + triggerIds = new Set(); + root: NavigationMenuRootState; - constructor(props: NavigationMenuSubStateProps) { + constructor(props: NavigationMenuSubStateProps, root: NavigationMenuRootState) { this.id = props.id; this.orientation = props.orientation; this.dir = props.dir; this.value = props.value; + this.root = root; } - setValue(v: string) { + + registerTriggerId = (triggerId: string) => { + this.triggerIds.add(triggerId); + }; + + deRegisterTriggerId = (triggerId: string) => { + this.triggerIds.delete(triggerId); + }; + + getTriggerNodes = () => { + return Array.from(this.triggerIds) + .map((triggerId) => document.getElementById(triggerId)) + .filter((node): node is HTMLElement => Boolean(node)); + }; + + setValue = (v: string) => { this.value.value = v; - } + }; - onTriggerEnter(itemValue: string) { + onTriggerEnter = (itemValue: string) => { this.setValue(itemValue); - } + }; - onItemSelect(itemValue: string) { + onItemSelect = (itemValue: string) => { this.setValue(itemValue); - } + }; - onItemDismiss() { + onItemDismiss = () => { this.setValue(""); - } + }; props = $derived.by(() => ({ "data-orientation": getDataOrientation(this.orientation.value), @@ -182,51 +195,86 @@ class NavigationMenuSubState { })); } -type NavigationMenuProviderStateProps = ReadableBoxedValues<{ +type NavigationMenuMenuStateProps = ReadableBoxedValues<{ rootNavigationId: string; dir: Direction; orientation: Orientation; }> & WritableBoxedValues<{ value: string; + previousValue: string; }> & { isRoot: boolean; onTriggerEnter: (itemValue: string) => void; onTriggerLeave?: () => void; - onContentEnter?: () => void; + onContentEnter?: (itemValue: string) => void; onContentLeave?: () => void; onItemSelect: (itemValue: string) => void; onItemDismiss: () => void; + getTriggerNodes: () => HTMLElement[]; + registerTriggerId: (triggerId: string) => void; + deRegisterTriggerId: (triggerId: string) => void; }; -class NavigationMenuProviderState { - isRoot: NavigationMenuProviderStateProps["isRoot"] = $state(false); - rootNavigationId: NavigationMenuProviderStateProps["rootNavigationId"]; - dir: NavigationMenuProviderStateProps["dir"]; - orientation: NavigationMenuProviderStateProps["orientation"]; - value: NavigationMenuProviderStateProps["value"]; - onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"]; - onTriggerLeave: NavigationMenuProviderStateProps["onTriggerLeave"]; - onContentEnter: NavigationMenuProviderStateProps["onContentEnter"]; - onContentLeave: NavigationMenuProviderStateProps["onContentLeave"]; - onItemSelect: NavigationMenuProviderStateProps["onItemSelect"]; - onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"]; +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"]; + getTriggerNodes: NavigationMenuMenuStateProps["getTriggerNodes"]; + registerTriggerId: NavigationMenuMenuStateProps["registerTriggerId"]; + deRegisterTriggerId: NavigationMenuMenuStateProps["deRegisterTriggerId"]; viewportId = box.with(() => undefined); viewportContentId = box.with(() => undefined); indicatorTrackId = box.with(() => undefined); + root: NavigationMenuRootState; - constructor(props: NavigationMenuProviderStateProps) { + 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; + console.log(props.onTriggerEnter); 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.deRegisterTriggerId = props.deRegisterTriggerId; + this.registerTriggerId = props.registerTriggerId; + this.getTriggerNodes = props.getTriggerNodes; + this.root = root; + this.previousValue = props.previousValue; + } + + getViewportNode = () => { + return document.getElementById(this.viewportId.value ?? ""); + }; + + 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); } } @@ -240,7 +288,7 @@ class NavigationMenuListState { constructor( props: NavigationMenuListStateProps, - private provider: NavigationMenuProviderState + private menu: NavigationMenuMenuState ) { this.id = props.id; } @@ -258,7 +306,8 @@ class NavigationMenuListState { props = $derived.by( () => ({ - "data-orientation": getDataOrientation(this.provider.orientation.value), + "data-orientation": getDataOrientation(this.menu.orientation.value), + [LIST_ATTR]: "", }) as const ); } @@ -273,31 +322,30 @@ class NavigationMenuItemState { value: NavigationMenuItemStateProps["value"]; contentId = box.with(() => undefined); triggerId = box.with(() => undefined); - focusProxyId = box.with(() => undefined); + focusProxyId = box.with(() => useId()); restoreContentTabOrder = $state(() => {}); wasEscapeClose = $state(false); + menu: NavigationMenuMenuState; - constructor( - props: NavigationMenuItemStateProps, - private provider: NavigationMenuProviderState - ) { + constructor(props: NavigationMenuItemStateProps, menu: NavigationMenuMenuState) { this.id = props.id; this.value = props.value; + this.menu = menu; } - getContentNode() { + getContentNode = () => { return document.getElementById(this.contentId.value ?? ""); - } + }; - getTriggerNode() { + getTriggerNode = () => { return document.getElementById(this.triggerId.value ?? ""); - } + }; - getFocusProxyNode() { + getFocusProxyNode = () => { return document.getElementById(this.focusProxyId.value ?? ""); - } + }; - handleContentEntry(side: "start" | "end" = "start") { + #handleContentEntry = (side: "start" | "end" = "start") => { const contentNode = this.getContentNode(); if (!contentNode) return; this.restoreContentTabOrder(); @@ -305,16 +353,21 @@ class NavigationMenuItemState { if (candidates.length) { focusFirst(side === "start" ? candidates : candidates.reverse()); } - } + }; - handleContentExit() { + #handleContentExit = () => { const contentNode = this.getContentNode(); if (!contentNode) return; const candidates = getTabbableCandidates(contentNode); if (candidates.length) { this.restoreContentTabOrder = removeFromTabOrder(candidates); } - } + }; + + onEntryKeydown = this.#handleContentEntry; + onFocusProxyEnter = this.#handleContentEntry; + onContentFocusOutside = this.#handleContentExit; + onRootContentClose = this.#handleContentExit; props = $derived.by( () => @@ -323,8 +376,599 @@ class NavigationMenuItemState { [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; +}>; + +class NavigationMenuTriggerState { + menu: NavigationMenuMenuState; + item: NavigationMenuItemState; + disabled: NavigationMenuTriggerStateProps["disabled"]; + hasPointerMoveOpened = boxAutoReset(false, 300); + wasClickClose = $state(false); + open = $derived.by(() => this.item.value.value === this.menu.value.value); + + constructor(props: NavigationMenuTriggerStateProps, item: NavigationMenuItemState) { + this.item = item; + this.menu = item.menu; + this.item.triggerId = props.id; + this.disabled = props.disabled; + } + + #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 = () => { + // 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(); + e.stopPropagation(); + } + }; + + props = $derived.by( + () => + ({ + id: this.item.triggerId.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.contentId.value ? this.item.contentId.value : 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 contentNode = this.item.getContentNode(); + const prevFocusedElement = e.relatedTarget as HTMLElement | null; + const wasTriggerFocused = prevFocusedElement === this.item.getFocusProxyNode(); + const wasFocusFromContent = contentNode?.contains(prevFocusedElement); + + if (wasTriggerFocused || !wasFocusFromContent) { + 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) { + // TODO: handle dismiss + } + }; + + props = $derived.by( + () => + ({ + id: this.id.value, + "data-active": this.active.value ? "" : undefined, + "aria-current": this.active.value ? "page" : undefined, + onclick: this.#onclick, + }) as const + ); +} + +type NavigationMenuIndicatorStateProps = ReadableBoxedValues<{ + id: string; +}>; + +class NavigationMenuIndicatorState { + id: NavigationMenuIndicatorStateProps["id"]; + menu: NavigationMenuMenuState; + 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)); + indicatorTrackNode = $state(null); + + constructor(props: NavigationMenuIndicatorStateProps, menu: NavigationMenuMenuState) { + this.id = props.id; + this.menu = menu; + + $effect(() => { + console.log("3"); + const triggerNodes = this.menu.getTriggerNodes(); + const triggerNode = triggerNodes.find( + (node) => node.dataset.value === this.menu.value.value + ); + if (triggerNode) { + untrack(() => { + this.activeTrigger = triggerNode; + }); + } + }); + + $effect(() => { + console.log("4"); + const indicatorTrackNode = document.getElementById( + this.menu.indicatorTrackId.value ?? "" + ); + untrack(() => (this.indicatorTrackNode = indicatorTrackNode)); + }); + + useResizeObserver(() => this.activeTrigger, this.handlePositionChange); + useResizeObserver(() => this.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; +}>; + +type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; + +class NavigationMenuContentState { + id: NavigationMenuContentStateProps["id"]; + menu: NavigationMenuMenuState; + item: NavigationMenuItemState; + prevMotionAttribute = $state(null); + motionAttribute = $state(null); + open = $derived.by(() => this.menu.value.value === this.item.value.value); + isLastActiveValue = $derived.by(() => { + if (!isBrowser) return false; + if (this.menu.viewportId.value) { + const viewportNode = document.getElementById(this.menu.viewportId.value); + if (viewportNode) { + if (!this.menu.value.value && this.menu.previousValue.value) { + return this.menu.previousValue.value === this.item.value.value; + } + } + } + return false; + }); + + constructor(props: NavigationMenuContentStateProps, item: NavigationMenuItemState) { + this.id = props.id; + this.item = item; + this.menu = item.menu; + + $effect(() => { + console.log("1"); + const contentNode = this.getNode(); + if (this.menu.isRoot && contentNode) { + // bubble dimiss to the root content node and focus its trigger + const handleClose = () => { + this.menu.onItemDismiss(); + this.item.onRootContentClose(); + if (contentNode.contains(document.activeElement)) { + this.item.getTriggerNode()?.focus(); + } + }; + + contentNode.addEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); + + return () => { + contentNode.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); + }; + } + }); + + $effect(() => { + console.log("menu.value.value", this.menu.value.value); + }); + $effect(() => { + console.log("item.value.value", this.item.value.value); + }); + + $effect(() => { + const items = untrack(() => this.menu.getTriggerNodes()); + const prev = untrack(() => this.menu.previousValue.value); + 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 = untrack(() => this.item.value.value === this.menu.value.value); + const wasSelected = untrack(() => 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) { + untrack(() => (this.motionAttribute = this.prevMotionAttribute)); + } + + untrack(() => { + 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; + }); + }); + } + + getNode = () => { + return document.getElementById(this.id.value); + }; + + onFocusOutside = (e: Event) => { + if (e.defaultPrevented) return; + this.item.onContentFocusOutside(); + const target = e.target as HTMLElement; + // only dismiss content when focus moves outside the menu + if (this.menu.root.getNode()?.contains(target)) { + e.preventDefault(); + } + }; + + 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.getViewportNode()?.contains(target); + + if (isTrigger || isRootViewport || !this.menu.isRoot) { + e.preventDefault(); + } + }; + + onEscapeKeydown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + this.menu.onItemDismiss(); + this.item.getTriggerNode()?.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(); + } 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.getFocusProxyNode()?.focus(); + } + } + 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.triggerId.value, + "data-motion": this.motionAttribute, + "data-state": getDataOpenClosed(this.menu.value.value === this.item.value.value), + "data-orientation": getDataOrientation(this.menu.orientation.value), + [CONTENT_ATTR]: "", + onkeydown: this.#onkeydown, + }) as const + ); } +type NavigationMenuViewportStateProps = ReadableBoxedValues<{ + id: string; +}>; + +class NavigationMenuViewportState { + id: NavigationMenuViewportStateProps["id"]; + menu: NavigationMenuMenuState; + size = $state<{ width: number; height: number } | null>(null); + open = $derived.by(() => this.menu.value.value !== ""); + activeContentValue = $derived.by(() => this.menu.value.value); + contentNode = $state(); + + constructor(props: NavigationMenuViewportStateProps, menu: NavigationMenuMenuState) { + this.id = props.id; + this.menu = menu; + this.menu.viewportId = props.id; + + $effect(() => { + this.open; + this.activeContentValue; + const currentNode = untrack(() => this.getNode()); + if (!currentNode) return; + untrack(() => { + this.contentNode = currentNode.querySelector("[data-state=open]") + ?.children?.[0] as HTMLElement | null; + }); + }); + + useResizeObserver( + () => this.contentNode, + () => { + if (this.contentNode) { + this.size = { + width: this.contentNode.offsetWidth, + height: this.contentNode.offsetHeight, + }; + } + } + ); + } + + getNode = () => { + return document.getElementById(this.id.value); + }; + + #onpointerenter = () => { + this.menu.onContentEnter?.(this.menu.value.value); + }; + + #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 + ); +} + +// Context Methods + +export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { + const rootState = getNavigationMenuRootContext(); + const menuState = rootState.createMenu({ + getTriggerNodes: rootState.getTriggerNodes, + rootNavigationId: rootState.id, + dir: rootState.dir, + orientation: rootState.orientation, + value: rootState.value, + isRoot: false, + deRegisterTriggerId: rootState.deRegisterTriggerId, + registerTriggerId: rootState.registerTriggerId, + onTriggerEnter: rootState.onTriggerEnter, + onItemSelect: rootState.onItemSelect, + onItemDismiss: rootState.onItemDismiss, + onContentEnter: rootState.onContentEnter, + onContentLeave: rootState.onContentLeave, + onTriggerLeave: rootState.onTriggerLeave, + previousValue: box(""), + }); + + setNavigationMenuMenuContext(menuState); + return new NavigationMenuSubState( + { + dir: props.dir, + id: props.id, + orientation: props.orientation, + value: props.value, + }, + rootState + ); +} + +export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { + const rootState = new NavigationMenuRootState(props); + const menuState = rootState.createMenu({ + getTriggerNodes: rootState.getTriggerNodes, + rootNavigationId: rootState.id, + dir: rootState.dir, + orientation: rootState.orientation, + value: rootState.value, + isRoot: true, + deRegisterTriggerId: rootState.deRegisterTriggerId, + registerTriggerId: rootState.registerTriggerId, + 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(new NavigationMenuRootState(props)); +} + +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 new NavigationMenuLinkState(props); +} + +/// Utils + function focusFirst(candidates: HTMLElement[]) { const previouslyFocusedElement = document.activeElement; return candidates.some((candidate) => { @@ -347,3 +991,23 @@ function removeFromTabOrder(candidates: HTMLElement[]) { }); }; } + +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 index e69de29bb..e1ff842de 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/types.ts +++ 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/internal/types.ts b/packages/bits-ui/src/lib/internal/types.ts index aebf3ad28..b550f7df6 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,6 +134,9 @@ 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 }]>; 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/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..20aa66921 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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.143) scule: specifier: ^1.3.0 version: 1.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 @@ -10295,8 +10295,9 @@ 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.143): dependencies: + esm-env: 1.0.0 nanoid: 5.0.7 svelte: 5.0.0-next.143 diff --git a/sites/docs/content/components/navigation-menu.md b/sites/docs/content/components/navigation-menu.md new file mode 100644 index 000000000..f816706b0 --- /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 or sections of a website. +--- + + + + + + + + + +## Structure + +```svelte + +``` 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/navigation-menu-demo.svelte b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte new file mode 100644 index 000000000..30ca5a9fd --- /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", From 9219f8bfb567d6137ebd92033e274ddc4ebd63af Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 14 Jun 2024 22:31:33 -0400 Subject: [PATCH 03/18] infinite loop hell --- .../components/navigation-menu-content.svelte | 30 +- .../navigation-menu/navigation-menu.svelte.ts | 317 +++++++----------- 2 files changed, 136 insertions(+), 211 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index 1aac4a617..f10702fdf 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -8,7 +8,6 @@ import { PresenceLayer } from "$lib/bits/utilities/presence-layer/index.js"; import EscapeLayer from "$lib/bits/utilities/escape-layer/escape-layer.svelte"; import DismissableLayer from "$lib/bits/utilities/dismissable-layer/dismissable-layer.svelte"; - import TextSelectionLayer from "$lib/bits/utilities/text-selection-layer/text-selection-layer.svelte"; import { IsMounted } from "runed"; import { isBrowser } from "$lib/internal/is.js"; @@ -33,30 +32,25 @@ if (!node) return undefined; }); const portalDisabled = $derived(!Boolean(contentState.menu.viewportId.value)); - - const isMounted = new IsMounted(); + const mounted = new IsMounted(); -{#if isMounted.current} +{#if mounted.current} - + {#snippet presence({ present })} {#snippet children({ props: dismissableProps })} - - {@const finalProps = mergeProps(mergedProps, dismissableProps)} - {#if asChild} - {@render child?.({ props: finalProps })} - {:else} -
    - {@render children?.()} -
    - {/if} -
    + {@const finalProps = mergeProps(mergedProps, dismissableProps)} + + {#if asChild} + {@render child?.({ props: finalProps })} + {:else} +
    + {@render children?.()} +
    + {/if} {/snippet}
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts index af8da1fc8..b8f7af5f9 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -1,6 +1,6 @@ import { untrack } from "svelte"; import { box, type WritableBox } from "svelte-toolbelt"; -import { useDebounce } from "runed"; +import { Previous, useDebounce } from "runed"; import { getTabbableCandidates } from "../utilities/focus-scope/utils.js"; import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; import type { Direction, Orientation } from "$lib/shared/index.js"; @@ -55,19 +55,19 @@ class NavigationMenuRootState { orientation: NavigationMenuRootStateProps["orientation"]; dir: NavigationMenuRootStateProps["dir"]; value: NavigationMenuRootStateProps["value"]; - previousValue = box(""); - isOpenDelayed = $state(true); + previousValue = new Previous(() => this.value.value); triggerIds = new Set(); isDelaySkipped: WritableBox; derivedDelay = $derived.by(() => { - const isOpen = this.value.value !== ""; - if (isOpen || this.isDelaySkipped.value) return 150; + const isOpen = this.value?.value !== ""; + if (isOpen || this.isDelaySkipped?.value) return 150; return this.delayDuration.value; }); + setValue = useDebounce((v: string) => { - this.previousValue.value = this.value.value; + if (this.value.value === v) return; this.value.value = v; - }, 1000); + }, this.derivedDelay); constructor(props: NavigationMenuRootStateProps) { this.id = props.id; @@ -115,12 +115,10 @@ class NavigationMenuRootState { }; onItemSelect = (itemValue: string) => { - this.previousValue.value = this.value.value; this.value.value = itemValue; }; onItemDismiss = () => { - this.previousValue.value = this.value.value; this.value.value = ""; }; @@ -136,65 +134,6 @@ class NavigationMenuRootState { } } -type NavigationMenuSubStateProps = ReadableBoxedValues<{ - id: string; - orientation: Orientation; - dir: Direction; -}> & - WritableBoxedValues<{ value: string }>; - -class NavigationMenuSubState { - id: NavigationMenuSubStateProps["id"]; - orientation: NavigationMenuSubStateProps["orientation"]; - dir: NavigationMenuSubStateProps["dir"]; - value: NavigationMenuSubStateProps["value"]; - triggerIds = new Set(); - root: NavigationMenuRootState; - - constructor(props: NavigationMenuSubStateProps, root: NavigationMenuRootState) { - this.id = props.id; - this.orientation = props.orientation; - this.dir = props.dir; - this.value = props.value; - this.root = root; - } - - registerTriggerId = (triggerId: string) => { - this.triggerIds.add(triggerId); - }; - - deRegisterTriggerId = (triggerId: string) => { - this.triggerIds.delete(triggerId); - }; - - getTriggerNodes = () => { - return Array.from(this.triggerIds) - .map((triggerId) => document.getElementById(triggerId)) - .filter((node): node is HTMLElement => Boolean(node)); - }; - - setValue = (v: string) => { - this.value.value = v; - }; - - onTriggerEnter = (itemValue: string) => { - this.setValue(itemValue); - }; - - onItemSelect = (itemValue: string) => { - this.setValue(itemValue); - }; - - onItemDismiss = () => { - this.setValue(""); - }; - - props = $derived.by(() => ({ - "data-orientation": getDataOrientation(this.orientation.value), - [SUB_ATTR]: "", - })); -} - type NavigationMenuMenuStateProps = ReadableBoxedValues<{ rootNavigationId: string; dir: Direction; @@ -202,7 +141,6 @@ type NavigationMenuMenuStateProps = ReadableBoxedValues<{ }> & WritableBoxedValues<{ value: string; - previousValue: string; }> & { isRoot: boolean; onTriggerEnter: (itemValue: string) => void; @@ -214,6 +152,7 @@ type NavigationMenuMenuStateProps = ReadableBoxedValues<{ getTriggerNodes: () => HTMLElement[]; registerTriggerId: (triggerId: string) => void; deRegisterTriggerId: (triggerId: string) => void; + previousValue: Previous; }; class NavigationMenuMenuState { @@ -243,7 +182,6 @@ class NavigationMenuMenuState { this.dir = props.dir; this.orientation = props.orientation; this.value = props.value; - console.log(props.onTriggerEnter); this.onTriggerEnter = props.onTriggerEnter; this.onTriggerLeave = props.onTriggerLeave; this.onContentEnter = props.onContentEnter; @@ -434,7 +372,7 @@ class NavigationMenuTriggerState { this.hasPointerMoveOpened.value = false; }; - #onclick = () => { + #onclick = (e: PointerEvent) => { // if opened via pointer move, we prevent clicke event if (this.hasPointerMoveOpened.value) return; if (this.open) { @@ -556,10 +494,9 @@ class NavigationMenuIndicatorState { this.menu = menu; $effect(() => { - console.log("3"); const triggerNodes = this.menu.getTriggerNodes(); const triggerNode = triggerNodes.find( - (node) => node.dataset.value === this.menu.value.value + (node) => node.dataset.value === untrack(() => this.menu.value.value) ); if (triggerNode) { untrack(() => { @@ -569,7 +506,6 @@ class NavigationMenuIndicatorState { }); $effect(() => { - console.log("4"); const indicatorTrackNode = document.getElementById( this.menu.indicatorTrackId.value ?? "" ); @@ -581,15 +517,17 @@ class NavigationMenuIndicatorState { } 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, - }; + untrack(() => { + 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( @@ -633,91 +571,84 @@ class NavigationMenuContentState { prevMotionAttribute = $state(null); motionAttribute = $state(null); open = $derived.by(() => this.menu.value.value === this.item.value.value); - isLastActiveValue = $derived.by(() => { - if (!isBrowser) return false; - if (this.menu.viewportId.value) { - const viewportNode = document.getElementById(this.menu.viewportId.value); - if (viewportNode) { - if (!this.menu.value.value && this.menu.previousValue.value) { - return this.menu.previousValue.value === this.item.value.value; - } - } - } - return false; - }); + // isLastActiveValue = $derived.by(() => { + // if (!isBrowser) return false; + // if (this.menu.viewportId.value) { + // const viewportNode = document.getElementById(this.menu.viewportId.value); + // if (viewportNode) { + // if (!this.menu.value.value && this.menu.previousValue.current) { + // return this.menu.previousValue.current === this.item.value.value; + // } + // } + // } + // return false; + // }); constructor(props: NavigationMenuContentStateProps, item: NavigationMenuItemState) { this.id = props.id; this.item = item; this.menu = item.menu; - $effect(() => { - console.log("1"); - const contentNode = this.getNode(); - if (this.menu.isRoot && contentNode) { - // bubble dimiss to the root content node and focus its trigger - const handleClose = () => { - this.menu.onItemDismiss(); - this.item.onRootContentClose(); - if (contentNode.contains(document.activeElement)) { - this.item.getTriggerNode()?.focus(); - } - }; - - contentNode.addEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); - - return () => { - contentNode.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); - }; - } - }); - - $effect(() => { - console.log("menu.value.value", this.menu.value.value); - }); - $effect(() => { - console.log("item.value.value", this.item.value.value); - }); - - $effect(() => { - const items = untrack(() => this.menu.getTriggerNodes()); - const prev = untrack(() => this.menu.previousValue.value); - 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 = untrack(() => this.item.value.value === this.menu.value.value); - const wasSelected = untrack(() => 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) { - untrack(() => (this.motionAttribute = this.prevMotionAttribute)); - } - - untrack(() => { - 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; - }); - }); + // $effect(() => { + // console.log("1"); + // const contentNode = this.getNode(); + // if (this.menu.isRoot && contentNode) { + // // bubble dimiss to the root content node and focus its trigger + // const handleClose = () => { + // this.menu.onItemDismiss(); + // this.item.onRootContentClose(); + // if (contentNode.contains(document.activeElement)) { + // this.item.getTriggerNode()?.focus(); + // } + // }; + + // contentNode.addEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); + + // return () => { + // contentNode.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); + // }; + // } + // }); + + // $effect(() => { + // const items = untrack(() => this.menu.getTriggerNodes()); + // const prev = untrack(() => this.menu.previousValue.value); + // 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 = untrack(() => this.item.value.value === this.menu.value.value); + // const wasSelected = untrack(() => 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) { + // untrack(() => (this.motionAttribute = this.prevMotionAttribute)); + // } + + // untrack(() => { + // 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; + // }); + // }); } getNode = () => { @@ -881,37 +812,37 @@ class NavigationMenuViewportState { // Context Methods -export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { - const rootState = getNavigationMenuRootContext(); - const menuState = rootState.createMenu({ - getTriggerNodes: rootState.getTriggerNodes, - rootNavigationId: rootState.id, - dir: rootState.dir, - orientation: rootState.orientation, - value: rootState.value, - isRoot: false, - deRegisterTriggerId: rootState.deRegisterTriggerId, - registerTriggerId: rootState.registerTriggerId, - onTriggerEnter: rootState.onTriggerEnter, - onItemSelect: rootState.onItemSelect, - onItemDismiss: rootState.onItemDismiss, - onContentEnter: rootState.onContentEnter, - onContentLeave: rootState.onContentLeave, - onTriggerLeave: rootState.onTriggerLeave, - previousValue: box(""), - }); - - setNavigationMenuMenuContext(menuState); - return new NavigationMenuSubState( - { - dir: props.dir, - id: props.id, - orientation: props.orientation, - value: props.value, - }, - rootState - ); -} +// export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { +// const rootState = getNavigationMenuRootContext(); +// const menuState = rootState.createMenu({ +// getTriggerNodes: rootState.getTriggerNodes, +// rootNavigationId: rootState.id, +// dir: rootState.dir, +// orientation: rootState.orientation, +// value: rootState.value, +// isRoot: false, +// deRegisterTriggerId: rootState.deRegisterTriggerId, +// registerTriggerId: rootState.registerTriggerId, +// onTriggerEnter: rootState.onTriggerEnter, +// onItemSelect: rootState.onItemSelect, +// onItemDismiss: rootState.onItemDismiss, +// onContentEnter: rootState.onContentEnter, +// onContentLeave: rootState.onContentLeave, +// onTriggerLeave: rootState.onTriggerLeave, +// previousValue: new Previous, +// }); + +// setNavigationMenuMenuContext(menuState); +// return new NavigationMenuSubState( +// { +// dir: props.dir, +// id: props.id, +// orientation: props.orientation, +// value: props.value, +// }, +// rootState +// ); +// } export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { const rootState = new NavigationMenuRootState(props); From f1f27f019c5ea2f13dde701964657843237c4e98 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 14 Jun 2024 22:38:51 -0400 Subject: [PATCH 04/18] somewhere over the rainbow --- .../components/navigation-menu-content.svelte | 24 +-- .../navigation-menu/navigation-menu.svelte.ts | 158 +++++++++--------- 2 files changed, 88 insertions(+), 94 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index f10702fdf..e796ea553 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -31,7 +31,7 @@ const node = document.getElementById(viewportId ?? ""); if (!node) return undefined; }); - const portalDisabled = $derived(!Boolean(contentState.menu.viewportId.value)); + const portalDisabled = $derived(!Boolean(viewportNode)); const mounted = new IsMounted(); @@ -39,21 +39,13 @@ {#snippet presence({ present })} - - - {#snippet children({ props: dismissableProps })} - {@const finalProps = mergeProps(mergedProps, dismissableProps)} - - {#if asChild} - {@render child?.({ props: finalProps })} - {:else} -
    - {@render children?.()} -
    - {/if} - {/snippet} -
    -
    + {#if asChild} + {@render child?.({ props: mergedProps })} + {:else} +
    + {@render children?.()} +
    + {/if} {/snippet}
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts index b8f7af5f9..1acd1d55a 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -571,84 +571,84 @@ class NavigationMenuContentState { prevMotionAttribute = $state(null); motionAttribute = $state(null); open = $derived.by(() => this.menu.value.value === this.item.value.value); - // isLastActiveValue = $derived.by(() => { - // if (!isBrowser) return false; - // if (this.menu.viewportId.value) { - // const viewportNode = document.getElementById(this.menu.viewportId.value); - // if (viewportNode) { - // if (!this.menu.value.value && this.menu.previousValue.current) { - // return this.menu.previousValue.current === this.item.value.value; - // } - // } - // } - // return false; - // }); + isLastActiveValue = $derived.by(() => { + if (!isBrowser) return false; + if (this.menu.viewportId.value) { + const viewportNode = document.getElementById(this.menu.viewportId.value); + if (viewportNode) { + if (!this.menu.value.value && this.menu.previousValue.current) { + return this.menu.previousValue.current === this.item.value.value; + } + } + } + return false; + }); constructor(props: NavigationMenuContentStateProps, item: NavigationMenuItemState) { this.id = props.id; this.item = item; this.menu = item.menu; - // $effect(() => { - // console.log("1"); - // const contentNode = this.getNode(); - // if (this.menu.isRoot && contentNode) { - // // bubble dimiss to the root content node and focus its trigger - // const handleClose = () => { - // this.menu.onItemDismiss(); - // this.item.onRootContentClose(); - // if (contentNode.contains(document.activeElement)) { - // this.item.getTriggerNode()?.focus(); - // } - // }; - - // contentNode.addEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); - - // return () => { - // contentNode.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); - // }; - // } - // }); - - // $effect(() => { - // const items = untrack(() => this.menu.getTriggerNodes()); - // const prev = untrack(() => this.menu.previousValue.value); - // 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 = untrack(() => this.item.value.value === this.menu.value.value); - // const wasSelected = untrack(() => 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) { - // untrack(() => (this.motionAttribute = this.prevMotionAttribute)); - // } - - // untrack(() => { - // 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; - // }); - // }); + $effect(() => { + console.log("1"); + const contentNode = this.getNode(); + if (this.menu.isRoot && contentNode) { + // bubble dimiss to the root content node and focus its trigger + const handleClose = () => { + this.menu.onItemDismiss(); + this.item.onRootContentClose(); + if (contentNode.contains(document.activeElement)) { + this.item.getTriggerNode()?.focus(); + } + }; + + contentNode.addEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); + + return () => { + contentNode.removeEventListener(EVENT_ROOT_CONTENT_DISMISS, handleClose); + }; + } + }); + + $effect(() => { + const items = untrack(() => this.menu.getTriggerNodes()); + const prev = untrack(() => 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 = untrack(() => this.item.value.value === this.menu.value.value); + const wasSelected = untrack(() => 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) { + untrack(() => (this.motionAttribute = this.prevMotionAttribute)); + } + + untrack(() => { + 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; + }); + }); } getNode = () => { @@ -766,12 +766,14 @@ class NavigationMenuViewportState { useResizeObserver( () => this.contentNode, () => { - if (this.contentNode) { - this.size = { - width: this.contentNode.offsetWidth, - height: this.contentNode.offsetHeight, - }; - } + untrack(() => { + if (this.contentNode) { + this.size = { + width: this.contentNode.offsetWidth, + height: this.contentNode.offsetHeight, + }; + } + }); } ); } From 0fa8ff5e15bc8fe4ee4a78e143986a37e1cc1aa5 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 14 Jun 2024 22:53:20 -0400 Subject: [PATCH 05/18] closer --- .../components/navigation-menu-content.svelte | 28 +++++++++++++------ .../navigation-menu/navigation-menu.svelte.ts | 17 +++++++++-- .../demos/navigation-menu-demo.svelte | 2 +- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index e796ea553..8e4a81654 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -26,18 +26,16 @@ }); const mergedProps = $derived(mergeProps(restProps, contentState.props)); const viewportId = $derived(contentState.menu.viewportId.value); - const viewportNode = $derived.by(() => { - if (!isBrowser) return undefined; - const node = document.getElementById(viewportId ?? ""); - if (!node) return undefined; - }); - const portalDisabled = $derived(!Boolean(viewportNode)); + const portalDisabled = $derived(!Boolean(contentState.menu.viewportNode)); const mounted = new IsMounted(); -{#if mounted.current} - - +{#if mounted.current && contentState.menu.viewportNode} + + {#snippet presence({ present })} {#if asChild} {@render child?.({ props: mergedProps })} @@ -49,4 +47,16 @@ {/snippet} +{:else} + + {#snippet presence({ present })} + {#if asChild} + {@render child?.({ props: mergedProps })} + {:else} +
    + {@render children?.()} +
    + {/if} + {/snippet} +
    {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts index 1acd1d55a..08c7a823f 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -18,6 +18,7 @@ import { kbd } from "$lib/internal/kbd.js"; import { isBrowser } from "$lib/internal/is.js"; import { useArrowNavigation } from "$lib/internal/useArrowNavigation.js"; import { boxAutoReset } from "$lib/internal/boxAutoReset.svelte.js"; +import { afterTick } from "$lib/internal/afterTick.js"; const [setNavigationMenuRootContext, getNavigationMenuRootContext] = createContext("NavigationMenu.Root"); @@ -171,7 +172,8 @@ class NavigationMenuMenuState { getTriggerNodes: NavigationMenuMenuStateProps["getTriggerNodes"]; registerTriggerId: NavigationMenuMenuStateProps["registerTriggerId"]; deRegisterTriggerId: NavigationMenuMenuStateProps["deRegisterTriggerId"]; - viewportId = box.with(() => undefined); + viewportId = box(""); + viewportNode = $state(null); viewportContentId = box.with(() => undefined); indicatorTrackId = box.with(() => undefined); root: NavigationMenuRootState; @@ -193,6 +195,14 @@ class NavigationMenuMenuState { this.getTriggerNodes = props.getTriggerNodes; this.root = root; this.previousValue = props.previousValue; + + $effect(() => { + if (this.viewportId.value) { + afterTick(() => { + this.viewportNode = document.getElementById(this.viewportId.value ?? ""); + }); + } + }); } getViewportNode = () => { @@ -750,7 +760,10 @@ class NavigationMenuViewportState { constructor(props: NavigationMenuViewportStateProps, menu: NavigationMenuMenuState) { this.id = props.id; this.menu = menu; - this.menu.viewportId = props.id; + + $effect(() => { + this.menu.viewportId.value = props.id.value; + }); $effect(() => { this.open; diff --git a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte index 30ca5a9fd..02ddb6113 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -154,7 +154,7 @@
    From 805e5c3c7649bb2820cf3ed4bf0bb2539fdfc2e9 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 14 Jun 2024 23:35:37 -0400 Subject: [PATCH 06/18] closer --- .../components/navigation-menu-content.svelte | 63 +++++++---- .../navigation-menu/navigation-menu.svelte.ts | 104 +++++++++--------- .../useDismissableLayer.svelte.ts | 1 - .../demos/navigation-menu-demo.svelte | 4 +- 4 files changed, 92 insertions(+), 80 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index 8e4a81654..2d5392065 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -6,14 +6,13 @@ import { mergeProps } from "$lib/internal/mergeProps.js"; import Portal from "$lib/bits/utilities/portal/portal.svelte"; import { PresenceLayer } from "$lib/bits/utilities/presence-layer/index.js"; - import EscapeLayer from "$lib/bits/utilities/escape-layer/escape-layer.svelte"; - import DismissableLayer from "$lib/bits/utilities/dismissable-layer/dismissable-layer.svelte"; import { IsMounted } from "runed"; - import { isBrowser } from "$lib/internal/is.js"; + import { untrack } from "svelte"; + import DismissableLayer from "$lib/bits/utilities/dismissable-layer/dismissable-layer.svelte"; let { asChild, - children, + children: contentChildren, child, ref = $bindable(), id = useId(), @@ -25,38 +24,54 @@ id: box.with(() => id), }); const mergedProps = $derived(mergeProps(restProps, contentState.props)); - const viewportId = $derived(contentState.menu.viewportId.value); const portalDisabled = $derived(!Boolean(contentState.menu.viewportNode)); const mounted = new IsMounted(); + + const isPresent = $derived(forceMount || contentState.open || contentState.isLastActiveValue); {#if mounted.current && contentState.menu.viewportNode} - + {#snippet presence({ present })} - {#if asChild} - {@render child?.({ props: mergedProps })} - {:else} -
    - {@render children?.()} -
    - {/if} + + {#snippet children({ props: dismissableProps })} + {#if asChild} + {@render child?.({ props: mergeProps(mergedProps, dismissableProps) })} + {:else} +
    + {@render contentChildren?.()} +
    + {/if} + {/snippet} +
    {/snippet}
    {:else} - + {#snippet presence({ present })} - {#if asChild} - {@render child?.({ props: mergedProps })} - {:else} -
    - {@render children?.()} -
    - {/if} + + {#snippet children({ props: dismissableProps })} + {#if asChild} + {@render child?.({ props: mergeProps(mergedProps, dismissableProps) })} + {:else} +
    + {@render contentChildren?.()} +
    + {/if} + {/snippet} +
    {/snippet}
    {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts index 08c7a823f..4e3fe6eac 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -506,7 +506,7 @@ class NavigationMenuIndicatorState { $effect(() => { const triggerNodes = this.menu.getTriggerNodes(); const triggerNode = triggerNodes.find( - (node) => node.dataset.value === untrack(() => this.menu.value.value) + (node) => node.dataset.value === this.menu.value.value ); if (triggerNode) { untrack(() => { @@ -527,17 +527,15 @@ class NavigationMenuIndicatorState { } handlePositionChange = () => { - untrack(() => { - if (!this.activeTrigger) return; - this.position = { - size: this.isHorizontal - ? this.activeTrigger.offsetWidth - : this.activeTrigger.offsetHeight, - offset: this.isHorizontal - ? this.activeTrigger.offsetLeft - : this.activeTrigger.offsetTop, - }; - }); + 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( @@ -583,12 +581,10 @@ class NavigationMenuContentState { open = $derived.by(() => this.menu.value.value === this.item.value.value); isLastActiveValue = $derived.by(() => { if (!isBrowser) return false; - if (this.menu.viewportId.value) { - const viewportNode = document.getElementById(this.menu.viewportId.value); - if (viewportNode) { - if (!this.menu.value.value && this.menu.previousValue.current) { - return this.menu.previousValue.current === this.item.value.value; - } + if (this.menu.viewportNode) { + console.log("this.menu.viewportNode", this.menu.viewportNode); + if (!this.menu.value.value && this.menu.previousValue.current) { + return this.menu.previousValue.current === this.item.value.value; } } return false; @@ -600,7 +596,13 @@ class NavigationMenuContentState { this.menu = item.menu; $effect(() => { - console.log("1"); + console.log("open", this.open); + }); + $effect(() => { + console.log("isLastActiveValue", this.isLastActiveValue); + }); + + $effect(() => { const contentNode = this.getNode(); if (this.menu.isRoot && contentNode) { // bubble dimiss to the root content node and focus its trigger @@ -621,43 +623,41 @@ class NavigationMenuContentState { }); $effect(() => { - const items = untrack(() => this.menu.getTriggerNodes()); - const prev = untrack(() => this.menu.previousValue.current); + 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 = untrack(() => this.item.value.value === this.menu.value.value); - const wasSelected = untrack(() => prevIndex === values.indexOf(this.item.value.value)); + 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) { - untrack(() => (this.motionAttribute = this.prevMotionAttribute)); + this.motionAttribute = this.prevMotionAttribute; } - untrack(() => { - 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"; - } + 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"; } - // 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; - }); + // 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; }); } @@ -679,7 +679,7 @@ class NavigationMenuContentState { 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.getViewportNode()?.contains(target); + const isRootViewport = this.menu.isRoot && this.menu.viewportNode?.contains(target); if (isTrigger || isRootViewport || !this.menu.isRoot) { e.preventDefault(); @@ -779,14 +779,12 @@ class NavigationMenuViewportState { useResizeObserver( () => this.contentNode, () => { - untrack(() => { - if (this.contentNode) { - this.size = { - width: this.contentNode.offsetWidth, - height: this.contentNode.offsetHeight, - }; - } - }); + if (this.contentNode) { + this.size = { + width: this.contentNode.offsetWidth, + height: this.contentNode.offsetHeight, + }; + } } ); } @@ -880,7 +878,7 @@ export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { }); setNavigationMenuMenuContext(menuState); - return setNavigationMenuRootContext(new NavigationMenuRootState(props)); + return setNavigationMenuRootContext(rootState); } export function useNavigationMenuList(props: NavigationMenuListStateProps) { 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..5a588865c 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 @@ -21,7 +21,6 @@ import { noop, useNodeById, } from "$lib/internal/index.js"; -import { eventLogs } from "$lib/bits/index.js"; const layers = new Map>(); diff --git a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte index 02ddb6113..430494175 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -112,7 +112,7 @@ - + Date: Fri, 14 Jun 2024 23:37:44 -0400 Subject: [PATCH 07/18] cleanup menubar demo --- sites/docs/src/lib/components/demos/menubar-demo.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 })} From eabf11760964d7075adda594219906ed7f02f838 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 14 Jun 2024 23:44:42 -0400 Subject: [PATCH 08/18] bits --- .../navigation-menu/navigation-menu.svelte.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts index 4e3fe6eac..a795721cd 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -356,6 +356,14 @@ class NavigationMenuTriggerState { this.menu = item.menu; this.item.triggerId = props.id; this.disabled = props.disabled; + + $effect(() => { + this.menu.registerTriggerId(props.id.value); + + return () => { + this.menu.deRegisterTriggerId(props.id.value); + }; + }); } #onpointerenter = () => { @@ -582,7 +590,6 @@ class NavigationMenuContentState { isLastActiveValue = $derived.by(() => { if (!isBrowser) return false; if (this.menu.viewportNode) { - console.log("this.menu.viewportNode", this.menu.viewportNode); if (!this.menu.value.value && this.menu.previousValue.current) { return this.menu.previousValue.current === this.item.value.value; } @@ -595,13 +602,6 @@ class NavigationMenuContentState { this.item = item; this.menu = item.menu; - $effect(() => { - console.log("open", this.open); - }); - $effect(() => { - console.log("isLastActiveValue", this.isLastActiveValue); - }); - $effect(() => { const contentNode = this.getNode(); if (this.menu.isRoot && contentNode) { @@ -656,6 +656,7 @@ class NavigationMenuContentState { // entirely and should not animate in any direction return null; })(); + this.prevMotionAttribute = attribute; this.motionAttribute = attribute; }); From 7b87c086e88ab2e86bce0102763b3f797222b9ee Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 15 Jun 2024 15:11:58 -0400 Subject: [PATCH 09/18] more --- package.json | 2 +- packages/bits-ui/package.json | 2 +- .../components/navigation-menu-content.svelte | 44 +--- .../navigation-menu-indicator.svelte | 12 +- .../components/navigation-menu-list.svelte | 11 +- .../components/navigation-menu-trigger.svelte | 12 +- .../navigation-menu-viewport.svelte | 26 +- .../components/navigation-menu.svelte | 8 +- .../navigation-menu/navigation-menu.svelte.ts | 244 ++++++++++-------- .../lib/bits/utilities/portal/portal.svelte | 24 +- .../presence-layer/usePresence.svelte.ts | 7 +- packages/bits-ui/src/lib/internal/types.ts | 4 +- .../src/lib/internal/useNodeById.svelte.ts | 54 +++- pnpm-lock.yaml | 190 +++++++------- sites/docs/package.json | 2 +- .../demos/navigation-menu-demo.svelte | 4 +- 16 files changed, 368 insertions(+), 278 deletions(-) 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 0467998cc..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", diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index 2d5392065..5b99d39aa 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -7,14 +7,13 @@ import Portal from "$lib/bits/utilities/portal/portal.svelte"; import { PresenceLayer } from "$lib/bits/utilities/presence-layer/index.js"; import { IsMounted } from "runed"; - import { untrack } from "svelte"; import DismissableLayer from "$lib/bits/utilities/dismissable-layer/dismissable-layer.svelte"; let { asChild, children: contentChildren, child, - ref = $bindable(), + ref = $bindable(null), id = useId(), forceMount = false, ...restProps @@ -22,45 +21,28 @@ const contentState = useNavigationMenuContent({ id: box.with(() => id), + ref: box.with( + () => ref, + (v) => { + ref = v; + } + ), + forceMount: box.with(() => forceMount), }); + const mergedProps = $derived(mergeProps(restProps, contentState.props)); const portalDisabled = $derived(!Boolean(contentState.menu.viewportNode)); const mounted = new IsMounted(); - - const isPresent = $derived(forceMount || contentState.open || contentState.isLastActiveValue); -{#if mounted.current && contentState.menu.viewportNode} - - - {#snippet presence({ present })} - - {#snippet children({ props: dismissableProps })} - {#if asChild} - {@render child?.({ props: mergeProps(mergedProps, dismissableProps) })} - {:else} -
    - {@render contentChildren?.()} -
    - {/if} - {/snippet} -
    - {/snippet} -
    -
    -{:else} - + + {#snippet presence({ present })} {#snippet children({ props: dismissableProps })} {#if asChild} @@ -74,4 +56,4 @@ {/snippet} -{/if} + 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 index 34cbfcbe8..2f44da5cf 100644 --- 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 @@ -9,7 +9,7 @@ let { id = useId(), - ref = $bindable(), + ref = $bindable(null), asChild, children, child, @@ -19,19 +19,23 @@ const indicatorState = useNavigationMenuIndicator({ id: box.with(() => id), + ref: box.with( + () => ref, + (v) => (ref = v) + ), }); const mergedProps = $derived(mergeProps(restProps, indicatorState.props)); -{#if indicatorState.indicatorTrackNode} - +{#if indicatorState.menu.indicatorTrackNode} + {#snippet presence()} {#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 index 5d8df47a9..fdb801d6b 100644 --- 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 @@ -10,19 +10,24 @@ asChild, children, child, - ref = $bindable(), + ref = $bindable(null), ...restProps }: ListProps = $props(); const listState = useNavigationMenuList({ id: box.with(() => id), + ref: box.with( + () => ref, + (v) => (ref = v) + ), + indicatorTrackRef: box(null), }); const mergedProps = $derived(mergeProps(restProps, listState.props)); - const indicatorProps = $derived(mergeProps(listState.indicatorProps, {})); + const indicatorTrackProps = $derived(mergeProps(listState.indicatorTrackProps, {})); -
    +
    {#if asChild} {@render child?.({ props: mergedProps })} {:else} 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 index 0f34dc45d..4b96c91f6 100644 --- 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 @@ -12,13 +12,17 @@ asChild, children, child, - ref = $bindable(), + ref = $bindable(null), ...restProps }: TriggerProps = $props(); const triggerState = useNavigationMenuTrigger({ id: box.with(() => id), disabled: box.with(() => disabled), + ref: box.with( + () => ref, + (v) => (ref = v) + ), }); const mergedProps = $derived(mergeProps(restProps, triggerState.props)); @@ -27,14 +31,14 @@ {#if asChild} {@render child?.({ props: mergedProps })} {:else} - {/if} {#if triggerState.open} - {#if triggerState.menu.viewportId.value} - + {#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 index 32cbd6ffd..68cbd9add 100644 --- 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 @@ -4,27 +4,37 @@ import { useNavigationMenuViewport } from "../navigation-menu.svelte.js"; import { useId } from "$lib/internal/useId.svelte.js"; import { mergeProps } from "$lib/internal/mergeProps.js"; + import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte"; let { id = useId(), - ref = $bindable(), + ref = $bindable(null), asChild, children, child, + forceMount = false, ...restProps }: ViewportProps = $props(); const viewportState = useNavigationMenuViewport({ id: box.with(() => id), + ref: box.with( + () => ref, + (v) => (ref = v) + ), }); const mergedProps = $derived(mergeProps(restProps, viewportState.props)); -{#if asChild} - {@render child?.({ props: mergedProps })} -{:else} -
    - {@render children?.()} -
    -{/if} + + {#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 index ee0396f34..a468a6217 100644 --- 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 @@ -11,7 +11,7 @@ child, children, id = useId(), - ref = $bindable(), + ref = $bindable(null), value = $bindable(""), onValueChange = noop, delayDuration = 200, @@ -36,6 +36,10 @@ skipDelayDuration: box.with(() => skipDelayDuration), dir: box.with(() => dir), orientation: box.with(() => orientation), + ref: box.with( + () => ref, + (v) => (ref = v) + ), }); const mergedProps = $derived(mergeProps({ "aria-label": "main" }, restProps, rootState.props)); @@ -44,7 +48,7 @@ {#if asChild} {@render child?.({ props: mergedProps })} {:else} -