From efb683609a73d92c9a249458e1d6a766d22266ed Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 13 Dec 2024 21:29:51 -0500 Subject: [PATCH 1/8] init --- packages/bits-ui/src/lib/bits/index.ts | 1 + .../components/navigation-menu-content.svelte | 82 +++ .../navigation-menu-indicator.svelte | 43 ++ .../components/navigation-menu-item.svelte | 34 + .../components/navigation-menu-link.svelte | 37 + .../components/navigation-menu-list.svelte | 36 + .../components/navigation-menu-trigger.svelte | 47 ++ .../navigation-menu-viewport.svelte | 38 + .../components/navigation-menu.svelte | 55 ++ .../src/lib/bits/navigation-menu-2/exports.ts | 19 + .../src/lib/bits/navigation-menu-2/index.ts | 1 + .../navigation-menu.svelte.ts | 679 ++++++++++++++++++ .../src/lib/bits/navigation-menu-2/types.ts | 185 +++++ packages/bits-ui/src/lib/internal/events.ts | 23 +- 14 files changed, 1277 insertions(+), 3 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index c19d8477e..91979b5a7 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -19,6 +19,7 @@ export { Label } from "./label/index.js"; export { LinkPreview } from "./link-preview/index.js"; export { Menubar } from "./menubar/index.js"; export { NavigationMenu } from "./navigation-menu/index.js"; +export { NavigationMenu as NavMenu } from "./navigation-menu-2/index.js"; export { Pagination } from "./pagination/index.js"; export { PinInput } from "./pin-input/index.js"; export { Popover } from "./popover/index.js"; diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte new file mode 100644 index 000000000..fbb286b29 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte @@ -0,0 +1,82 @@ + + + + + {#snippet presence()} + { + onEscapeKeydown?.(e); + if (e.defaultPrevented) return; + contentState.onEscapeKeydown(e); + }} + > + { + onInteractOutside?.(e); + if (e.defaultPrevented) return; + contentState.onInteractOutside(e); + }} + onFocusOutside={(e) => { + onFocusOutside?.(e); + if (e.defaultPrevented) return; + contentState.onFocusOutside(e); + }} + > + {#snippet children({ props: dismissibleProps })} + {#if child} + + {@render child({ props: mergeProps(dismissibleProps, mergedProps) })} + {:else} + +
+ {@render contentChildren?.()} +
+ {/if} + {/snippet} +
+
+ {/snippet} +
+
diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte new file mode 100644 index 000000000..0829e60a3 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte @@ -0,0 +1,43 @@ + + +{#if indicatorState.menu.indicatorTrackNode} + + + {#snippet presence()} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
+ {@render children?.()} +
+ {/if} + {/snippet} +
+
+{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte new file mode 100644 index 000000000..93eecb3e5 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
  • + {@render children?.()} +
  • +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte new file mode 100644 index 000000000..6f482aa2b --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte new file mode 100644 index 000000000..4d509970d --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte @@ -0,0 +1,36 @@ + + +
    + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte new file mode 100644 index 000000000..b4c9c2dc2 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte @@ -0,0 +1,47 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} + +{#if triggerState.open} + + + {#if triggerState.menu.viewportNode} + + {/if} +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte new file mode 100644 index 000000000..99f6f4d31 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte @@ -0,0 +1,38 @@ + + + + {#snippet presence()} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
    + {@render children?.()} +
    + {/if} + {/snippet} +
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte new file mode 100644 index 000000000..b0a88c4e4 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte @@ -0,0 +1,55 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts new file mode 100644 index 000000000..edfa842a0 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts @@ -0,0 +1,19 @@ +export { default as Root } from "./components/navigation-menu.svelte"; +export { default as Content } from "./components/navigation-menu-content.svelte"; +export { default as Indicator } from "./components/navigation-menu-indicator.svelte"; +export { default as Item } from "./components/navigation-menu-item.svelte"; +export { default as Link } from "./components/navigation-menu-link.svelte"; +export { default as List } from "./components/navigation-menu-list.svelte"; +export { default as Trigger } from "./components/navigation-menu-trigger.svelte"; +export { default as Viewport } from "./components/navigation-menu-viewport.svelte"; + +export type { + NavigationMenuRootProps as RootProps, + NavigationMenuItemProps as ItemProps, + NavigationMenuListProps as ListProps, + NavigationMenuTriggerProps as TriggerProps, + NavigationMenuViewportProps as ViewportProps, + NavigationMenuIndicatorProps as IndicatorProps, + NavigationMenuContentProps as ContentProps, + NavigationMenuLinkProps as LinkProps, +} from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts new file mode 100644 index 000000000..960bdca7c --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts @@ -0,0 +1 @@ +export * as NavigationMenu from "./exports.js"; diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts new file mode 100644 index 000000000..70282a723 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -0,0 +1,679 @@ +/** + * Based on Radix UI's Navigation Menu + * https://www.radix-ui.com/docs/primitives/components/navigation-menu + */ + +import { createContext } from "$lib/internal/create-context.js"; +import { useId, type Direction, type Orientation } from "$lib/shared/index.js"; +import { + box, + onDestroyEffect, + useRefById, + type AnyFn, + type ReadableBoxedValues, + type WithRefProps, + type WritableBoxedValues, +} from "svelte-toolbelt"; +import { Previous } from "runed"; +import { + getAriaExpanded, + getDataDisabled, + getDataOpenClosed, + getDataOrientation, +} from "$lib/internal/attrs.js"; +import { noop } from "$lib/internal/noop.js"; +import { getTabbableCandidates } from "$lib/internal/focus.js"; +import type { + BitsFocusEvent, + BitsKeyboardEvent, + BitsMouseEvent, + BitsPointerEvent, +} from "$lib/internal/types.js"; +import { kbd } from "$lib/internal/kbd.js"; +import { createCustomEvent } from "$lib/internal/events.js"; + +const ROOT_ATTR = "data-navigation-menu-root"; +const SUB_ATTR = "data-navigation-menu-sub"; +const LIST_ATTR = "data-navigation-menu-list"; +const ITEM_ATTR = "data-navigation-menu-item"; +const TRIGGER_ATTR = "data-navigation-menu-trigger"; +const LINK_ATTR = "data-navigation-menu-link"; + +type NavigationMenuProviderStateProps = ReadableBoxedValues<{ + dir: Direction; + orientation: Orientation; +}> & + WritableBoxedValues<{ + rootNavigationMenuRef: HTMLElement | null; + value: string; + }> & { + isRootMenu: boolean; + onTriggerEnter(itemValue: string): void; + onTriggerLeave?(): void; + onContentEnter?(): void; + onContentLeave?(): void; + onItemSelect(itemValue: string): void; + onItemDismiss(): void; + }; + +class NavigationMenuProviderState { + isRootMenu: NavigationMenuProviderStateProps["isRootMenu"]; + value: NavigationMenuProviderStateProps["value"]; + previousValue: Previous; + dir: NavigationMenuProviderStateProps["dir"]; + orientation: NavigationMenuProviderStateProps["orientation"]; + rootNavigationMenuRef: NavigationMenuProviderStateProps["rootNavigationMenuRef"]; + indicatorTrackRef = box(null); + viewportRef = box(null); + onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"]; + onTriggerLeave: () => void = noop; + onContentEnter: () => void = noop; + onContentLeave: () => void = noop; + onItemSelect: NavigationMenuProviderStateProps["onItemSelect"]; + onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"]; + + constructor(props: NavigationMenuProviderStateProps) { + this.isRootMenu = props.isRootMenu; + this.value = props.value; + this.previousValue = new Previous(() => this.value.current); + this.dir = props.dir; + this.orientation = props.orientation; + this.rootNavigationMenuRef = props.rootNavigationMenuRef; + this.onTriggerEnter = props.onTriggerEnter; + this.onTriggerLeave = props.onTriggerLeave ?? noop; + this.onContentEnter = props.onContentEnter ?? noop; + this.onContentLeave = props.onContentLeave ?? noop; + this.onItemDismiss = props.onItemDismiss; + this.onItemSelect = props.onItemSelect; + + this.onItemSelect = this.onItemSelect.bind(this); + this.onItemDismiss = this.onItemDismiss.bind(this); + this.onTriggerEnter = this.onTriggerEnter.bind(this); + this.onTriggerLeave = this.onTriggerLeave.bind(this); + this.onContentEnter = this.onContentEnter.bind(this); + this.onContentLeave = this.onContentLeave.bind(this); + } +} + +type NavigationMenuRootStateProps = WithRefProps< + WritableBoxedValues<{ + value: string; + }> & + ReadableBoxedValues<{ + dir: Direction; + orientation: Orientation; + delayDuration: number; + skipDelayDuration: number; + }> +>; + +class NavigationMenuRootState { + id: NavigationMenuRootStateProps["id"]; + ref: NavigationMenuRootStateProps["ref"]; + value: NavigationMenuRootStateProps["value"]; + dir: NavigationMenuRootStateProps["dir"]; + orientation: NavigationMenuRootStateProps["orientation"]; + delayDuration: NavigationMenuRootStateProps["delayDuration"]; + skipDelayDuration: NavigationMenuRootStateProps["skipDelayDuration"]; + + openTimer = $state(0); + closeTimer = $state(0); + skipDelayTimer = $state(0); + isOpenDelayed = $state(true); + + provider: NavigationMenuProviderState; + + constructor(props: NavigationMenuRootStateProps) { + this.id = props.id; + this.ref = props.ref; + this.value = props.value; + this.dir = props.dir; + this.orientation = props.orientation; + this.delayDuration = props.delayDuration; + this.skipDelayDuration = props.skipDelayDuration; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + onDestroyEffect(() => { + window.clearTimeout(this.openTimer); + window.clearTimeout(this.closeTimer); + window.clearTimeout(this.skipDelayTimer); + }); + + this.provider = useNavigationMenuProvider({ + value: this.value, + dir: this.dir, + orientation: this.orientation, + rootNavigationMenuRef: this.ref, + isRootMenu: true, + onTriggerEnter: (itemValue) => this.#onTriggerEnter(itemValue), + onTriggerLeave: () => this.#onTriggerLeave(), + onContentEnter: () => this.#onContentEnter(), + onContentLeave: () => this.#onContentLeave(), + onItemSelect: (itemValue) => this.#onItemSelect(itemValue), + onItemDismiss: () => this.#onItemDismiss(), + }); + } + + #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) { + if (this.value.current === itemValue) { + this.setValue(""); + } else { + this.setValue(itemValue); + } + } + + #onItemDismiss() { + this.setValue(""); + } + + setValue(newValue: string) { + this.value.current = newValue; + } + + handleValueChange(newValue: string) { + const isOpen = newValue !== ""; + const hasSkipDelayDuration = this.skipDelayDuration.current > 0; + + if (isOpen) { + window.clearTimeout(this.skipDelayTimer); + if (hasSkipDelayDuration) this.isOpenDelayed = false; + } else { + window.clearTimeout(this.skipDelayTimer); + this.skipDelayTimer = window.setTimeout( + () => (this.isOpenDelayed = true), + this.skipDelayDuration.current + ); + } + } + + 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 isOpenItem = this.value.current === itemValue; + if (isOpenItem) { + // If the item is already open (e.g. we're transitioning from the content to the trigger) then we want to clear the close timer immediately. + window.clearTimeout(this.closeTimer); + } else { + this.openTimer = window.setTimeout(() => { + window.clearTimeout(this.closeTimer); + this.setValue(itemValue); + }, this.delayDuration.current); + } + } + + props = $derived.by( + () => + ({ + id: this.id.current, + "aria-label": "Main", + "data-orientation": getDataOrientation(this.orientation.current), + dir: this.dir.current, + [ROOT_ATTR]: "", + }) as const + ); +} + +type NavigationMenuSubStateProps = WithRefProps< + WritableBoxedValues<{ + value: string; + }> & + ReadableBoxedValues<{ + orientation: Orientation; + }> +>; + +class NavigationMenuSubState { + id: NavigationMenuSubStateProps["id"]; + ref: NavigationMenuSubStateProps["ref"]; + value: NavigationMenuSubStateProps["value"]; + context: NavigationMenuProviderState; + orientation: NavigationMenuSubStateProps["orientation"]; + + constructor(props: NavigationMenuSubStateProps, context: NavigationMenuProviderState) { + this.id = props.id; + this.ref = props.ref; + this.value = props.value; + this.orientation = props.orientation; + this.context = context; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + useNavigationMenuProvider({ + isRootMenu: false, + value: this.value, + dir: this.context.dir, + orientation: this.orientation, + rootNavigationMenuRef: this.context.rootNavigationMenuRef, + onTriggerEnter: (itemValue) => this.setValue(itemValue), + onItemSelect: (itemValue) => this.setValue(itemValue), + onItemDismiss: () => this.setValue(""), + }); + } + + setValue(newValue: string) { + this.value.current = newValue; + } + + props = $derived.by( + () => + ({ + id: this.id.current, + "data-orientation": getDataOrientation(this.orientation.current), + [SUB_ATTR]: "", + }) as const + ); +} + +type NavigationMenuListStateProps = WithRefProps; + +class NavigationMenuListState { + id: NavigationMenuListStateProps["id"]; + ref: NavigationMenuListStateProps["ref"]; + context: NavigationMenuProviderState; + wrapperId = box.with(() => useId()); + wrapperRef = box(null); + + constructor(props: NavigationMenuListStateProps, context: NavigationMenuProviderState) { + this.id = props.id; + this.ref = props.ref; + this.context = context; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + useRefById({ + id: this.wrapperId, + ref: this.wrapperRef, + onRefChange: (node) => { + this.context.indicatorTrackRef.current = node; + }, + }); + } + + wrapperProps = $derived.by( + () => + ({ + id: this.id.current, + }) as const + ); + + props = $derived.by( + () => + ({ + id: this.id.current, + "data-orientation": getDataOrientation(this.context.orientation.current), + [LIST_ATTR]: "", + }) as const + ); +} + +type NavigationMenuItemStateProps = WithRefProps< + ReadableBoxedValues<{ + value: string; + }> +>; + +class NavigationMenuItemState { + ref: NavigationMenuItemStateProps["ref"]; + id: NavigationMenuItemStateProps["id"]; + value: NavigationMenuItemStateProps["value"]; + contentNode = $state(null); + triggerNode = $state(null); + focusProxyNode = $state(null); + restoreContentTabOrder: AnyFn = noop; + wasEscapeClose = $state(false); + + constructor(props: NavigationMenuItemStateProps) { + this.ref = props.ref; + this.id = props.id; + this.value = props.value; + } + + #handleContentEntry = (side: "start" | "end" = "start") => { + if (!this.contentNode) return; + this.restoreContentTabOrder(); + const candidates = getTabbableCandidates(this.contentNode); + if (candidates.length) focusFirst(side === "start" ? candidates : candidates.reverse()); + }; + + #handleContextExit = () => { + if (!this.contentNode) return; + const candidates = getTabbableCandidates(this.contentNode); + if (candidates.length) this.restoreContentTabOrder = removeFromTabOrder(candidates); + }; + + onEntryKeydown = this.#handleContentEntry; + onFocusProxyEnter = this.#handleContentEntry; + onRootContentClose = this.#handleContextExit; + onContentFocusOutside = this.#handleContextExit; + + props = $derived.by( + () => + ({ + id: this.id.current, + [ITEM_ATTR]: "", + }) as const + ); +} + +type NavigationMenuTriggerStateProps = WithRefProps & + ReadableBoxedValues<{ + disabled: boolean | null | undefined; + }>; + +class NavigationMenuTriggerState { + id: NavigationMenuTriggerStateProps["id"]; + ref: NavigationMenuTriggerStateProps["ref"]; + focusProxyId = box.with(() => useId()); + focusProxyRef = box(null); + disabled: NavigationMenuTriggerStateProps["disabled"]; + context: NavigationMenuProviderState; + itemContext: NavigationMenuItemState; + contentId = $derived.by(() => this.itemContext.contentNode?.id ?? undefined); + hasPointerMoveOpened = $state(false); + wasClickClose = $state(false); + open = $derived.by(() => this.itemContext.value.current === this.context.value.current); + + constructor( + props: NavigationMenuTriggerStateProps, + context: NavigationMenuProviderState, + itemContext: NavigationMenuItemState + ) { + this.id = props.id; + this.ref = props.ref; + this.disabled = props.disabled; + this.context = context; + this.itemContext = itemContext; + this.open = itemContext.value.current === context.value.current; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + useRefById({ + id: this.focusProxyId, + ref: this.focusProxyRef, + onRefChange: (node) => { + this.itemContext.focusProxyNode = node; + }, + deps: () => this.open, + }); + + this.onpointerenter = this.onpointerenter.bind(this); + this.onpointerleave = this.onpointerleave.bind(this); + this.onclick = this.onclick.bind(this); + this.onkeydown = this.onkeydown.bind(this); + this.focusProxyOnFocus = this.focusProxyOnFocus.bind(this); + } + + onpointerenter(_: BitsPointerEvent) { + this.wasClickClose = false; + this.itemContext.wasEscapeClose = false; + } + + onpointermove = whenMouse(() => { + if ( + this.disabled.current || + this.wasClickClose || + this.itemContext.wasEscapeClose || + this.hasPointerMoveOpened + ) { + return; + } + this.context.onTriggerEnter(this.itemContext.value.current); + this.hasPointerMoveOpened = true; + }); + + onpointerleave = whenMouse(() => { + if (this.disabled.current) return; + this.context.onTriggerLeave(); + this.hasPointerMoveOpened = false; + }); + + onclick(_: BitsMouseEvent) { + this.context.onItemSelect(this.itemContext.value.current); + this.wasClickClose = this.open; + } + + onkeydown(e: BitsKeyboardEvent) { + const verticalEntryKey = + this.context.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; + const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[ + this.context.orientation.current + ]; + if (this.open && e.key === entryKey) { + this.itemContext.onEntryKeydown(); + // prevent focus group from handling the event + e.preventDefault(); + } + } + + focusProxyOnFocus(e: BitsFocusEvent) { + const content = this.itemContext.contentNode; + const prevFocusedElement = e.relatedTarget as HTMLElement | null; + const wasTriggerFocused = this.ref.current && prevFocusedElement === this.ref.current; + const wasFocusFromContent = content?.contains(prevFocusedElement); + + if (wasTriggerFocused || !wasFocusFromContent) { + this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end"); + } + } + + props = $derived.by( + () => + ({ + id: this.id.current, + disabled: this.disabled.current, + "data-disabled": getDataDisabled(Boolean(this.disabled.current)), + "data-state": getDataOpenClosed(this.open), + "aria-expanded": getAriaExpanded(this.open), + "aria-controls": this.contentId, + [TRIGGER_ATTR]: "", + }) as const + ); + + focusProxyProps = $derived.by( + () => + ({ + "aria-hidden": "true", + tabindex: 0, + onfocus: this.focusProxyOnFocus, + }) as const + ); + + restructureSpanProps = $derived.by( + () => + ({ + "aria-owns": this.contentId, + }) as const + ); +} + +type NavigationMenuLinkStateProps = WithRefProps & + ReadableBoxedValues<{ + active: boolean; + onSelect: (e: Event) => void; + }>; + +const [dispatchLinkSelect, listenLinkSelect] = createCustomEvent("bitsLinkSelect", { + bubbles: true, + cancelable: true, +}); + +const [dispatchRootContentDismiss, listenRootContentDismiss] = createCustomEvent( + "bitsRootContentDismiss", + { + cancelable: true, + bubbles: true, + } +); + +class NavigationMenuLinkState { + id: NavigationMenuLinkStateProps["id"]; + ref: NavigationMenuLinkStateProps["ref"]; + active: NavigationMenuLinkStateProps["active"]; + onSelect: NavigationMenuLinkStateProps["onSelect"]; + + constructor(props: NavigationMenuLinkStateProps, context: NavigationMenuProviderState) { + this.id = props.id; + this.ref = props.ref; + this.active = props.active; + this.onSelect = props.onSelect; + + useRefById({ + id: this.id, + ref: this.ref, + }); + + this.onclick = this.onclick.bind(this); + } + + onclick(e: BitsMouseEvent) { + const currTarget = e.currentTarget; + + listenLinkSelect(currTarget, (e) => this.onSelect.current(e), { once: true }); + const linkSelectEvent = dispatchLinkSelect(currTarget); + + if (!linkSelectEvent.defaultPrevented && !e.metaKey) { + dispatchRootContentDismiss(currTarget); + } + } + + props = $derived.by( + () => + ({ + id: this.id.current, + "data-active": this.active.current ? "" : undefined, + "aria-current": this.active.current ? "page" : undefined, + onclick: this.onclick, + [LINK_ATTR]: "", + }) as const + ); +} + +type NavigationMenuIndicatorStateProps = WithRefProps; + +class NavigationMenuIndicatorState { + context: NavigationMenuProviderState; + isVisible = $derived.by(() => Boolean(this.context.value.current)); + + constructor(context: NavigationMenuProviderState) { + this.context = context; + } +} + +class NavigationMenuIndicatorImplState { + id: NavigationMenuIndicatorStateProps["id"]; + ref: NavigationMenuIndicatorStateProps["ref"]; + context: NavigationMenuProviderState; + activeTrigger = $state(null); + position = $state<{ size: number; offset: number } | null>(null); + isHorizontal = $derived.by(() => this.context.orientation.current === "horizontal"); + isVisible = $derived.by(() => Boolean(this.context.value.current)); + + constructor(props: NavigationMenuIndicatorStateProps, context: NavigationMenuProviderState) { + this.id = props.id; + this.ref = props.ref; + this.context = context; + + useRefById({ + id: this.id, + ref: this.ref, + deps: () => this.context.value.current, + }); + } +} + +const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = + createContext("NavigationMenu.Root", "NavigationMenuProvider"); + +const [setNavigationMenuItemContext, getNavigationMenuItemContext] = + createContext("NavigationMenu.Item"); + +export function useNavigationMenuProvider(props: NavigationMenuProviderStateProps) { + return setNavigationMenuProviderContext(new NavigationMenuProviderState(props)); +} + +export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { + return new NavigationMenuSubState(props, getNavigationMenuProviderContext()); +} + +export function useNavigationMenuList(props: NavigationMenuListStateProps) { + return new NavigationMenuListState(props, getNavigationMenuProviderContext()); +} + +export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { + return setNavigationMenuItemContext(new NavigationMenuItemState(props)); +} + +// + +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); + }); + }; +} + +type BitsPointerEventHandler = ( + e: BitsPointerEvent +) => void; + +function whenMouse( + handler: BitsPointerEventHandler +): BitsPointerEventHandler { + return (e) => (e.pointerType === "mouse" ? handler(e) : undefined); +} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts new file mode 100644 index 000000000..f6dd8d4e3 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts @@ -0,0 +1,185 @@ +import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; +import type { + BitsPrimitiveAnchorAttributes, + BitsPrimitiveButtonAttributes, + BitsPrimitiveDivAttributes, + BitsPrimitiveElementAttributes, + BitsPrimitiveLiAttributes, + BitsPrimitiveUListAttributes, +} from "$lib/shared/attributes.js"; +import type { Direction, Orientation } from "$lib/shared/index.js"; + +export type NavigationMenuRootPropsWithoutHTML = WithChild<{ + /** + * The value of the currently open menu item. + * + * @bindable + */ + value?: string; + + /** + * The callback to call when a menu item is selected. + */ + onValueChange?: OnChangeFn; + + /** + * Whether or not the value state is controlled or not. If `true`, the component will not update + * the value state internally, instead it will call `onValueChange` when it would have + * otherwise, and it is up to you to update the `value` prop that is passed to the component. + */ + controlledValue?: boolean; + + /** + * The duration from when the mouse enters a trigger until the content opens. + * + * @defaultValue 200 + */ + delayDuration?: number; + + /** + * How much time a user has to enter another trigger without incurring a delay again. + * + * @defaultValue 300 + */ + skipDelayDuration?: number; + + /** + * The reading direction of the content. + * + * @defaultValue "ltr" + */ + dir?: Direction; + + /** + * The orientation of the menu. + */ + orientation?: Orientation; +}>; + +export type NavigationMenuRootProps = NavigationMenuRootPropsWithoutHTML & + Without; + +export type NavigationMenuSubPropsWithoutHTML = WithChild<{ + /** + * The value of the currently open menu item within the menu. + * + * @bindable + */ + value?: string; + + /** + * A callback fired when the active menu item changes. + */ + onValueChange?: OnChangeFn; + + /** + * The orientation of the menu. + */ + orientation?: Orientation; +}>; + +export type NavigationMenuSubProps = NavigationMenuSubPropsWithoutHTML & + Without; + +export type NavigationMenuListPropsWithoutHTML = WithChild; + +export type NavigationMenuListProps = NavigationMenuListPropsWithoutHTML & + Without; + +export type NavigationMenuItemPropsWithoutHTML = WithChild<{ + /** + * The value of the menu item. + */ + value?: string; +}>; + +export type NavigationMenuItemProps = NavigationMenuItemPropsWithoutHTML & + Without; + +export type NavigationMenuTriggerPropsWithoutHTML = WithChild<{ + /** + * Whether the trigger is disabled. + * @defaultValue false + */ + disabled?: boolean | null | undefined; +}>; + +export type NavigationMenuTriggerProps = NavigationMenuTriggerPropsWithoutHTML & + Without; + +export type NavigationMenuContentPropsWithoutHTML = WithChild<{ + /** + * Callback fired when an interaction occurs outside the content. + * Default behavior can be prevented with `event.preventDefault()` + * + */ + onInteractOutside?: (event: PointerEvent) => void; + + /** + * Callback fired when a focus event occurs outside the content. + * Default behavior can be prevented with `event.preventDefault()` + */ + onFocusOutside?: (event: FocusEvent) => void; + + /** + * Callback fires when an escape keydown event occurs. + * Default behavior can be prevented with `event.preventDefault()` + */ + onEscapeKeydown?: (event: KeyboardEvent) => void; + + /** + * 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 = WithChild<{ + /** + * Whether the link is the current active page + */ + active?: boolean; + + /** + * A callback fired when the link is clicked. + * Default behavior can be prevented with `event.preventDefault()` + */ + onSelect?: (e: Event) => void; +}>; + +export type NavigationMenuLinkProps = NavigationMenuLinkPropsWithoutHTML & + Without; + +export type NavigationMenuIndicatorPropsWithoutHTML = WithChild<{ + /** + * Whether to forcefully mount the content, regardless of the open state. + * This is useful when wanting to use more custom transition and animation + * libraries. + * + * @defaultValue false + */ + forceMount?: boolean; +}>; + +export type NavigationMenuIndicatorProps = NavigationMenuIndicatorPropsWithoutHTML & + Without; + +export type NavigationMenuViewportPropsWithoutHTML = WithChild<{ + /** + * Whether to forcefully mount the content, regardless of the open state. + * This is useful when wanting to use more custom transition and animation + * libraries. + * + * @defaultValue false + */ + forceMount?: boolean; +}>; + +export type NavigationMenuViewportProps = NavigationMenuViewportPropsWithoutHTML & + Without; diff --git a/packages/bits-ui/src/lib/internal/events.ts b/packages/bits-ui/src/lib/internal/events.ts index c9738b8fb..3da892758 100644 --- a/packages/bits-ui/src/lib/internal/events.ts +++ b/packages/bits-ui/src/lib/internal/events.ts @@ -63,21 +63,38 @@ export function createCustomEvent( type CustomEventType = CustomEvent; type EventListener = (event: CustomEventType) => void; - function dispatch(element: HTMLElement, detail?: T) { + /** + * Dispatches a custom event on the specified element with the given detail. + * + * @returns The dispatched event. + */ + function dispatch(element: HTMLElement, detail?: T): CustomEvent { const event = new CustomEvent(eventName, { ...options, detail, }); element.dispatchEvent(event); + return event; } - function listen(element: EventTarget, callback: EventListener) { + /** + * + * Listens for a custom event on the specified element and calls the given callback + * when the event is triggered. + * + * @returns A function that removes the event listener from the target element(s). + */ + function listen( + element: EventTarget, + callback: EventListener, + options?: AddEventListenerOptions + ) { const handler = (event: Event) => { callback(event as CustomEventType); }; // @ts-expect-error shh - return addEventListener(element, eventName, handler); + return addEventListener(element, eventName, handler, options); } return [dispatch, listen] as const; From 5da705defdcd61039bebc2d47c4a371d063fb060 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 13 Dec 2024 22:09:47 -0500 Subject: [PATCH 2/8] progress --- .../navigation-menu.svelte.ts | 160 +++++++++++++++--- 1 file changed, 137 insertions(+), 23 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index 70282a723..961561787 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -3,18 +3,18 @@ * https://www.radix-ui.com/docs/primitives/components/navigation-menu */ -import { createContext } from "$lib/internal/create-context.js"; -import { useId, type Direction, type Orientation } from "$lib/shared/index.js"; import { - box, - onDestroyEffect, - useRefById, type AnyFn, type ReadableBoxedValues, type WithRefProps, type WritableBoxedValues, + box, + onDestroyEffect, + useRefById, } from "svelte-toolbelt"; import { Previous } from "runed"; +import { createContext } from "$lib/internal/create-context.js"; +import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; import { getAriaExpanded, getDataDisabled, @@ -31,6 +31,7 @@ import type { } from "$lib/internal/types.js"; import { kbd } from "$lib/internal/kbd.js"; import { createCustomEvent } from "$lib/internal/events.js"; +import { useResizeObserver } from "$lib/internal/use-resize-observer.svelte.js"; const ROOT_ATTR = "data-navigation-menu-root"; const SUB_ATTR = "data-navigation-menu-sub"; @@ -48,12 +49,12 @@ type NavigationMenuProviderStateProps = ReadableBoxedValues<{ value: string; }> & { isRootMenu: boolean; - onTriggerEnter(itemValue: string): void; - onTriggerLeave?(): void; - onContentEnter?(): void; - onContentLeave?(): void; - onItemSelect(itemValue: string): void; - onItemDismiss(): void; + onTriggerEnter: (itemValue: string) => void; + onTriggerLeave?: () => void; + onContentEnter?: () => void; + onContentLeave?: () => void; + onItemSelect: (itemValue: string) => void; + onItemDismiss: () => void; }; class NavigationMenuProviderState { @@ -115,12 +116,10 @@ class NavigationMenuRootState { orientation: NavigationMenuRootStateProps["orientation"]; delayDuration: NavigationMenuRootStateProps["delayDuration"]; skipDelayDuration: NavigationMenuRootStateProps["skipDelayDuration"]; - openTimer = $state(0); closeTimer = $state(0); skipDelayTimer = $state(0); isOpenDelayed = $state(true); - provider: NavigationMenuProviderState; constructor(props: NavigationMenuRootStateProps) { @@ -306,6 +305,7 @@ class NavigationMenuListState { context: NavigationMenuProviderState; wrapperId = box.with(() => useId()); wrapperRef = box(null); + listTriggers = $state.raw([]); constructor(props: NavigationMenuListStateProps, context: NavigationMenuProviderState) { this.id = props.id; @@ -326,6 +326,13 @@ class NavigationMenuListState { }); } + registerTrigger(trigger: HTMLElement | null) { + if (trigger) this.listTriggers.push(trigger); + return () => { + this.listTriggers = this.listTriggers.filter((t) => t.id !== trigger!.id); + }; + } + wrapperProps = $derived.by( () => ({ @@ -405,6 +412,7 @@ class NavigationMenuTriggerState { disabled: NavigationMenuTriggerStateProps["disabled"]; context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; + listContext: NavigationMenuListState; contentId = $derived.by(() => this.itemContext.contentNode?.id ?? undefined); hasPointerMoveOpened = $state(false); wasClickClose = $state(false); @@ -412,15 +420,18 @@ class NavigationMenuTriggerState { constructor( props: NavigationMenuTriggerStateProps, - context: NavigationMenuProviderState, - itemContext: NavigationMenuItemState + context: { + provider: NavigationMenuProviderState; + item: NavigationMenuItemState; + list: NavigationMenuListState; + } ) { this.id = props.id; this.ref = props.ref; this.disabled = props.disabled; - this.context = context; - this.itemContext = itemContext; - this.open = itemContext.value.current === context.value.current; + this.context = context.provider; + this.itemContext = context.item; + this.listContext = context.list; useRefById({ id: this.id, @@ -436,6 +447,18 @@ class NavigationMenuTriggerState { deps: () => this.open, }); + $effect(() => { + const node = this.ref.current; + + if (node) { + const unregister = this.listContext.registerTrigger(node); + + return () => { + unregister(); + }; + } + }); + this.onpointerenter = this.onpointerenter.bind(this); this.onpointerleave = this.onpointerleave.bind(this); this.onclick = this.onclick.bind(this); @@ -503,6 +526,7 @@ class NavigationMenuTriggerState { disabled: this.disabled.current, "data-disabled": getDataDisabled(Boolean(this.disabled.current)), "data-state": getDataOpenClosed(this.open), + "data-value": this.itemContext.value.current, "aria-expanded": getAriaExpanded(this.open), "aria-controls": this.contentId, [TRIGGER_ATTR]: "", @@ -603,22 +627,92 @@ class NavigationMenuIndicatorImplState { id: NavigationMenuIndicatorStateProps["id"]; ref: NavigationMenuIndicatorStateProps["ref"]; context: NavigationMenuProviderState; - activeTrigger = $state(null); - position = $state<{ size: number; offset: number } | null>(null); + listContext: NavigationMenuListState; + position = $state.raw<{ size: number; offset: number } | null>(null); isHorizontal = $derived.by(() => this.context.orientation.current === "horizontal"); isVisible = $derived.by(() => Boolean(this.context.value.current)); + activeTrigger = $derived.by(() => { + const items = this.listContext.listTriggers; + const triggerNode = items.find( + (item) => item.getAttribute("data-value") === this.context.value.current + ); + return triggerNode ?? null; + }); + shouldRender = $derived.by(() => this.position !== null); - constructor(props: NavigationMenuIndicatorStateProps, context: NavigationMenuProviderState) { + constructor( + props: NavigationMenuIndicatorStateProps, + context: { + provider: NavigationMenuProviderState; + list: NavigationMenuListState; + } + ) { this.id = props.id; this.ref = props.ref; - this.context = context; + this.context = context.provider; + this.listContext = context.list; useRefById({ id: this.id, ref: this.ref, deps: () => this.context.value.current, }); + + useResizeObserver(() => this.activeTrigger, this.handlePositionChange); + useResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange); + } + + handlePositionChange = () => { + if (!this.activeTrigger) return; + this.position = { + size: this.isHorizontal + ? this.activeTrigger.offsetWidth + : this.activeTrigger.offsetHeight, + offset: this.isHorizontal + ? this.activeTrigger.offsetLeft + : this.activeTrigger.offsetTop, + }; + }; + + props = $derived.by( + () => + ({ + id: this.id.current, + "data-state": this.isVisible ? "visible" : "hidden", + "data-orientation": getDataOrientation(this.context.orientation.current), + style: this.position + ? { + position: "absolute", + ...(this.isHorizontal + ? { + left: 0, + width: `${this.position.size}px`, + transform: `translateX(${this.position.offset}px)`, + } + : { + top: 0, + height: `${this.position.size}px`, + transform: `translateY(${this.position.offset}px)`, + }), + } + : undefined, + }) as const + ); +} + +type NavigationMenuContentStateProps = WithRefProps; + +class NavigationMenuContentState { + context: NavigationMenuProviderState; + itemContext: NavigationMenuItemState; + open = $derived.by(() => this.itemContext.value.current === this.context.value.current); + + constructor(context: NavigationMenuProviderState, itemContext: NavigationMenuItemState) { + this.context = context; + this.itemContext = itemContext; } + + props = $derived.by(() => ({})); } const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = @@ -627,6 +721,9 @@ const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = const [setNavigationMenuItemContext, getNavigationMenuItemContext] = createContext("NavigationMenu.Item"); +const [setNavigationMenuListContext, getNavigationMenuListContext] = + createContext("NavigationMenu.List"); + export function useNavigationMenuProvider(props: NavigationMenuProviderStateProps) { return setNavigationMenuProviderContext(new NavigationMenuProviderState(props)); } @@ -636,13 +733,30 @@ export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { } export function useNavigationMenuList(props: NavigationMenuListStateProps) { - return new NavigationMenuListState(props, getNavigationMenuProviderContext()); + return setNavigationMenuListContext( + new NavigationMenuListState(props, getNavigationMenuProviderContext()) + ); } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { return setNavigationMenuItemContext(new NavigationMenuItemState(props)); } +export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { + return new NavigationMenuIndicatorImplState(props, { + provider: getNavigationMenuProviderContext(), + list: getNavigationMenuListContext(), + }); +} + +export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) { + return new NavigationMenuTriggerState(props, { + provider: getNavigationMenuProviderContext(), + item: getNavigationMenuItemContext(), + list: getNavigationMenuListContext(), + }); +} + // function focusFirst(candidates: HTMLElement[]) { From d2314e165f2aa28508d3555b51e6ae930c6bd6a7 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 14 Dec 2024 16:05:17 -0500 Subject: [PATCH 3/8] more --- .../navigation-menu-content-impl.svelte | 2 + .../components/navigation-menu-sub.svelte | 49 +++ .../components/navigation-menu-trigger.svelte | 11 +- .../src/lib/bits/navigation-menu-2/exports.ts | 1 + .../navigation-menu.svelte.ts | 327 +++++++++++++++++- .../src/lib/bits/navigation-menu-2/types.ts | 7 + .../lib/internal/previous-with-init.svelte.ts | 26 ++ 7 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte create mode 100644 packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte new file mode 100644 index 000000000..0fbba9978 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte @@ -0,0 +1,2 @@ + diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte new file mode 100644 index 000000000..6f9a1b33c --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte @@ -0,0 +1,49 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
    + {@render children?.()} +
    +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte index b4c9c2dc2..8402f75b0 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte @@ -15,8 +15,6 @@ ...restProps }: NavigationMenuTriggerProps = $props(); - let focusProxyMounted = $state(false); - const triggerState = useNavigationMenuTrigger({ id: box.with(() => id), disabled: box.with(() => disabled ?? false), @@ -24,7 +22,6 @@ () => ref, (v) => (ref = v) ), - focusProxyMounted: box.with(() => focusProxyMounted), }); const mergedProps = $derived(mergeProps(restProps, triggerState.props)); @@ -39,9 +36,9 @@ {/if} {#if triggerState.open} - - - {#if triggerState.menu.viewportNode} - + (triggerState.focusProxyMounted = m)} /> + + {#if triggerState.context.viewportRef.current} + {/if} {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts index edfa842a0..e25251951 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts @@ -16,4 +16,5 @@ export type { NavigationMenuIndicatorProps as IndicatorProps, NavigationMenuContentProps as ContentProps, NavigationMenuLinkProps as LinkProps, + NavigationMenuSubProps as SubProps, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index 961561787..cc6251213 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -7,12 +7,15 @@ import { type AnyFn, type ReadableBoxedValues, type WithRefProps, + type WritableBox, type WritableBoxedValues, box, onDestroyEffect, useRefById, } from "svelte-toolbelt"; -import { Previous } from "runed"; +import { watch } from "runed"; +import { untrack } from "svelte"; +import { SvelteMap } from "svelte/reactivity"; import { createContext } from "$lib/internal/create-context.js"; import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; import { @@ -32,6 +35,7 @@ import type { import { kbd } from "$lib/internal/kbd.js"; import { createCustomEvent } from "$lib/internal/events.js"; import { useResizeObserver } from "$lib/internal/use-resize-observer.svelte.js"; +import { PreviousWithInit } from "$lib/internal/previous-with-init.svelte.js"; const ROOT_ATTR = "data-navigation-menu-root"; const SUB_ATTR = "data-navigation-menu-sub"; @@ -60,12 +64,13 @@ type NavigationMenuProviderStateProps = ReadableBoxedValues<{ class NavigationMenuProviderState { isRootMenu: NavigationMenuProviderStateProps["isRootMenu"]; value: NavigationMenuProviderStateProps["value"]; - previousValue: Previous; + previousValue: PreviousWithInit; dir: NavigationMenuProviderStateProps["dir"]; orientation: NavigationMenuProviderStateProps["orientation"]; rootNavigationMenuRef: NavigationMenuProviderStateProps["rootNavigationMenuRef"]; indicatorTrackRef = box(null); viewportRef = box(null); + viewportContent = new SvelteMap(); onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"]; onTriggerLeave: () => void = noop; onContentEnter: () => void = noop; @@ -76,7 +81,7 @@ class NavigationMenuProviderState { constructor(props: NavigationMenuProviderStateProps) { this.isRootMenu = props.isRootMenu; this.value = props.value; - this.previousValue = new Previous(() => this.value.current); + this.previousValue = new PreviousWithInit(() => this.value.current); this.dir = props.dir; this.orientation = props.orientation; this.rootNavigationMenuRef = props.rootNavigationMenuRef; @@ -94,6 +99,15 @@ class NavigationMenuProviderState { this.onContentEnter = this.onContentEnter.bind(this); this.onContentLeave = this.onContentLeave.bind(this); } + + onViewportContentChange(contentValue: string, item: NavigationMenuItemState) { + this.viewportContent.set(contentValue, item); + } + + onViewportContentRemove(contentValue: string) { + if (!this.viewportContent.has(contentValue)) return; + this.viewportContent.delete(contentValue); + } } type NavigationMenuRootStateProps = WithRefProps< @@ -235,7 +249,6 @@ class NavigationMenuRootState { () => ({ id: this.id.current, - "aria-label": "Main", "data-orientation": getDataOrientation(this.orientation.current), dir: this.dir.current, [ROOT_ATTR]: "", @@ -365,6 +378,8 @@ class NavigationMenuItemState { focusProxyNode = $state(null); restoreContentTabOrder: AnyFn = noop; wasEscapeClose = $state(false); + contentId = $derived.by(() => this.contentNode?.id); + triggerId = $derived.by(() => this.triggerNode?.id); constructor(props: NavigationMenuItemStateProps) { this.ref = props.ref; @@ -413,7 +428,6 @@ class NavigationMenuTriggerState { context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; listContext: NavigationMenuListState; - contentId = $derived.by(() => this.itemContext.contentNode?.id ?? undefined); hasPointerMoveOpened = $state(false); wasClickClose = $state(false); open = $derived.by(() => this.itemContext.value.current === this.context.value.current); @@ -528,7 +542,7 @@ class NavigationMenuTriggerState { "data-state": getDataOpenClosed(this.open), "data-value": this.itemContext.value.current, "aria-expanded": getAriaExpanded(this.open), - "aria-controls": this.contentId, + "aria-controls": this.itemContext.contentId, [TRIGGER_ATTR]: "", }) as const ); @@ -545,7 +559,7 @@ class NavigationMenuTriggerState { restructureSpanProps = $derived.by( () => ({ - "aria-owns": this.contentId, + "aria-owns": this.itemContext.contentId, }) as const ); } @@ -703,18 +717,271 @@ class NavigationMenuIndicatorImplState { type NavigationMenuContentStateProps = WithRefProps; class NavigationMenuContentState { + id: NavigationMenuContentStateProps["id"]; + ref: NavigationMenuContentStateProps["ref"]; context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; + listContext: NavigationMenuListState; open = $derived.by(() => this.itemContext.value.current === this.context.value.current); + value = $derived.by(() => this.itemContext.value.current); - constructor(context: NavigationMenuProviderState, itemContext: NavigationMenuItemState) { - this.context = context; - this.itemContext = itemContext; + constructor( + props: NavigationMenuContentStateProps, + context: { + provider: NavigationMenuProviderState; + item: NavigationMenuItemState; + list: NavigationMenuListState; + } + ) { + this.id = props.id; + this.ref = props.ref; + this.context = context.provider; + this.itemContext = context.item; + this.listContext = context.list; + + useRefById({ + id: this.id, + ref: this.ref, + }); } props = $derived.by(() => ({})); } +type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; +type NavigationMenuContentImplStateProps = WithRefProps; + +class NavigationMenuContentImplState { + context: NavigationMenuProviderState; + itemContext: NavigationMenuItemState; + contentContext: NavigationMenuContentState; + listContext: NavigationMenuListState; + prevMotionAttribute = $state(null); + + motionAttribute: MotionAttribute | null = $derived.by(() => { + const items = this.listContext.listTriggers; + const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean); + if (this.context.dir.current === "rtl") values.reverse(); + const index = values.indexOf(this.context.value.current); + const prevIndex = values.indexOf(this.context.previousValue.current); + const isSelected = this.itemContext.value.current === this.context.value.current; + const wasSelected = prevIndex === values.indexOf(this.itemContext.value.current); + + // We only want to update selected and the last selected content + // this avoids animations being interrupted outside of that range + if (!isSelected && !wasSelected) return untrack(() => this.prevMotionAttribute); + + const attribute = (() => { + // Don't provide a direction on the initial open + if (index !== prevIndex) { + // If we're moving to this item from another + if (isSelected && prevIndex !== -1) + return index > prevIndex ? "from-end" : "from-start"; + // If we're leaving this item for another + if (wasSelected && index !== -1) return index > prevIndex ? "to-start" : "to-end"; + } + // Otherwise we're entering from close or leaving the list + // entirely and should not animate in any direction + return null; + })(); + + untrack(() => (this.prevMotionAttribute = attribute)); + return attribute; + }); + + constructor( + props: NavigationMenuContentImplStateProps, + contentContext: NavigationMenuContentState + ) { + this.contentContext = contentContext; + this.listContext = contentContext.listContext; + this.itemContext = contentContext.itemContext; + this.context = contentContext.context; + + watch( + [ + () => this.itemContext.value.current, + () => this.itemContext.triggerNode, + () => this.contentContext.ref.current, + ], + () => { + const content = this.contentContext.ref.current; + if (!(content && this.context.isRootMenu)) return; + + const handleClose = () => { + this.context.onItemDismiss(); + this.itemContext.onRootContentClose(); + if (content.contains(document.activeElement)) { + this.itemContext.triggerNode?.focus(); + } + }; + + const removeListener = listenRootContentDismiss(content, handleClose); + + return () => { + removeListener(); + }; + } + ); + + this.onFocusOutside = this.onFocusOutside.bind(this); + this.onInteractOutside = this.onInteractOutside.bind(this); + this.onkeydown = this.onkeydown.bind(this); + this.onEscapeKeydown = this.onEscapeKeydown.bind(this); + } + + onFocusOutside(e: Event) { + this.itemContext.onContentFocusOutside(); + const target = e.target as HTMLElement; + // only dismiss content when focus moves outside of the menu + if (this.context.rootNavigationMenuRef.current?.contains(target)) { + e.preventDefault(); + } + } + + onInteractOutside(e: PointerEvent) { + const target = e.target as HTMLElement; + const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target)); + const isRootViewport = + this.context.isRootMenu && this.context.viewportRef.current?.contains(target); + if (isTrigger || isRootViewport || !this.context.isRootMenu) e.preventDefault(); + } + + onkeydown(e: BitsKeyboardEvent) { + const isMetaKey = e.altKey || e.ctrlKey || e.metaKey; + const isTabKey = e.key === kbd.TAB && !isMetaKey; + if (!isTabKey) return; + const candidates = getTabbableCandidates(e.currentTarget); + 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.itemContext.focusProxyNode?.focus(); + } + } + + onEscapeKeydown(_: KeyboardEvent) { + // prevent the dropdown from reopening after the + // escape key has been pressed + this.itemContext.wasEscapeClose = true; + } + + props = $derived.by( + () => + ({ + id: this.contentContext.id.current, + "aria-labelledby": this.itemContext.triggerId, + "data-motion": this.motionAttribute ?? undefined, + "data-orientation": getDataOrientation(this.context.orientation.current), + }) as const + ); +} + +class NavigationMenuViewportContentMounterState { + context: NavigationMenuProviderState; + contentContext: NavigationMenuContentState; + + constructor(context: NavigationMenuProviderState, contentContext: NavigationMenuContentState) { + this.context = context; + this.contentContext = contentContext; + + $effect(() => { + this.context.onViewportContentChange( + this.contentContext.value, + this.contentContext.itemContext + ); + }); + + onDestroyEffect(() => { + this.context.onViewportContentRemove(this.contentContext.value); + }); + } +} + +class NavigationMenuViewportState { + context: NavigationMenuProviderState; + open = $derived.by(() => Boolean(this.context.value.current)); + + constructor(context: NavigationMenuProviderState) { + this.context = context; + } +} + +type NavigationMenuViewportImplStateProps = WithRefProps; + +class NavigationMenuViewportImplState { + id: NavigationMenuViewportImplStateProps["id"]; + ref: NavigationMenuViewportImplStateProps["ref"]; + context: NavigationMenuProviderState; + size = $state<{ width: number; height: number } | null>(null); + contentNode = $state(null); + viewportWidth = $derived.by(() => (this.size ? `${this.size.width}px` : undefined)); + viewportHeight = $derived.by(() => (this.size ? `${this.size.height}px` : undefined)); + open = $derived.by(() => Boolean(this.context.value.current)); + // We persist the last active content value as the viewport may be animating out + // and we want the content to remain mounted for the lifecycle of the viewport. + activeContentValue = $derived.by(() => + this.open ? this.context.value.current : this.context.previousValue.current + ); + + constructor(props: NavigationMenuViewportImplStateProps, context: NavigationMenuProviderState) { + this.id = props.id; + this.ref = props.ref; + this.context = context; + + useRefById({ + id: this.id, + ref: this.ref, + onRefChange: (node) => { + this.context.viewportRef.current = node; + }, + }); + + /** + * Update viewport size to match the active content node. + * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform. + * For example, if content animates in from `scale(0.5)` the dimensions would be anything + * from `0.5` to `1` of the intended size. + */ + const handleSizeChange = () => { + if (this.contentNode) { + this.size = { + width: this.contentNode.offsetWidth, + height: this.contentNode.offsetHeight, + }; + } + }; + + useResizeObserver(() => this.contentNode, handleSizeChange); + } + + props = $derived.by( + () => + ({ + id: this.id.current, + "data-state": getDataOpenClosed(this.open), + "data-orientation": getDataOrientation(this.context.orientation.current), + style: { + pointerEvents: !this.open && this.context.isRootMenu ? "none" : undefined, + "--bits-navigation-menu-viewport-width": this.viewportWidth, + "--bits-navigation-menu-viewport-height": this.viewportHeight, + }, + onpointerenter: this.context.onContentEnter, + onpointerleave: this.context.onContentLeave, + }) as const + ); +} + const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = createContext("NavigationMenu.Root", "NavigationMenuProvider"); @@ -724,6 +991,13 @@ const [setNavigationMenuItemContext, getNavigationMenuItemContext] = const [setNavigationMenuListContext, getNavigationMenuListContext] = createContext("NavigationMenu.List"); +const [setNavigationMenuContentContext, getNavigationMenuContentContext] = + createContext("NavigationMenu.Content"); + +export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { + return new NavigationMenuRootState(props); +} + export function useNavigationMenuProvider(props: NavigationMenuProviderStateProps) { return setNavigationMenuProviderContext(new NavigationMenuProviderState(props)); } @@ -757,6 +1031,39 @@ export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) }); } +export function useNavigationMenuContent(props: NavigationMenuContentStateProps) { + return setNavigationMenuContentContext( + new NavigationMenuContentState(props, { + provider: getNavigationMenuProviderContext(), + item: getNavigationMenuItemContext(), + list: getNavigationMenuListContext(), + }) + ); +} + +export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { + return new NavigationMenuLinkState(props, getNavigationMenuProviderContext()); +} + +export function useNavigationMenuContentImpl(props: NavigationMenuContentImplStateProps) { + return new NavigationMenuContentImplState(props, getNavigationMenuContentContext()); +} + +export function useNavigationMenuViewport() { + return new NavigationMenuViewportState(getNavigationMenuProviderContext()); +} + +export function useNavigationMenuViewportImpl(props: NavigationMenuViewportImplStateProps) { + return new NavigationMenuViewportImplState(props, getNavigationMenuProviderContext()); +} + +export function useNavigationMenuViewportContentMounter() { + return new NavigationMenuViewportContentMounterState( + getNavigationMenuProviderContext(), + getNavigationMenuContentContext() + ); +} + // function focusFirst(candidates: HTMLElement[]) { diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts index f6dd8d4e3..b463069d3 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts @@ -72,6 +72,13 @@ export type NavigationMenuSubPropsWithoutHTML = WithChild<{ */ onValueChange?: OnChangeFn; + /** + * Whether or not the value state is controlled or not. If `true`, the component will not update + * the value state internally, instead it will call `onValueChange` when it would have + * otherwise, and it is up to you to update the `value` prop that is passed to the component. + */ + controlledValue?: boolean; + /** * The orientation of the menu. */ diff --git a/packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts b/packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts new file mode 100644 index 000000000..57dc7ed95 --- /dev/null +++ b/packages/bits-ui/src/lib/internal/previous-with-init.svelte.ts @@ -0,0 +1,26 @@ +import type { Getter } from "svelte-toolbelt"; + +/** + * Holds the previous value of a getter, with the initial value being + * the value of the getter when the instance is created, rather than + * `undefined`. + */ +export class PreviousWithInit { + #previous = $state(null!); + #curr: T; + + constructor(getter: Getter) { + const init = getter(); + this.#previous = init; + this.#curr = init; + + $effect(() => { + this.#previous = this.#curr; + this.#curr = getter(); + }); + } + + get current(): T { + return this.#previous; + } +} From a1afad5d65ee2d9769dd2cbd2b9a5ed7364630e3 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 14 Dec 2024 19:54:17 -0500 Subject: [PATCH 4/8] more --- .../navigation-menu-content-impl.svelte | 67 ++++++++++++++++++ .../components/navigation-menu-content.svelte | 70 ++++--------------- .../navigation-menu-indicator-impl.svelte | 34 +++++++++ .../navigation-menu-indicator.svelte | 26 ++----- .../components/navigation-menu-list.svelte | 15 ++-- ...ation-menu-viewport-content-mounter.svelte | 5 ++ .../navigation-menu-viewport-impl.svelte | 36 ++++++++++ .../navigation-menu-viewport.svelte | 20 +----- .../components/navigation-menu.svelte | 1 + .../navigation-menu.svelte.ts | 34 +++++++-- .../src/lib/bits/navigation-menu-2/types.ts | 30 +++++++- packages/bits-ui/src/lib/index.ts | 1 + .../demos/navigation-menu-demo.svelte | 56 +++++++-------- 13 files changed, 260 insertions(+), 135 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte create mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte index 0fbba9978..3b1a7b22c 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte @@ -1,2 +1,69 @@ + + { + onInteractOutside(e); + if (e.defaultPrevented) return; + contentImplState.onInteractOutside(e); + }} + onFocusOutside={(e) => { + onFocusOutside(e); + if (e.defaultPrevented) return; + contentImplState.onFocusOutside(e); + }} + {interactOutsideBehavior} +> + {#snippet children({ props: dismissibleProps })} + { + onEscapeKeydown(e); + if (e.defaultPrevented) return; + contentImplState.onEscapeKeydown(e); + }} + {escapeKeydownBehavior} + > + {@const finalProps = mergeProps(mergedProps, dismissibleProps)} + {#if child} + {@render child({ props: finalProps })} + {:else} +
    + {@render childrenProp?.()} +
    + {/if} +
    + {/snippet} +
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte index fbb286b29..6ff0f5a8d 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte @@ -1,82 +1,38 @@ - - +{#if !contentState.context.viewportRef.current} + {#snippet presence()} - { - onEscapeKeydown?.(e); - if (e.defaultPrevented) return; - contentState.onEscapeKeydown(e); - }} - > - { - onInteractOutside?.(e); - if (e.defaultPrevented) return; - contentState.onInteractOutside(e); - }} - onFocusOutside={(e) => { - onFocusOutside?.(e); - if (e.defaultPrevented) return; - contentState.onFocusOutside(e); - }} - > - {#snippet children({ props: dismissibleProps })} - {#if child} - - {@render child({ props: mergeProps(dismissibleProps, mergedProps) })} - {:else} - -
    - {@render contentChildren?.()} -
    - {/if} - {/snippet} -
    -
    + {/snippet}
    -
    +{:else} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte new file mode 100644 index 000000000..a139e6ba9 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte @@ -0,0 +1,34 @@ + + +{#if indicatorState.position} + {#if child} + {@render child({ props: mergedProps })} + {:else} +
    + {@render children?.()} +
    + {/if} +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte index 0829e60a3..f334b8a28 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte @@ -1,7 +1,8 @@ -{#if indicatorState.menu.indicatorTrackNode} - +{#if indicatorState.context.indicatorTrackRef.current} + {#snippet presence()} - {#if child} - {@render child({ props: mergedProps })} - {:else} -
    - {@render children?.()} -
    - {/if} + {/snippet}
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte index 4d509970d..614db6c9f 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte @@ -18,19 +18,18 @@ () => ref, (v) => (ref = v) ), - indicatorTrackRef: box(null), }); const mergedProps = $derived(mergeProps(restProps, listState.props)); - const indicatorTrackProps = $derived(mergeProps(listState.indicatorTrackProps, {})); + const wrapperProps = $derived(mergeProps(listState.wrapperProps)); -
    - {#if child} - {@render child({ props: mergedProps })} - {:else} +{#if child} + {@render child({ props: mergedProps, wrapperProps })} +{:else} +
      {@render children?.()}
    - {/if} -
    +
    +{/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte new file mode 100644 index 000000000..36ff3ffec --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte new file mode 100644 index 000000000..bf846641c --- /dev/null +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte @@ -0,0 +1,36 @@ + + +
    + {#each Array.from(viewportState.context.viewportContent) as [value, item]} + {@const isActive = viewportState.activeContentValue === value} + + {#snippet presence()} + + {/snippet} + + {/each} +
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte index 99f6f4d31..7dd6e0234 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte @@ -1,7 +1,7 @@ {#snippet presence()} - {#if child} - {@render child({ props: mergedProps })} - {:else} -
    - {@render children?.()} -
    - {/if} + {/snippet}
    diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte index b0a88c4e4..dd3e4d0e6 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte @@ -25,6 +25,7 @@ value: box.with( () => value, (v) => { + rootState.handleValueChange(v); if (controlledValue) { onValueChange(v); } else { diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index cc6251213..4ad33beaa 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -7,7 +7,6 @@ import { type AnyFn, type ReadableBoxedValues, type WithRefProps, - type WritableBox, type WritableBoxedValues, box, onDestroyEffect, @@ -349,7 +348,7 @@ class NavigationMenuListState { wrapperProps = $derived.by( () => ({ - id: this.id.current, + id: this.wrapperId.current, }) as const ); @@ -431,6 +430,7 @@ class NavigationMenuTriggerState { hasPointerMoveOpened = $state(false); wasClickClose = $state(false); open = $derived.by(() => this.itemContext.value.current === this.context.value.current); + focusProxyMounted = $state(false); constructor( props: NavigationMenuTriggerStateProps, @@ -458,7 +458,7 @@ class NavigationMenuTriggerState { onRefChange: (node) => { this.itemContext.focusProxyNode = node; }, - deps: () => this.open, + deps: () => this.focusProxyMounted, }); $effect(() => { @@ -544,6 +544,11 @@ class NavigationMenuTriggerState { "aria-expanded": getAriaExpanded(this.open), "aria-controls": this.itemContext.contentId, [TRIGGER_ATTR]: "", + onpointermove: this.onpointermove, + onpointerleave: this.onpointerleave, + onpointerenter: this.onpointerenter, + onclick: this.onclick, + onkeydown: this.onkeydown, }) as const ); @@ -743,9 +748,26 @@ class NavigationMenuContentState { id: this.id, ref: this.ref, }); + + this.onpointerenter = this.onpointerenter.bind(this); } - props = $derived.by(() => ({})); + onpointerenter(_: BitsPointerEvent) { + this.context.onContentEnter; + } + + onpointerleave = whenMouse(() => { + this.context.onContentLeave(); + }); + + props = $derived.by( + () => + ({ + id: this.id.current, + onpointerenter: this.onpointerenter, + onpointerleave: this.onpointerleave, + }) as const + ); } type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; @@ -1064,6 +1086,10 @@ export function useNavigationMenuViewportContentMounter() { ); } +export function useNavigationMenuIndicator() { + return new NavigationMenuIndicatorState(getNavigationMenuProviderContext()); +} + // function focusFirst(candidates: HTMLElement[]) { diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts index b463069d3..87b4c9c13 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts @@ -1,4 +1,11 @@ -import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; +import type { EscapeBehaviorType } from "../utilities/escape-layer/types.js"; +import type { InteractOutsideBehaviorType } from "../utilities/dismissible-layer/types.js"; +import type { + OnChangeFn, + WithChild, + WithChildNoChildrenSnippetProps, + Without, +} from "$lib/internal/types.js"; import type { BitsPrimitiveAnchorAttributes, BitsPrimitiveButtonAttributes, @@ -88,7 +95,16 @@ export type NavigationMenuSubPropsWithoutHTML = WithChild<{ export type NavigationMenuSubProps = NavigationMenuSubPropsWithoutHTML & Without; -export type NavigationMenuListPropsWithoutHTML = WithChild; +export type NavigationMenuListPropsWithoutHTML = WithChildNoChildrenSnippetProps< + {}, + { + /** + * Attributes to spread onto a wrapper element around the content. + * Do not style the wrapper element, its styles are computed by Floating UI. + */ + wrapperProps: Record; + } +>; export type NavigationMenuListProps = NavigationMenuListPropsWithoutHTML & Without; @@ -134,6 +150,16 @@ export type NavigationMenuContentPropsWithoutHTML = WithChild<{ */ onEscapeKeydown?: (event: KeyboardEvent) => void; + /** + * Behavior when the escape key is pressed while the menu content is open. + */ + escapeKeydownBehavior?: EscapeBehaviorType; + + /** + * Behavior when an interaction occurs outside the content. + */ + interactOutsideBehavior?: InteractOutsideBehaviorType; + /** * Whether to forcefully mount the content, regardless of the open state. * This is useful when wanting to use more custom transition and animation diff --git a/packages/bits-ui/src/lib/index.ts b/packages/bits-ui/src/lib/index.ts index 1f8d5f74c..6bf9c60c7 100644 --- a/packages/bits-ui/src/lib/index.ts +++ b/packages/bits-ui/src/lib/index.ts @@ -20,6 +20,7 @@ export { LinkPreview, Menubar, NavigationMenu, + NavMenu, Pagination, PinInput, Popover, 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 c82793b0e..8b8d4e886 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -1,5 +1,5 @@ @@ -57,10 +66,11 @@ {escapeKeydownBehavior} > {@const finalProps = mergeProps(mergedProps, dismissibleProps)} - {#if child} + {#if child && !childrenProp} {@render child({ props: finalProps })} {:else}
    +

    Here I am

    {@render childrenProp?.()}
    {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte index 6ff0f5a8d..9dfa61136 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte @@ -34,5 +34,5 @@ {/snippet} {:else} - + {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte index 36ff3ffec..5d17affee 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-content-mounter.svelte @@ -1,5 +1,12 @@ diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte index bf846641c..52bce6b39 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte @@ -22,14 +22,24 @@ }); const mergedProps = $derived(mergeProps(restProps, viewportState.props)); + + const viewportContent = $derived.by(() => { + viewportState.context.viewportContent.keys(); + return Array.from(viewportState.context.viewportContent); + });
    - {#each Array.from(viewportState.context.viewportContent) as [value, item]} + {#each viewportContent as [value, item]} {@const isActive = viewportState.activeContentValue === value} - + {#snippet presence()} - + {/snippet} {/each} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index 4ad33beaa..db565c9a1 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -5,6 +5,7 @@ import { type AnyFn, + type ReadableBox, type ReadableBoxedValues, type WithRefProps, type WritableBoxedValues, @@ -13,7 +14,7 @@ import { useRefById, } from "svelte-toolbelt"; import { watch } from "runed"; -import { untrack } from "svelte"; +import { type Snippet, untrack } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { createContext } from "$lib/internal/create-context.js"; import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; @@ -90,13 +91,6 @@ class NavigationMenuProviderState { this.onContentLeave = props.onContentLeave ?? noop; this.onItemDismiss = props.onItemDismiss; this.onItemSelect = props.onItemSelect; - - this.onItemSelect = this.onItemSelect.bind(this); - this.onItemDismiss = this.onItemDismiss.bind(this); - this.onTriggerEnter = this.onTriggerEnter.bind(this); - this.onTriggerLeave = this.onTriggerLeave.bind(this); - this.onContentEnter = this.onContentEnter.bind(this); - this.onContentLeave = this.onContentLeave.bind(this); } onViewportContentChange(contentValue: string, item: NavigationMenuItemState) { @@ -161,7 +155,9 @@ class NavigationMenuRootState { orientation: this.orientation, rootNavigationMenuRef: this.ref, isRootMenu: true, - onTriggerEnter: (itemValue) => this.#onTriggerEnter(itemValue), + onTriggerEnter: (itemValue) => { + return this.#onTriggerEnter(itemValue); + }, onTriggerLeave: () => this.#onTriggerLeave(), onContentEnter: () => this.#onContentEnter(), onContentLeave: () => this.#onContentLeave(), @@ -368,7 +364,7 @@ type NavigationMenuItemStateProps = WithRefProps< }> >; -class NavigationMenuItemState { +export class NavigationMenuItemState { ref: NavigationMenuItemStateProps["ref"]; id: NavigationMenuItemStateProps["id"]; value: NavigationMenuItemStateProps["value"]; @@ -379,11 +375,15 @@ class NavigationMenuItemState { wasEscapeClose = $state(false); contentId = $derived.by(() => this.contentNode?.id); triggerId = $derived.by(() => this.triggerNode?.id); + listContext: NavigationMenuListState; + contentChildren: ReadableBox = box(undefined); + contentChild: ReadableBox = box(undefined); - constructor(props: NavigationMenuItemStateProps) { + constructor(props: NavigationMenuItemStateProps, listContext: NavigationMenuListState) { this.ref = props.ref; this.id = props.id; this.value = props.value; + this.listContext = listContext; } #handleContentEntry = (side: "start" | "end" = "start") => { @@ -774,9 +774,10 @@ type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; type NavigationMenuContentImplStateProps = WithRefProps; class NavigationMenuContentImplState { + ref: NavigationMenuContentImplStateProps["ref"]; + id: NavigationMenuContentImplStateProps["id"]; context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; - contentContext: NavigationMenuContentState; listContext: NavigationMenuListState; prevMotionAttribute = $state(null); @@ -811,23 +812,21 @@ class NavigationMenuContentImplState { return attribute; }); - constructor( - props: NavigationMenuContentImplStateProps, - contentContext: NavigationMenuContentState - ) { - this.contentContext = contentContext; - this.listContext = contentContext.listContext; - this.itemContext = contentContext.itemContext; - this.context = contentContext.context; + constructor(props: NavigationMenuContentImplStateProps, itemContext: NavigationMenuItemState) { + this.ref = props.ref; + this.id = props.id; + this.itemContext = itemContext; + this.listContext = itemContext.listContext; + this.context = itemContext.listContext.context; watch( [ () => this.itemContext.value.current, () => this.itemContext.triggerNode, - () => this.contentContext.ref.current, + () => this.ref.current, ], () => { - const content = this.contentContext.ref.current; + const content = this.ref.current; if (!(content && this.context.isRootMenu)) return; const handleClose = () => { @@ -901,7 +900,7 @@ class NavigationMenuContentImplState { props = $derived.by( () => ({ - id: this.contentContext.id.current, + id: this.id.current, "aria-labelledby": this.itemContext.triggerId, "data-motion": this.motionAttribute ?? undefined, "data-orientation": getDataOrientation(this.context.orientation.current), @@ -909,13 +908,24 @@ class NavigationMenuContentImplState { ); } +type NavigationMenuViewportContentMounterStateProps = ReadableBoxedValues<{ + children: Snippet | undefined; + child: Snippet | undefined; +}>; + class NavigationMenuViewportContentMounterState { context: NavigationMenuProviderState; contentContext: NavigationMenuContentState; - constructor(context: NavigationMenuProviderState, contentContext: NavigationMenuContentState) { + constructor( + props: NavigationMenuViewportContentMounterStateProps, + context: NavigationMenuProviderState, + contentContext: NavigationMenuContentState + ) { this.context = context; this.contentContext = contentContext; + this.contentContext.itemContext.contentChildren = props.children; + this.contentContext.itemContext.contentChild = props.child; $effect(() => { this.context.onViewportContentChange( @@ -1035,7 +1045,9 @@ export function useNavigationMenuList(props: NavigationMenuListStateProps) { } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - return setNavigationMenuItemContext(new NavigationMenuItemState(props)); + return setNavigationMenuItemContext( + new NavigationMenuItemState(props, getNavigationMenuListContext()) + ); } export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { @@ -1067,8 +1079,11 @@ export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { return new NavigationMenuLinkState(props, getNavigationMenuProviderContext()); } -export function useNavigationMenuContentImpl(props: NavigationMenuContentImplStateProps) { - return new NavigationMenuContentImplState(props, getNavigationMenuContentContext()); +export function useNavigationMenuContentImpl( + props: NavigationMenuContentImplStateProps, + itemState: NavigationMenuItemState = getNavigationMenuItemContext() +) { + return new NavigationMenuContentImplState(props, itemState); } export function useNavigationMenuViewport() { @@ -1079,8 +1094,11 @@ export function useNavigationMenuViewportImpl(props: NavigationMenuViewportImplS return new NavigationMenuViewportImplState(props, getNavigationMenuProviderContext()); } -export function useNavigationMenuViewportContentMounter() { +export function useNavigationMenuViewportContentMounter( + props: NavigationMenuViewportContentMounterStateProps +) { return new NavigationMenuViewportContentMounterState( + props, getNavigationMenuProviderContext(), getNavigationMenuContentContext() ); 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 8b8d4e886..42e3cc9ef 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -78,7 +78,7 @@ />
    • From 3f5ca3c8f3870e20539a6970db8d7f69e75bb8a7 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 15 Dec 2024 20:29:59 -0500 Subject: [PATCH 6/8] ok ok --- .../navigation-menu-content-impl.svelte | 19 ++++++++++++------- .../components/navigation-menu-content.svelte | 2 +- ...ation-menu-viewport-content-mounter.svelte | 3 ++- .../navigation-menu-viewport-impl.svelte | 12 ++++++++++-- .../navigation-menu.svelte.ts | 9 +++++++++ .../demos/navigation-menu-demo.svelte | 6 ++++-- 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte index e12ec3c9e..9b3930900 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte @@ -1,5 +1,6 @@ diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte index 52bce6b39..610685248 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport-impl.svelte @@ -32,13 +32,21 @@
      {#each viewportContent as [value, item]} {@const isActive = viewportState.activeContentValue === value} - + {#snippet presence()} { + if (isActive && v) { + viewportState.contentNode = v; + } + }} /> {/snippet} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index db565c9a1..2702acf86 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -378,6 +378,7 @@ export class NavigationMenuItemState { listContext: NavigationMenuListState; contentChildren: ReadableBox = box(undefined); contentChild: ReadableBox = box(undefined); + contentProps: ReadableBox> = box({}); constructor(props: NavigationMenuItemStateProps, listContext: NavigationMenuListState) { this.ref = props.ref; @@ -819,6 +820,12 @@ class NavigationMenuContentImplState { this.listContext = itemContext.listContext; this.context = itemContext.listContext.context; + useRefById({ + id: this.id, + ref: this.ref, + deps: () => this.context.value.current, + }); + watch( [ () => this.itemContext.value.current, @@ -911,6 +918,7 @@ class NavigationMenuContentImplState { type NavigationMenuViewportContentMounterStateProps = ReadableBoxedValues<{ children: Snippet | undefined; child: Snippet | undefined; + props: Record; }>; class NavigationMenuViewportContentMounterState { @@ -926,6 +934,7 @@ class NavigationMenuViewportContentMounterState { this.contentContext = contentContext; this.contentContext.itemContext.contentChildren = props.children; this.contentContext.itemContext.contentChild = props.child; + this.contentContext.itemContext.contentProps = props.props; $effect(() => { this.context.onViewportContentChange( 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 42e3cc9ef..e4495fc24 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -78,9 +78,11 @@ /> -
        +
        • Date: Tue, 7 Jan 2025 18:40:02 -0500 Subject: [PATCH 7/8] new apis --- .../navigation-menu.svelte.ts | 87 +++++++++---------- packages/bits-ui/src/lib/internal/events.ts | 8 +- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index 2702acf86..500318592 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -13,10 +13,9 @@ import { onDestroyEffect, useRefById, } from "svelte-toolbelt"; -import { watch } from "runed"; +import { Context, watch } from "runed"; import { type Snippet, untrack } from "svelte"; import { SvelteMap } from "svelte/reactivity"; -import { createContext } from "$lib/internal/create-context.js"; import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; import { getAriaExpanded, @@ -33,9 +32,9 @@ import type { BitsPointerEvent, } from "$lib/internal/types.js"; import { kbd } from "$lib/internal/kbd.js"; -import { createCustomEvent } from "$lib/internal/events.js"; import { useResizeObserver } from "$lib/internal/use-resize-observer.svelte.js"; import { PreviousWithInit } from "$lib/internal/previous-with-init.svelte.js"; +import { CustomEventDispatcher } from "$lib/internal/events.js"; const ROOT_ATTR = "data-navigation-menu-root"; const SUB_ATTR = "data-navigation-menu-sub"; @@ -576,18 +575,15 @@ type NavigationMenuLinkStateProps = WithRefProps & onSelect: (e: Event) => void; }>; -const [dispatchLinkSelect, listenLinkSelect] = createCustomEvent("bitsLinkSelect", { +const LINK_SELECT_EVENT = new CustomEventDispatcher("bitsLinkSelect", { bubbles: true, cancelable: true, }); -const [dispatchRootContentDismiss, listenRootContentDismiss] = createCustomEvent( - "bitsRootContentDismiss", - { - cancelable: true, - bubbles: true, - } -); +const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDismiss", { + cancelable: true, + bubbles: true, +}); class NavigationMenuLinkState { id: NavigationMenuLinkStateProps["id"]; @@ -612,11 +608,11 @@ class NavigationMenuLinkState { onclick(e: BitsMouseEvent) { const currTarget = e.currentTarget; - listenLinkSelect(currTarget, (e) => this.onSelect.current(e), { once: true }); - const linkSelectEvent = dispatchLinkSelect(currTarget); + LINK_SELECT_EVENT.listen(currTarget, (e) => this.onSelect.current(e), { once: true }); + const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget); if (!linkSelectEvent.defaultPrevented && !e.metaKey) { - dispatchRootContentDismiss(currTarget); + ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget); } } @@ -843,8 +839,7 @@ class NavigationMenuContentImplState { this.itemContext.triggerNode?.focus(); } }; - - const removeListener = listenRootContentDismiss(content, handleClose); + const removeListener = ROOT_CONTENT_DISMISS_EVENT.listen(content, handleClose); return () => { removeListener(); @@ -1023,84 +1018,84 @@ class NavigationMenuViewportImplState { ); } -const [setNavigationMenuProviderContext, getNavigationMenuProviderContext] = - createContext("NavigationMenu.Root", "NavigationMenuProvider"); +const NavigationMenuProviderContext = new Context( + "NavigationMenu.Root" +); -const [setNavigationMenuItemContext, getNavigationMenuItemContext] = - createContext("NavigationMenu.Item"); +const NavigationMenuItemContext = new Context("NavigationMenu.Item"); -const [setNavigationMenuListContext, getNavigationMenuListContext] = - createContext("NavigationMenu.List"); +const NavigationMenuListContext = new Context("NavigationMenu.List"); -const [setNavigationMenuContentContext, getNavigationMenuContentContext] = - createContext("NavigationMenu.Content"); +const NavigationMenuContentContext = new Context( + "NavigationMenu.Content" +); export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { return new NavigationMenuRootState(props); } export function useNavigationMenuProvider(props: NavigationMenuProviderStateProps) { - return setNavigationMenuProviderContext(new NavigationMenuProviderState(props)); + return NavigationMenuProviderContext.set(new NavigationMenuProviderState(props)); } export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { - return new NavigationMenuSubState(props, getNavigationMenuProviderContext()); + return new NavigationMenuSubState(props, NavigationMenuProviderContext.get()); } export function useNavigationMenuList(props: NavigationMenuListStateProps) { - return setNavigationMenuListContext( - new NavigationMenuListState(props, getNavigationMenuProviderContext()) + return NavigationMenuListContext.set( + new NavigationMenuListState(props, NavigationMenuProviderContext.get()) ); } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - return setNavigationMenuItemContext( - new NavigationMenuItemState(props, getNavigationMenuListContext()) + return NavigationMenuItemContext.set( + new NavigationMenuItemState(props, NavigationMenuListContext.get()) ); } export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { return new NavigationMenuIndicatorImplState(props, { - provider: getNavigationMenuProviderContext(), - list: getNavigationMenuListContext(), + provider: NavigationMenuProviderContext.get(), + list: NavigationMenuListContext.get(), }); } export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) { return new NavigationMenuTriggerState(props, { - provider: getNavigationMenuProviderContext(), - item: getNavigationMenuItemContext(), - list: getNavigationMenuListContext(), + provider: NavigationMenuProviderContext.get(), + item: NavigationMenuItemContext.get(), + list: NavigationMenuListContext.get(), }); } export function useNavigationMenuContent(props: NavigationMenuContentStateProps) { - return setNavigationMenuContentContext( + return NavigationMenuContentContext.set( new NavigationMenuContentState(props, { - provider: getNavigationMenuProviderContext(), - item: getNavigationMenuItemContext(), - list: getNavigationMenuListContext(), + provider: NavigationMenuProviderContext.get(), + item: NavigationMenuItemContext.get(), + list: NavigationMenuListContext.get(), }) ); } export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { - return new NavigationMenuLinkState(props, getNavigationMenuProviderContext()); + return new NavigationMenuLinkState(props, NavigationMenuProviderContext.get()); } export function useNavigationMenuContentImpl( props: NavigationMenuContentImplStateProps, - itemState: NavigationMenuItemState = getNavigationMenuItemContext() + itemState: NavigationMenuItemState = NavigationMenuItemContext.get() ) { return new NavigationMenuContentImplState(props, itemState); } export function useNavigationMenuViewport() { - return new NavigationMenuViewportState(getNavigationMenuProviderContext()); + return new NavigationMenuViewportState(NavigationMenuProviderContext.get()); } export function useNavigationMenuViewportImpl(props: NavigationMenuViewportImplStateProps) { - return new NavigationMenuViewportImplState(props, getNavigationMenuProviderContext()); + return new NavigationMenuViewportImplState(props, NavigationMenuProviderContext.get()); } export function useNavigationMenuViewportContentMounter( @@ -1108,13 +1103,13 @@ export function useNavigationMenuViewportContentMounter( ) { return new NavigationMenuViewportContentMounterState( props, - getNavigationMenuProviderContext(), - getNavigationMenuContentContext() + NavigationMenuProviderContext.get(), + NavigationMenuContentContext.get() ); } export function useNavigationMenuIndicator() { - return new NavigationMenuIndicatorState(getNavigationMenuProviderContext()); + return new NavigationMenuIndicatorState(NavigationMenuProviderContext.get()); } // diff --git a/packages/bits-ui/src/lib/internal/events.ts b/packages/bits-ui/src/lib/internal/events.ts index 3c157495c..e6dd3e2b5 100644 --- a/packages/bits-ui/src/lib/internal/events.ts +++ b/packages/bits-ui/src/lib/internal/events.ts @@ -75,11 +75,15 @@ export class CustomEventDispatcher { return event; } - listen(element: EventTarget, callback: (event: CustomEvent) => void) { + listen( + element: EventTarget, + callback: (event: CustomEvent) => void, + options?: AddEventListenerOptions + ) { const handler = (event: Event) => { callback(event as CustomEvent); }; - return on(element, this.eventName, handler); + return on(element, this.eventName, handler, options); } } From 15e866505a8e2cc9fc9740f373e927c68a4141ca Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 1 Feb 2025 13:55:44 -0500 Subject: [PATCH 8/8] fix: issue with transition out --- .../navigation-menu.svelte.ts | 103 +++++++++--------- 1 file changed, 51 insertions(+), 52 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts index 500318592..f42e47390 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts @@ -2,18 +2,18 @@ * Based on Radix UI's Navigation Menu * https://www.radix-ui.com/docs/primitives/components/navigation-menu */ - import { type AnyFn, type ReadableBox, type ReadableBoxedValues, type WithRefProps, type WritableBoxedValues, + afterTick, box, onDestroyEffect, useRefById, } from "svelte-toolbelt"; -import { Context, watch } from "runed"; +import { Context, watch, watchOnce } from "runed"; import { type Snippet, untrack } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; @@ -461,29 +461,20 @@ class NavigationMenuTriggerState { deps: () => this.focusProxyMounted, }); - $effect(() => { - const node = this.ref.current; - - if (node) { - const unregister = this.listContext.registerTrigger(node); - - return () => { - unregister(); - }; + watch( + () => this.ref.current, + () => { + const node = this.ref.current; + if (!node) return; + return this.listContext.registerTrigger(node); } - }); - - this.onpointerenter = this.onpointerenter.bind(this); - this.onpointerleave = this.onpointerleave.bind(this); - this.onclick = this.onclick.bind(this); - this.onkeydown = this.onkeydown.bind(this); - this.focusProxyOnFocus = this.focusProxyOnFocus.bind(this); + ); } - onpointerenter(_: BitsPointerEvent) { + onpointerenter = (_: BitsPointerEvent) => { this.wasClickClose = false; this.itemContext.wasEscapeClose = false; - } + }; onpointermove = whenMouse(() => { if ( @@ -504,12 +495,12 @@ class NavigationMenuTriggerState { this.hasPointerMoveOpened = false; }); - onclick(_: BitsMouseEvent) { + onclick = (_: BitsMouseEvent) => { this.context.onItemSelect(this.itemContext.value.current); this.wasClickClose = this.open; - } + }; - onkeydown(e: BitsKeyboardEvent) { + onkeydown = (e: BitsKeyboardEvent) => { const verticalEntryKey = this.context.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[ @@ -520,9 +511,9 @@ class NavigationMenuTriggerState { // prevent focus group from handling the event e.preventDefault(); } - } + }; - focusProxyOnFocus(e: BitsFocusEvent) { + focusProxyOnFocus = (e: BitsFocusEvent) => { const content = this.itemContext.contentNode; const prevFocusedElement = e.relatedTarget as HTMLElement | null; const wasTriggerFocused = this.ref.current && prevFocusedElement === this.ref.current; @@ -531,7 +522,7 @@ class NavigationMenuTriggerState { if (wasTriggerFocused || !wasFocusFromContent) { this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end"); } - } + }; props = $derived.by( () => @@ -601,11 +592,9 @@ class NavigationMenuLinkState { id: this.id, ref: this.ref, }); - - this.onclick = this.onclick.bind(this); } - onclick(e: BitsMouseEvent) { + onclick = (e: BitsMouseEvent) => { const currTarget = e.currentTarget; LINK_SELECT_EVENT.listen(currTarget, (e) => this.onSelect.current(e), { once: true }); @@ -614,7 +603,7 @@ class NavigationMenuLinkState { if (!linkSelectEvent.defaultPrevented && !e.metaKey) { ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget); } - } + }; props = $derived.by( () => @@ -745,13 +734,11 @@ class NavigationMenuContentState { id: this.id, ref: this.ref, }); - - this.onpointerenter = this.onpointerenter.bind(this); } - onpointerenter(_: BitsPointerEvent) { + onpointerenter = (_: BitsPointerEvent) => { this.context.onContentEnter; - } + }; onpointerleave = whenMouse(() => { this.context.onContentLeave(); @@ -846,31 +833,26 @@ class NavigationMenuContentImplState { }; } ); - - this.onFocusOutside = this.onFocusOutside.bind(this); - this.onInteractOutside = this.onInteractOutside.bind(this); - this.onkeydown = this.onkeydown.bind(this); - this.onEscapeKeydown = this.onEscapeKeydown.bind(this); } - onFocusOutside(e: Event) { + onFocusOutside = (e: Event) => { this.itemContext.onContentFocusOutside(); const target = e.target as HTMLElement; // only dismiss content when focus moves outside of the menu if (this.context.rootNavigationMenuRef.current?.contains(target)) { e.preventDefault(); } - } + }; - onInteractOutside(e: PointerEvent) { + onInteractOutside = (e: PointerEvent) => { const target = e.target as HTMLElement; const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target)); const isRootViewport = this.context.isRootMenu && this.context.viewportRef.current?.contains(target); if (isTrigger || isRootViewport || !this.context.isRootMenu) e.preventDefault(); - } + }; - onkeydown(e: BitsKeyboardEvent) { + onkeydown = (e: BitsKeyboardEvent) => { const isMetaKey = e.altKey || e.ctrlKey || e.metaKey; const isTabKey = e.key === kbd.TAB && !isMetaKey; if (!isTabKey) return; @@ -891,13 +873,13 @@ class NavigationMenuContentImplState { // tab/shift+tab keypress on the proxy instead this.itemContext.focusProxyNode?.focus(); } - } + }; - onEscapeKeydown(_: KeyboardEvent) { + onEscapeKeydown = (_: KeyboardEvent) => { // prevent the dropdown from reopening after the // escape key has been pressed this.itemContext.wasEscapeClose = true; - } + }; props = $derived.by( () => @@ -964,17 +946,34 @@ class NavigationMenuViewportImplState { viewportWidth = $derived.by(() => (this.size ? `${this.size.width}px` : undefined)); viewportHeight = $derived.by(() => (this.size ? `${this.size.height}px` : undefined)); open = $derived.by(() => Boolean(this.context.value.current)); - // We persist the last active content value as the viewport may be animating out - // and we want the content to remain mounted for the lifecycle of the viewport. - activeContentValue = $derived.by(() => - this.open ? this.context.value.current : this.context.previousValue.current - ); + + activeContentValue = $state(); constructor(props: NavigationMenuViewportImplStateProps, context: NavigationMenuProviderState) { this.id = props.id; this.ref = props.ref; this.context = context; + // We persist the last active content value as the viewport may be animating out + // and we want the content to remain mounted for the lifecycle of the viewport. + watch.pre( + [ + () => this.open, + () => this.context.value.current, + () => this.context.previousValue.current, + ], + () => { + if (this.open) { + this.activeContentValue = this.context.value.current; + } else { + this.activeContentValue = undefined; + afterTick(() => { + this.activeContentValue = this.context.previousValue.current; + }); + } + } + ); + useRefById({ id: this.id, ref: this.ref,