From efb683609a73d92c9a249458e1d6a766d22266ed Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 13 Dec 2024 21:29:51 -0500 Subject: [PATCH 01/30] 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 02/30] 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 03/30] 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 04/30] 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 06/30] 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 07/30] 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 08/30] 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, From 1838a0f5c41818df4ea8774bdb1d4df46d5924c7 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 7 Feb 2025 21:07:54 -0500 Subject: [PATCH 09/30] broke --- .../navigation-menu.svelte.ts | 332 +++++++----------- 1 file changed, 132 insertions(+), 200 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 f42e47390..006fcc0a1 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,7 +13,7 @@ import { onDestroyEffect, useRefById, } from "svelte-toolbelt"; -import { Context, watch, watchOnce } from "runed"; +import { Context, watch } from "runed"; import { type Snippet, untrack } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; @@ -35,13 +35,14 @@ import { kbd } from "$lib/internal/kbd.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"; +import { useRovingFocus } from "$lib/internal/use-roving-focus.svelte.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"; +const NAVIGATION_MENU_ROOT_ATTR = "data-navigation-menu-root"; +const NAVIGATION_MENU_SUB_ATTR = "data-navigation-menu-sub"; +const NAVIGATION_MENU_LIST_ATTR = "data-navigation-menu-list"; +const NAVIGATION_MENU_ITEM_ATTR = "data-navigation-menu-item"; +const NAVIGATION_MENU_TRIGGER_ATTR = "data-navigation-menu-trigger"; +const NAVIGATION_MENU_LINK_ATTR = "data-navigation-menu-link"; type NavigationMenuProviderStateProps = ReadableBoxedValues<{ dir: Direction; @@ -115,32 +116,14 @@ type NavigationMenuRootStateProps = WithRefProps< >; 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); + openTimer = -1; + closeTimer = -1; + skipDelayTimer = -1; + isOpenDelayed = 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, - }); + constructor(readonly opts: NavigationMenuRootStateProps) { + useRefById(opts); onDestroyEffect(() => { window.clearTimeout(this.openTimer); @@ -149,10 +132,10 @@ class NavigationMenuRootState { }); this.provider = useNavigationMenuProvider({ - value: this.value, - dir: this.dir, - orientation: this.orientation, - rootNavigationMenuRef: this.ref, + value: this.opts.value, + dir: this.opts.dir, + orientation: this.opts.orientation, + rootNavigationMenuRef: this.opts.ref, isRootMenu: true, onTriggerEnter: (itemValue) => { return this.#onTriggerEnter(itemValue); @@ -185,7 +168,7 @@ class NavigationMenuRootState { } #onItemSelect(itemValue: string) { - if (this.value.current === itemValue) { + if (this.opts.value.current === itemValue) { this.setValue(""); } else { this.setValue(itemValue); @@ -197,12 +180,12 @@ class NavigationMenuRootState { } setValue(newValue: string) { - this.value.current = newValue; + this.opts.value.current = newValue; } handleValueChange(newValue: string) { const isOpen = newValue !== ""; - const hasSkipDelayDuration = this.skipDelayDuration.current > 0; + const hasSkipDelayDuration = this.opts.skipDelayDuration.current > 0; if (isOpen) { window.clearTimeout(this.skipDelayTimer); @@ -211,7 +194,7 @@ class NavigationMenuRootState { window.clearTimeout(this.skipDelayTimer); this.skipDelayTimer = window.setTimeout( () => (this.isOpenDelayed = true), - this.skipDelayDuration.current + this.opts.skipDelayDuration.current ); } } @@ -227,7 +210,7 @@ class NavigationMenuRootState { } handleDelayedOpen(itemValue: string) { - const isOpenItem = this.value.current === itemValue; + const isOpenItem = this.opts.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); @@ -235,17 +218,17 @@ class NavigationMenuRootState { this.openTimer = window.setTimeout(() => { window.clearTimeout(this.closeTimer); this.setValue(itemValue); - }, this.delayDuration.current); + }, this.opts.delayDuration.current); } } props = $derived.by( () => ({ - id: this.id.current, - "data-orientation": getDataOrientation(this.orientation.current), - dir: this.dir.current, - [ROOT_ATTR]: "", + id: this.opts.id.current, + "data-orientation": getDataOrientation(this.opts.orientation.current), + dir: this.opts.dir.current, + [NAVIGATION_MENU_ROOT_ATTR]: "", }) as const ); } @@ -260,29 +243,17 @@ type NavigationMenuSubStateProps = WithRefProps< >; 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, - }); + constructor( + readonly opts: NavigationMenuSubStateProps, + readonly context: NavigationMenuProviderState + ) { + useRefById(opts); useNavigationMenuProvider({ isRootMenu: false, - value: this.value, + value: this.opts.value, dir: this.context.dir, - orientation: this.orientation, + orientation: this.opts.orientation, rootNavigationMenuRef: this.context.rootNavigationMenuRef, onTriggerEnter: (itemValue) => this.setValue(itemValue), onItemSelect: (itemValue) => this.setValue(itemValue), @@ -291,15 +262,15 @@ class NavigationMenuSubState { } setValue(newValue: string) { - this.value.current = newValue; + this.opts.value.current = newValue; } props = $derived.by( () => ({ - id: this.id.current, - "data-orientation": getDataOrientation(this.orientation.current), - [SUB_ATTR]: "", + id: this.opts.id.current, + "data-orientation": getDataOrientation(this.opts.orientation.current), + [NAVIGATION_MENU_SUB_ATTR]: "", }) as const ); } @@ -307,22 +278,16 @@ class NavigationMenuSubState { type NavigationMenuListStateProps = WithRefProps; class NavigationMenuListState { - id: NavigationMenuListStateProps["id"]; - ref: NavigationMenuListStateProps["ref"]; - context: NavigationMenuProviderState; wrapperId = box.with(() => useId()); wrapperRef = box(null); listTriggers = $state.raw([]); + rovingFocusGroup: ReturnType; - constructor(props: NavigationMenuListStateProps, context: NavigationMenuProviderState) { - this.id = props.id; - this.ref = props.ref; - this.context = context; - - useRefById({ - id: this.id, - ref: this.ref, - }); + constructor( + readonly opts: NavigationMenuListStateProps, + readonly context: NavigationMenuProviderState + ) { + useRefById(opts); useRefById({ id: this.wrapperId, @@ -331,6 +296,14 @@ class NavigationMenuListState { this.context.indicatorTrackRef.current = node; }, }); + + this.rovingFocusGroup = useRovingFocus({ + rootNodeId: opts.id, + candidateAttr: NAVIGATION_MENU_ITEM_ATTR, + candidateSelector: `:is([${NAVIGATION_MENU_TRIGGER_ATTR}], [data-list-link]):not([data-disabled])`, + loop: box.with(() => false), + orientation: this.context.orientation, + }); } registerTrigger(trigger: HTMLElement | null) { @@ -350,9 +323,9 @@ class NavigationMenuListState { props = $derived.by( () => ({ - id: this.id.current, + id: this.opts.id.current, "data-orientation": getDataOrientation(this.context.orientation.current), - [LIST_ATTR]: "", + [NAVIGATION_MENU_LIST_ATTR]: "", }) as const ); } @@ -364,9 +337,6 @@ type NavigationMenuItemStateProps = WithRefProps< >; export class NavigationMenuItemState { - ref: NavigationMenuItemStateProps["ref"]; - id: NavigationMenuItemStateProps["id"]; - value: NavigationMenuItemStateProps["value"]; contentNode = $state(null); triggerNode = $state(null); focusProxyNode = $state(null); @@ -374,17 +344,14 @@ export 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); contentProps: ReadableBox> = box({}); - constructor(props: NavigationMenuItemStateProps, listContext: NavigationMenuListState) { - this.ref = props.ref; - this.id = props.id; - this.value = props.value; - this.listContext = listContext; - } + constructor( + readonly opts: NavigationMenuItemStateProps, + readonly listContext: NavigationMenuListState + ) {} #handleContentEntry = (side: "start" | "end" = "start") => { if (!this.contentNode) return; @@ -407,8 +374,8 @@ export class NavigationMenuItemState { props = $derived.by( () => ({ - id: this.id.current, - [ITEM_ATTR]: "", + id: this.opts.id.current, + [NAVIGATION_MENU_ITEM_ATTR]: "", }) as const ); } @@ -419,38 +386,29 @@ type NavigationMenuTriggerStateProps = WithRefProps & }>; class NavigationMenuTriggerState { - id: NavigationMenuTriggerStateProps["id"]; - ref: NavigationMenuTriggerStateProps["ref"]; focusProxyId = box.with(() => useId()); focusProxyRef = box(null); - disabled: NavigationMenuTriggerStateProps["disabled"]; context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; listContext: NavigationMenuListState; hasPointerMoveOpened = $state(false); wasClickClose = $state(false); - open = $derived.by(() => this.itemContext.value.current === this.context.value.current); + open = $derived.by(() => this.itemContext.opts.value.current === this.context.value.current); focusProxyMounted = $state(false); constructor( - props: NavigationMenuTriggerStateProps, + readonly opts: NavigationMenuTriggerStateProps, context: { provider: NavigationMenuProviderState; item: NavigationMenuItemState; list: NavigationMenuListState; } ) { - this.id = props.id; - this.ref = props.ref; - this.disabled = props.disabled; this.context = context.provider; this.itemContext = context.item; this.listContext = context.list; - useRefById({ - id: this.id, - ref: this.ref, - }); + useRefById(opts); useRefById({ id: this.focusProxyId, @@ -462,9 +420,9 @@ class NavigationMenuTriggerState { }); watch( - () => this.ref.current, + () => this.opts.ref.current, () => { - const node = this.ref.current; + const node = this.opts.ref.current; if (!node) return; return this.listContext.registerTrigger(node); } @@ -478,25 +436,25 @@ class NavigationMenuTriggerState { onpointermove = whenMouse(() => { if ( - this.disabled.current || + this.opts.disabled.current || this.wasClickClose || this.itemContext.wasEscapeClose || this.hasPointerMoveOpened ) { return; } - this.context.onTriggerEnter(this.itemContext.value.current); + this.context.onTriggerEnter(this.itemContext.opts.value.current); this.hasPointerMoveOpened = true; }); onpointerleave = whenMouse(() => { - if (this.disabled.current) return; + if (this.opts.disabled.current) return; this.context.onTriggerLeave(); this.hasPointerMoveOpened = false; }); onclick = (_: BitsMouseEvent) => { - this.context.onItemSelect(this.itemContext.value.current); + this.context.onItemSelect(this.itemContext.opts.value.current); this.wasClickClose = this.open; }; @@ -510,13 +468,16 @@ class NavigationMenuTriggerState { this.itemContext.onEntryKeydown(); // prevent focus group from handling the event e.preventDefault(); + return; } + this.itemContext.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); }; focusProxyOnFocus = (e: BitsFocusEvent) => { const content = this.itemContext.contentNode; const prevFocusedElement = e.relatedTarget as HTMLElement | null; - const wasTriggerFocused = this.ref.current && prevFocusedElement === this.ref.current; + const wasTriggerFocused = + this.opts.ref.current && prevFocusedElement === this.opts.ref.current; const wasFocusFromContent = content?.contains(prevFocusedElement); if (wasTriggerFocused || !wasFocusFromContent) { @@ -527,14 +488,14 @@ class NavigationMenuTriggerState { props = $derived.by( () => ({ - id: this.id.current, - disabled: this.disabled.current, - "data-disabled": getDataDisabled(Boolean(this.disabled.current)), + id: this.opts.id.current, + disabled: this.opts.disabled.current, + "data-disabled": getDataDisabled(Boolean(this.opts.disabled.current)), "data-state": getDataOpenClosed(this.open), - "data-value": this.itemContext.value.current, + "data-value": this.itemContext.opts.value.current, "aria-expanded": getAriaExpanded(this.open), "aria-controls": this.itemContext.contentId, - [TRIGGER_ATTR]: "", + [NAVIGATION_MENU_TRIGGER_ATTR]: "", onpointermove: this.onpointermove, onpointerleave: this.onpointerleave, onpointerenter: this.onpointerenter, @@ -577,27 +538,20 @@ const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDis }); 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, - }); + constructor( + readonly opts: NavigationMenuLinkStateProps, + readonly context: { + provider: NavigationMenuProviderState; + item: NavigationMenuItemState; + } + ) { + useRefById(opts); } onclick = (e: BitsMouseEvent) => { const currTarget = e.currentTarget; - LINK_SELECT_EVENT.listen(currTarget, (e) => this.onSelect.current(e), { once: true }); + LINK_SELECT_EVENT.listen(currTarget, (e) => this.opts.onSelect.current(e), { once: true }); const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget); if (!linkSelectEvent.defaultPrevented && !e.metaKey) { @@ -605,14 +559,18 @@ class NavigationMenuLinkState { } }; + onkeydown = (e: BitsKeyboardEvent) => { + this.context.item.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); + }; + props = $derived.by( () => ({ - id: this.id.current, - "data-active": this.active.current ? "" : undefined, - "aria-current": this.active.current ? "page" : undefined, + id: this.opts.id.current, + "data-active": this.opts.active.current ? "" : undefined, + "aria-current": this.opts.active.current ? "page" : undefined, onclick: this.onclick, - [LINK_ATTR]: "", + [NAVIGATION_MENU_LINK_ATTR]: "", }) as const ); } @@ -629,8 +587,6 @@ class NavigationMenuIndicatorState { } class NavigationMenuIndicatorImplState { - id: NavigationMenuIndicatorStateProps["id"]; - ref: NavigationMenuIndicatorStateProps["ref"]; context: NavigationMenuProviderState; listContext: NavigationMenuListState; position = $state.raw<{ size: number; offset: number } | null>(null); @@ -646,20 +602,17 @@ class NavigationMenuIndicatorImplState { shouldRender = $derived.by(() => this.position !== null); constructor( - props: NavigationMenuIndicatorStateProps, + readonly opts: NavigationMenuIndicatorStateProps, context: { provider: NavigationMenuProviderState; list: NavigationMenuListState; } ) { - this.id = props.id; - this.ref = props.ref; this.context = context.provider; this.listContext = context.list; useRefById({ - id: this.id, - ref: this.ref, + ...opts, deps: () => this.context.value.current, }); @@ -682,7 +635,7 @@ class NavigationMenuIndicatorImplState { props = $derived.by( () => ({ - id: this.id.current, + id: this.opts.id.current, "data-state": this.isVisible ? "visible" : "hidden", "data-orientation": getDataOrientation(this.context.orientation.current), style: this.position @@ -708,32 +661,25 @@ 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); + open = $derived.by(() => this.itemContext.opts.value.current === this.context.value.current); + value = $derived.by(() => this.itemContext.opts.value.current); constructor( - props: NavigationMenuContentStateProps, + readonly opts: 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, - }); + useRefById(opts); } onpointerenter = (_: BitsPointerEvent) => { @@ -747,7 +693,7 @@ class NavigationMenuContentState { props = $derived.by( () => ({ - id: this.id.current, + id: this.opts.id.current, onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave, }) as const @@ -758,10 +704,7 @@ 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; listContext: NavigationMenuListState; prevMotionAttribute = $state(null); @@ -771,8 +714,8 @@ class NavigationMenuContentImplState { 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); + const isSelected = this.itemContext.opts.value.current === this.context.value.current; + const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.value.current); // We only want to update selected and the last selected content // this avoids animations being interrupted outside of that range @@ -796,27 +739,26 @@ class NavigationMenuContentImplState { return attribute; }); - constructor(props: NavigationMenuContentImplStateProps, itemContext: NavigationMenuItemState) { - this.ref = props.ref; - this.id = props.id; - this.itemContext = itemContext; + constructor( + readonly opts: NavigationMenuContentImplStateProps, + readonly itemContext: NavigationMenuItemState + ) { this.listContext = itemContext.listContext; this.context = itemContext.listContext.context; useRefById({ - id: this.id, - ref: this.ref, + ...opts, deps: () => this.context.value.current, }); watch( [ - () => this.itemContext.value.current, + () => this.itemContext.opts.value.current, () => this.itemContext.triggerNode, - () => this.ref.current, + () => this.opts.ref.current, ], () => { - const content = this.ref.current; + const content = this.opts.ref.current; if (!(content && this.context.isRootMenu)) return; const handleClose = () => { @@ -884,7 +826,7 @@ class NavigationMenuContentImplState { props = $derived.by( () => ({ - id: this.id.current, + id: this.opts.id.current, "aria-labelledby": this.itemContext.triggerId, "data-motion": this.motionAttribute ?? undefined, "data-orientation": getDataOrientation(this.context.orientation.current), @@ -899,19 +841,14 @@ type NavigationMenuViewportContentMounterStateProps = ReadableBoxedValues<{ }>; class NavigationMenuViewportContentMounterState { - context: NavigationMenuProviderState; - contentContext: NavigationMenuContentState; - constructor( - props: NavigationMenuViewportContentMounterStateProps, - context: NavigationMenuProviderState, - contentContext: NavigationMenuContentState + opts: NavigationMenuViewportContentMounterStateProps, + readonly context: NavigationMenuProviderState, + readonly contentContext: NavigationMenuContentState ) { - this.context = context; - this.contentContext = contentContext; - this.contentContext.itemContext.contentChildren = props.children; - this.contentContext.itemContext.contentChild = props.child; - this.contentContext.itemContext.contentProps = props.props; + this.contentContext.itemContext.contentChildren = opts.children; + this.contentContext.itemContext.contentChild = opts.child; + this.contentContext.itemContext.contentProps = opts.props; $effect(() => { this.context.onViewportContentChange( @@ -927,20 +864,14 @@ class NavigationMenuViewportContentMounterState { } class NavigationMenuViewportState { - context: NavigationMenuProviderState; open = $derived.by(() => Boolean(this.context.value.current)); - constructor(context: NavigationMenuProviderState) { - this.context = context; - } + constructor(readonly context: NavigationMenuProviderState) {} } 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)); @@ -949,11 +880,10 @@ class NavigationMenuViewportImplState { activeContentValue = $state(); - constructor(props: NavigationMenuViewportImplStateProps, context: NavigationMenuProviderState) { - this.id = props.id; - this.ref = props.ref; - this.context = context; - + constructor( + readonly opts: NavigationMenuViewportImplStateProps, + readonly context: NavigationMenuProviderState + ) { // 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( @@ -975,8 +905,7 @@ class NavigationMenuViewportImplState { ); useRefById({ - id: this.id, - ref: this.ref, + ...opts, onRefChange: (node) => { this.context.viewportRef.current = node; }, @@ -1003,7 +932,7 @@ class NavigationMenuViewportImplState { props = $derived.by( () => ({ - id: this.id.current, + id: this.opts.id.current, "data-state": getDataOpenClosed(this.open), "data-orientation": getDataOrientation(this.context.orientation.current), style: { @@ -1079,7 +1008,10 @@ export function useNavigationMenuContent(props: NavigationMenuContentStateProps) } export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { - return new NavigationMenuLinkState(props, NavigationMenuProviderContext.get()); + return new NavigationMenuLinkState(props, { + provider: NavigationMenuProviderContext.get(), + item: NavigationMenuItemContext.get(), + }); } export function useNavigationMenuContentImpl( From f74a5f721f5516d5acbc99a39ab73c24c8851bbe Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 12:25:35 -0500 Subject: [PATCH 10/30] need to fix focus proxy behavior --- .../navigation-menu-content-impl.svelte | 10 ++- .../components/navigation-menu-trigger.svelte | 2 +- ...ation-menu-viewport-content-mounter.svelte | 6 +- .../navigation-menu-viewport-impl.svelte | 2 +- .../navigation-menu.svelte.ts | 85 ++++++++++++++----- packages/bits-ui/src/lib/internal/tabbable.ts | 2 +- 6 files changed, 82 insertions(+), 25 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 9b3930900..3a61b25fa 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,8 +1,9 @@ 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 8402f75b0..b1318f611 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 @@ -36,7 +36,7 @@ {/if} {#if triggerState.open} - (triggerState.focusProxyMounted = m)} /> + {#if triggerState.context.viewportRef.current} 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 3e4103325..56c638f79 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 @@ -3,7 +3,11 @@ import { box } from "svelte-toolbelt"; import { useNavigationMenuViewportContentMounter } from "../navigation-menu.svelte.js"; - let { children, child, ...restProps }: { children?: Snippet; child?: Snippet } = $props(); + let { + children, + child, + ...restProps + }: { children?: Snippet; child?: Snippet<[{ props: Record }]> } = $props(); useNavigationMenuViewportContentMounter({ children: box.with(() => children), 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 610685248..781b41985 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 @@ -40,7 +40,7 @@ { if (isActive && v) { 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 006fcc0a1..41e964d9f 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 @@ -8,6 +8,7 @@ import { type ReadableBoxedValues, type WithRefProps, type WritableBoxedValues, + afterSleep, afterTick, box, onDestroyEffect, @@ -345,7 +346,8 @@ export class NavigationMenuItemState { contentId = $derived.by(() => this.contentNode?.id); triggerId = $derived.by(() => this.triggerNode?.id); contentChildren: ReadableBox = box(undefined); - contentChild: ReadableBox = box(undefined); + contentChild: ReadableBox }]> | undefined> = + box(undefined); contentProps: ReadableBox> = box({}); constructor( @@ -357,10 +359,11 @@ export class NavigationMenuItemState { if (!this.contentNode) return; this.restoreContentTabOrder(); const candidates = getTabbableCandidates(this.contentNode); + console.log("candidates", candidates); if (candidates.length) focusFirst(side === "start" ? candidates : candidates.reverse()); }; - #handleContextExit = () => { + #handleContentExit = () => { if (!this.contentNode) return; const candidates = getTabbableCandidates(this.contentNode); if (candidates.length) this.restoreContentTabOrder = removeFromTabOrder(candidates); @@ -368,8 +371,8 @@ export class NavigationMenuItemState { onEntryKeydown = this.#handleContentEntry; onFocusProxyEnter = this.#handleContentEntry; - onRootContentClose = this.#handleContextExit; - onContentFocusOutside = this.#handleContextExit; + onRootContentClose = this.#handleContentExit; + onContentFocusOutside = this.#handleContentExit; props = $derived.by( () => @@ -386,7 +389,7 @@ type NavigationMenuTriggerStateProps = WithRefProps & }>; class NavigationMenuTriggerState { - focusProxyId = box.with(() => useId()); + focusProxyId = box(useId()); focusProxyRef = box(null); context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; @@ -507,7 +510,7 @@ class NavigationMenuTriggerState { focusProxyProps = $derived.by( () => ({ - "aria-hidden": "true", + id: this.focusProxyId.current, tabindex: 0, onfocus: this.focusProxyOnFocus, }) as const @@ -679,7 +682,13 @@ class NavigationMenuContentState { this.itemContext = context.item; this.listContext = context.list; - useRefById(opts); + useRefById({ + ...opts, + onRefChange: (node) => { + this.itemContext.contentNode = node; + }, + deps: () => this.context.value.current, + }); } onpointerenter = (_: BitsPointerEvent) => { @@ -751,7 +760,7 @@ class NavigationMenuContentImplState { deps: () => this.context.value.current, }); - watch( + watch.pre( [ () => this.itemContext.opts.value.current, () => this.itemContext.triggerNode, @@ -813,7 +822,7 @@ class NavigationMenuContentImplState { // 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(); + handleProxyFocus(this.itemContext.focusProxyNode); } }; @@ -836,7 +845,7 @@ class NavigationMenuContentImplState { type NavigationMenuViewportContentMounterStateProps = ReadableBoxedValues<{ children: Snippet | undefined; - child: Snippet | undefined; + child: Snippet<[{ props: Record }]> | undefined; props: Record; }>; @@ -950,7 +959,9 @@ const NavigationMenuProviderContext = new Context( "NavigationMenu.Root" ); -const NavigationMenuItemContext = new Context("NavigationMenu.Item"); +export const NavigationMenuItemContext = new Context( + "NavigationMenu.Item" +); const NavigationMenuListContext = new Context("NavigationMenu.List"); @@ -977,9 +988,8 @@ export function useNavigationMenuList(props: NavigationMenuListStateProps) { } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - return NavigationMenuItemContext.set( - new NavigationMenuItemState(props, NavigationMenuListContext.get()) - ); + const listContext = NavigationMenuListContext.get(); + return NavigationMenuItemContext.set(new NavigationMenuItemState(props, listContext)); } export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { @@ -1008,17 +1018,20 @@ export function useNavigationMenuContent(props: NavigationMenuContentStateProps) } export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { + const provider = NavigationMenuProviderContext.get(); + const item = NavigationMenuItemContext.get(); + return new NavigationMenuLinkState(props, { - provider: NavigationMenuProviderContext.get(), - item: NavigationMenuItemContext.get(), + provider, + item, }); } export function useNavigationMenuContentImpl( props: NavigationMenuContentImplStateProps, - itemState: NavigationMenuItemState = NavigationMenuItemContext.get() + itemState?: NavigationMenuItemState ) { - return new NavigationMenuContentImplState(props, itemState); + return new NavigationMenuContentImplState(props, itemState ?? NavigationMenuItemContext.get()); } export function useNavigationMenuViewport() { @@ -1056,14 +1069,18 @@ function focusFirst(candidates: HTMLElement[]) { } function removeFromTabOrder(candidates: HTMLElement[]) { + console.log("removing from tab order", candidates); candidates.forEach((candidate) => { candidate.dataset.tabindex = candidate.getAttribute("tabindex") || ""; candidate.setAttribute("tabindex", "-1"); }); return () => { + console.log("restoring tab order"); candidates.forEach((candidate) => { const prevTabIndex = candidate.dataset.tabindex as string; - candidate.setAttribute("tabindex", prevTabIndex); + if (prevTabIndex) { + candidate.setAttribute("tabindex", prevTabIndex); + } }); }; } @@ -1077,3 +1094,33 @@ function whenMouse( ): BitsPointerEventHandler { return (e) => (e.pointerType === "mouse" ? handler(e) : undefined); } + +/** + * + * We apply the `aria-hidden` attribute to elements that should not be visible to screen readers + * under specific circumstances, mostly when in a "modal" context or when they are strictly for + * utility purposes, like the focus guards. + * + * When these elements receive focus before we can remove the aria-hidden attribute, we need to + * handle the focus in a way that does not cause an error to be logged. + * + * This function handles the focus of the guard element first by momentary removing the + * `aria-hidden` attribute, focusing the guard (which will cause something else to focus), and then + * restoring the attribute. + */ +function handleProxyFocus( + guard: HTMLElement | null, + focusOptions?: Parameters[0] +) { + if (!guard) return; + const ariaHidden = guard.getAttribute("aria-hidden"); + guard.removeAttribute("aria-hidden"); + guard.focus(focusOptions); + afterSleep(0, () => { + if (ariaHidden === null) { + guard.setAttribute("aria-hidden", ""); + } else { + guard.setAttribute("aria-hidden", ariaHidden); + } + }); +} diff --git a/packages/bits-ui/src/lib/internal/tabbable.ts b/packages/bits-ui/src/lib/internal/tabbable.ts index c984a3e7c..6bbc51ee9 100644 --- a/packages/bits-ui/src/lib/internal/tabbable.ts +++ b/packages/bits-ui/src/lib/internal/tabbable.ts @@ -15,7 +15,7 @@ function getTabbableOptions() { } as const; } -function getTabbableIn(container: HTMLElement, direction: "next" | "prev") { +export function getTabbableIn(container: HTMLElement, direction: "next" | "prev") { const allTabbable = tabbable(container, getTabbableOptions()); if (direction === "prev") { From b2d61c93a746b798b80db4cdecc1926a22406987 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 12:41:13 -0500 Subject: [PATCH 11/30] focus is rolling --- .../components/navigation-menu-trigger.svelte | 2 +- .../navigation-menu-viewport-impl.svelte | 1 + .../navigation-menu.svelte.ts | 20 ++++++------------- 3 files changed, 8 insertions(+), 15 deletions(-) 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 b1318f611..5b44090e1 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 @@ -36,8 +36,8 @@ {/if} {#if triggerState.open} - + {#if triggerState.context.viewportRef.current} {/if} 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 781b41985..4d09ee43c 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 @@ -45,6 +45,7 @@ onRefChange={(v) => { if (isActive && v) { viewportState.contentNode = v; + item.contentNode = v; } }} /> 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 41e964d9f..1b79b7822 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 @@ -359,7 +359,6 @@ export class NavigationMenuItemState { if (!this.contentNode) return; this.restoreContentTabOrder(); const candidates = getTabbableCandidates(this.contentNode); - console.log("candidates", candidates); if (candidates.length) focusFirst(side === "start" ? candidates : candidates.reverse()); }; @@ -573,6 +572,7 @@ class NavigationMenuLinkState { "data-active": this.opts.active.current ? "" : undefined, "aria-current": this.opts.active.current ? "page" : undefined, onclick: this.onclick, + onkeydown: this.onkeydown, [NAVIGATION_MENU_LINK_ATTR]: "", }) as const ); @@ -682,13 +682,7 @@ class NavigationMenuContentState { this.itemContext = context.item; this.listContext = context.list; - useRefById({ - ...opts, - onRefChange: (node) => { - this.itemContext.contentNode = node; - }, - deps: () => this.context.value.current, - }); + useRefById(opts); } onpointerenter = (_: BitsPointerEvent) => { @@ -792,7 +786,9 @@ class NavigationMenuContentImplState { // only dismiss content when focus moves outside of the menu if (this.context.rootNavigationMenuRef.current?.contains(target)) { e.preventDefault(); + return; } + this.context.onItemDismiss(); }; onInteractOutside = (e: PointerEvent) => { @@ -839,6 +835,7 @@ class NavigationMenuContentImplState { "aria-labelledby": this.itemContext.triggerId, "data-motion": this.motionAttribute ?? undefined, "data-orientation": getDataOrientation(this.context.orientation.current), + onkeydown: this.onkeydown, }) as const ); } @@ -886,7 +883,6 @@ 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)); - activeContentValue = $state(); constructor( @@ -1069,18 +1065,14 @@ function focusFirst(candidates: HTMLElement[]) { } function removeFromTabOrder(candidates: HTMLElement[]) { - console.log("removing from tab order", candidates); candidates.forEach((candidate) => { candidate.dataset.tabindex = candidate.getAttribute("tabindex") || ""; candidate.setAttribute("tabindex", "-1"); }); return () => { - console.log("restoring tab order"); candidates.forEach((candidate) => { const prevTabIndex = candidate.dataset.tabindex as string; - if (prevTabIndex) { - candidate.setAttribute("tabindex", prevTabIndex); - } + candidate.setAttribute("tabindex", prevTabIndex); }); }; } From 0bf386c1ccbe3400c19e91ba0d1d30b5fa9e389a Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 13:29:27 -0500 Subject: [PATCH 12/30] focus within menus - check! --- .../navigation-menu.svelte.ts | 83 +++++++++++++++---- .../src/lib/internal/use-arrow-navigation.ts | 3 +- 2 files changed, 67 insertions(+), 19 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 1b79b7822..2707a9960 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 @@ -37,12 +37,15 @@ 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"; import { useRovingFocus } from "$lib/internal/use-roving-focus.svelte.js"; +import { useArrowNavigation } from "$lib/internal/use-arrow-navigation.js"; const NAVIGATION_MENU_ROOT_ATTR = "data-navigation-menu-root"; const NAVIGATION_MENU_SUB_ATTR = "data-navigation-menu-sub"; -const NAVIGATION_MENU_LIST_ATTR = "data-navigation-menu-list"; const NAVIGATION_MENU_ITEM_ATTR = "data-navigation-menu-item"; +const NAVIGATION_MENU_INDICATOR_ATTR = "data-navigation-menu-indicator"; +const NAVIGATION_MENU_LIST_ATTR = "data-navigation-menu-list"; const NAVIGATION_MENU_TRIGGER_ATTR = "data-navigation-menu-trigger"; +const NAVIGATION_MENU_CONTENT_ATTR = "data-navigation-menu-content"; const NAVIGATION_MENU_LINK_ATTR = "data-navigation-menu-link"; type NavigationMenuProviderStateProps = ReadableBoxedValues<{ @@ -410,7 +413,12 @@ class NavigationMenuTriggerState { this.itemContext = context.item; this.listContext = context.list; - useRefById(opts); + useRefById({ + ...opts, + onRefChange: (node) => { + this.itemContext.triggerNode = node; + }, + }); useRefById({ id: this.focusProxyId, @@ -540,6 +548,7 @@ const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDis }); class NavigationMenuLinkState { + isFocused = $state(false); constructor( readonly opts: NavigationMenuLinkStateProps, readonly context: { @@ -562,17 +571,29 @@ class NavigationMenuLinkState { }; onkeydown = (e: BitsKeyboardEvent) => { + if (this.context.item.contentNode) return; this.context.item.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); }; + onfocus = (_: BitsFocusEvent) => { + this.isFocused = true; + }; + + onblur = (_: BitsFocusEvent) => { + this.isFocused = false; + }; + props = $derived.by( () => ({ id: this.opts.id.current, "data-active": this.opts.active.current ? "" : undefined, "aria-current": this.opts.active.current ? "page" : undefined, + "data-focused": this.isFocused ? "" : undefined, onclick: this.onclick, onkeydown: this.onkeydown, + onfocus: this.onfocus, + onblur: this.onblur, [NAVIGATION_MENU_LINK_ATTR]: "", }) as const ); @@ -657,6 +678,7 @@ class NavigationMenuIndicatorImplState { }), } : undefined, + [NAVIGATION_MENU_INDICATOR_ATTR]: "", }) as const ); } @@ -802,24 +824,48 @@ class NavigationMenuContentImplState { 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 - handleProxyFocus(this.itemContext.focusProxyNode); + + if (isTabKey) { + const focusedElement = document.activeElement; + const index = candidates.findIndex((candidate) => candidate === focusedElement); + const isMovingBackwards = e.shiftKey; + const nextCandidates = isMovingBackwards + ? candidates.slice(0, index).reverse() + : candidates.slice(index + 1, candidates.length); + if (focusFirst(nextCandidates)) { + // prevent browser tab keydown because we've handled focus + e.preventDefault(); + return; + } else { + // If we can't focus that means we're at the edges + // so focus the proxy and let browser handle + // tab/shift+tab keypress on the proxy instead + handleProxyFocus(this.itemContext.focusProxyNode); + return; + } + } + + let activeEl: HTMLElement = document.activeElement as HTMLElement; + + if (this.itemContext.contentNode) { + const focusedNode = + this.itemContext.contentNode.querySelector("[data-focused]"); + if (focusedNode) { + activeEl = focusedNode; + } } + + if (activeEl === this.itemContext.triggerNode) return; + + const newSelectedElement = useArrowNavigation(e, activeEl, undefined, { + itemsArray: candidates, + attributeName: `[${NAVIGATION_MENU_LINK_ATTR}]`, + loop: false, + enableIgnoredElement: true, + }); + + newSelectedElement?.focus(); }; onEscapeKeydown = (_: KeyboardEvent) => { @@ -836,6 +882,7 @@ class NavigationMenuContentImplState { "data-motion": this.motionAttribute ?? undefined, "data-orientation": getDataOrientation(this.context.orientation.current), onkeydown: this.onkeydown, + [NAVIGATION_MENU_CONTENT_ATTR]: "", }) as const ); } diff --git a/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts b/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts index 1d88751eb..9c6f30e2e 100644 --- a/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts +++ b/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts @@ -80,8 +80,9 @@ export function useArrowNavigation( if ( !currentElement || (options.enableIgnoredElement && ignoredElement.includes(currentElement.nodeName)) - ) + ) { return null; + } const { arrowKeyOptions = "both", From 49f84ab46a16e3f3c44e8b237d787cc374af82dc Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 13:30:16 -0500 Subject: [PATCH 13/30] cleanup --- packages/bits-ui/src/lib/internal/use-arrow-navigation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts b/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts index 9c6f30e2e..b056ee18f 100644 --- a/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts +++ b/packages/bits-ui/src/lib/internal/use-arrow-navigation.ts @@ -63,7 +63,6 @@ interface ArrowNavigationOptions { const ignoredElement = ["INPUT", "TEXTAREA"]; /** - * Allow arrow navigation for every html element with data-radix-vue-collection-item tag * * @param e Keyboard event * @param currentElement Event initiator element or any element that wants to handle the navigation From a0578356c5d392c99f163d95e92d5a69dc0d0ac5 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 14:26:45 -0500 Subject: [PATCH 14/30] some more --- .../navigation-menu-viewport-impl.svelte | 7 +- .../navigation-menu.svelte.ts | 107 ++++++++---------- .../demos/navigation-menu-demo.svelte | 20 ++-- sites/docs/tailwind.config.js | 53 ++++++++- 4 files changed, 112 insertions(+), 75 deletions(-) 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 4d09ee43c..b1949873d 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 @@ -30,12 +30,9 @@
          - {#each viewportContent as [value, item]} + {#each viewportContent as [value, item] (value)} {@const isActive = viewportState.activeContentValue === value} - + {#snippet presence()} ; - dir: NavigationMenuProviderStateProps["dir"]; - orientation: NavigationMenuProviderStateProps["orientation"]; - rootNavigationMenuRef: NavigationMenuProviderStateProps["rootNavigationMenuRef"]; indicatorTrackRef = box(null); viewportRef = box(null); viewportContent = new SvelteMap(); @@ -82,19 +76,14 @@ class NavigationMenuProviderState { onItemSelect: NavigationMenuProviderStateProps["onItemSelect"]; onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"]; - constructor(props: NavigationMenuProviderStateProps) { - this.isRootMenu = props.isRootMenu; - this.value = props.value; - this.previousValue = new PreviousWithInit(() => 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; + constructor(readonly opts: NavigationMenuProviderStateProps) { + this.previousValue = new PreviousWithInit(() => this.opts.value.current); + this.onTriggerEnter = opts.onTriggerEnter; + this.onTriggerLeave = opts.onTriggerLeave ?? noop; + this.onContentEnter = opts.onContentEnter ?? noop; + this.onContentLeave = opts.onContentLeave ?? noop; + this.onItemDismiss = opts.onItemDismiss; + this.onItemSelect = opts.onItemSelect; } onViewportContentChange(contentValue: string, item: NavigationMenuItemState) { @@ -256,9 +245,9 @@ class NavigationMenuSubState { useNavigationMenuProvider({ isRootMenu: false, value: this.opts.value, - dir: this.context.dir, + dir: this.context.opts.dir, orientation: this.opts.orientation, - rootNavigationMenuRef: this.context.rootNavigationMenuRef, + rootNavigationMenuRef: this.context.opts.rootNavigationMenuRef, onTriggerEnter: (itemValue) => this.setValue(itemValue), onItemSelect: (itemValue) => this.setValue(itemValue), onItemDismiss: () => this.setValue(""), @@ -306,7 +295,7 @@ class NavigationMenuListState { candidateAttr: NAVIGATION_MENU_ITEM_ATTR, candidateSelector: `:is([${NAVIGATION_MENU_TRIGGER_ATTR}], [data-list-link]):not([data-disabled])`, loop: box.with(() => false), - orientation: this.context.orientation, + orientation: this.context.opts.orientation, }); } @@ -328,7 +317,7 @@ class NavigationMenuListState { () => ({ id: this.opts.id.current, - "data-orientation": getDataOrientation(this.context.orientation.current), + "data-orientation": getDataOrientation(this.context.opts.orientation.current), [NAVIGATION_MENU_LIST_ATTR]: "", }) as const ); @@ -398,7 +387,9 @@ class NavigationMenuTriggerState { listContext: NavigationMenuListState; hasPointerMoveOpened = $state(false); wasClickClose = $state(false); - open = $derived.by(() => this.itemContext.opts.value.current === this.context.value.current); + open = $derived.by( + () => this.itemContext.opts.value.current === this.context.opts.value.current + ); focusProxyMounted = $state(false); constructor( @@ -470,9 +461,9 @@ class NavigationMenuTriggerState { onkeydown = (e: BitsKeyboardEvent) => { const verticalEntryKey = - this.context.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; + this.context.opts.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[ - this.context.orientation.current + this.context.opts.orientation.current ]; if (this.open && e.key === entryKey) { this.itemContext.onEntryKeydown(); @@ -603,7 +594,7 @@ type NavigationMenuIndicatorStateProps = WithRefProps; class NavigationMenuIndicatorState { context: NavigationMenuProviderState; - isVisible = $derived.by(() => Boolean(this.context.value.current)); + isVisible = $derived.by(() => Boolean(this.context.opts.value.current)); constructor(context: NavigationMenuProviderState) { this.context = context; @@ -614,12 +605,12 @@ class NavigationMenuIndicatorImplState { context: NavigationMenuProviderState; 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)); + isHorizontal = $derived.by(() => this.context.opts.orientation.current === "horizontal"); + isVisible = $derived.by(() => Boolean(this.context.opts.value.current)); activeTrigger = $derived.by(() => { const items = this.listContext.listTriggers; const triggerNode = items.find( - (item) => item.getAttribute("data-value") === this.context.value.current + (item) => item.getAttribute("data-value") === this.context.opts.value.current ); return triggerNode ?? null; }); @@ -637,7 +628,7 @@ class NavigationMenuIndicatorImplState { useRefById({ ...opts, - deps: () => this.context.value.current, + deps: () => this.context.opts.value.current, }); useResizeObserver(() => this.activeTrigger, this.handlePositionChange); @@ -661,7 +652,7 @@ class NavigationMenuIndicatorImplState { ({ id: this.opts.id.current, "data-state": this.isVisible ? "visible" : "hidden", - "data-orientation": getDataOrientation(this.context.orientation.current), + "data-orientation": getDataOrientation(this.context.opts.orientation.current), style: this.position ? { position: "absolute", @@ -689,7 +680,9 @@ class NavigationMenuContentState { context: NavigationMenuProviderState; itemContext: NavigationMenuItemState; listContext: NavigationMenuListState; - open = $derived.by(() => this.itemContext.opts.value.current === this.context.value.current); + open = $derived.by( + () => this.itemContext.opts.value.current === this.context.opts.value.current + ); value = $derived.by(() => this.itemContext.opts.value.current); constructor( @@ -731,20 +724,21 @@ type NavigationMenuContentImplStateProps = WithRefProps; class NavigationMenuContentImplState { context: NavigationMenuProviderState; listContext: NavigationMenuListState; - prevMotionAttribute = $state(null); + prevMotionAttribute = new Previous(() => this.motionAttribute); motionAttribute: MotionAttribute | null = $derived.by(() => { - const items = this.listContext.listTriggers; + const items = untrack(() => 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); + if (this.context.opts.dir.current === "rtl") values.reverse(); + const index = values.indexOf(this.context.opts.value.current); const prevIndex = values.indexOf(this.context.previousValue.current); - const isSelected = this.itemContext.opts.value.current === this.context.value.current; + const isSelected = this.itemContext.opts.value.current === this.context.opts.value.current; const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.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); + if (!isSelected && !wasSelected) + return untrack(() => this.prevMotionAttribute.current ?? null); const attribute = (() => { // Don't provide a direction on the initial open @@ -759,8 +753,6 @@ class NavigationMenuContentImplState { // entirely and should not animate in any direction return null; })(); - - untrack(() => (this.prevMotionAttribute = attribute)); return attribute; }); @@ -773,7 +765,7 @@ class NavigationMenuContentImplState { useRefById({ ...opts, - deps: () => this.context.value.current, + deps: () => this.context.opts.value.current, }); watch.pre( @@ -784,7 +776,7 @@ class NavigationMenuContentImplState { ], () => { const content = this.opts.ref.current; - if (!(content && this.context.isRootMenu)) return; + if (!(content && this.context.opts.isRootMenu)) return; const handleClose = () => { this.context.onItemDismiss(); @@ -806,7 +798,7 @@ class NavigationMenuContentImplState { 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)) { + if (this.context.opts.rootNavigationMenuRef.current?.contains(target)) { e.preventDefault(); return; } @@ -817,8 +809,8 @@ class NavigationMenuContentImplState { 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(); + this.context.opts.isRootMenu && this.context.viewportRef.current?.contains(target); + if (isTrigger || isRootViewport || !this.context.opts.isRootMenu) e.preventDefault(); }; onkeydown = (e: BitsKeyboardEvent) => { @@ -880,7 +872,7 @@ class NavigationMenuContentImplState { id: this.opts.id.current, "aria-labelledby": this.itemContext.triggerId, "data-motion": this.motionAttribute ?? undefined, - "data-orientation": getDataOrientation(this.context.orientation.current), + "data-orientation": getDataOrientation(this.context.opts.orientation.current), onkeydown: this.onkeydown, [NAVIGATION_MENU_CONTENT_ATTR]: "", }) as const @@ -917,7 +909,7 @@ class NavigationMenuViewportContentMounterState { } class NavigationMenuViewportState { - open = $derived.by(() => Boolean(this.context.value.current)); + open = $derived.by(() => Boolean(this.context.opts.value.current)); constructor(readonly context: NavigationMenuProviderState) {} } @@ -929,7 +921,7 @@ class NavigationMenuViewportImplState { 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)); + open = $derived.by(() => Boolean(this.context.opts.value.current)); activeContentValue = $state(); constructor( @@ -938,20 +930,17 @@ class NavigationMenuViewportImplState { ) { // 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( + watch( [ () => this.open, - () => this.context.value.current, + () => this.context.opts.value.current, () => this.context.previousValue.current, ], () => { if (this.open) { - this.activeContentValue = this.context.value.current; + this.activeContentValue = this.context.opts.value.current; } else { - this.activeContentValue = undefined; - afterTick(() => { - this.activeContentValue = this.context.previousValue.current; - }); + this.activeContentValue = this.context.previousValue.current; } } ); @@ -986,9 +975,9 @@ class NavigationMenuViewportImplState { ({ id: this.opts.id.current, "data-state": getDataOpenClosed(this.open), - "data-orientation": getDataOrientation(this.context.orientation.current), + "data-orientation": getDataOrientation(this.context.opts.orientation.current), style: { - pointerEvents: !this.open && this.context.isRootMenu ? "none" : undefined, + pointerEvents: !this.open && this.context.opts.isRootMenu ? "none" : undefined, "--bits-navigation-menu-viewport-width": this.viewportWidth, "--bits-navigation-menu-viewport-height": this.viewportHeight, }, 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 e4495fc24..0be6925b3 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -65,20 +65,20 @@
        • {/snippet} - + - + Getting started
            Components
      -
      +
      + + diff --git a/sites/docs/tailwind.config.js b/sites/docs/tailwind.config.js index 3029af93a..78d60256e 100644 --- a/sites/docs/tailwind.config.js +++ b/sites/docs/tailwind.config.js @@ -1,6 +1,7 @@ import typography from "@tailwindcss/typography"; import animate from "tailwindcss-animate"; import { fontFamily } from "tailwindcss/defaultTheme"; +import plugin from "tailwindcss/plugin"; /** @type {import('tailwindcss').Config} */ export default { @@ -109,12 +110,62 @@ export default { "0%,70%,100%": { opacity: "1" }, "20%,50%": { opacity: "0" }, }, + enterFromRight: { + from: { opacity: "0", transform: "translateX(200px)" }, + to: { opacity: "1", transform: "translateX(0)" }, + }, + enterFromLeft: { + from: { opacity: "0", transform: "translateX(-200px)" }, + to: { opacity: "1", transform: "translateX(0)" }, + }, + exitToRight: { + from: { opacity: "1", transform: "translateX(0)" }, + to: { opacity: "0", transform: "translateX(200px)" }, + }, + exitToLeft: { + from: { opacity: "1", transform: "translateX(0)" }, + to: { opacity: "0", transform: "translateX(-200px)" }, + }, + scaleIn: { + from: { opacity: "0", transform: "rotateX(-10deg) scale(0.9)" }, + to: { opacity: "1", transform: "rotateX(0deg) scale(1)" }, + }, + scaleOut: { + from: { opacity: "1", transform: "rotateX(0deg) scale(1)" }, + to: { opacity: "0", transform: "rotateX(-10deg) scale(0.95)" }, + }, + fadeIn: { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + fadeOut: { + from: { opacity: "1" }, + to: { opacity: "0" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", "caret-blink": "caret-blink 1.25s ease-out infinite", + scaleIn: "scaleIn 200ms ease", + scaleOut: "scaleOut 200ms ease", + fadeIn: "fadeIn 200ms ease", + fadeOut: "fadeOut 200ms ease", + enterFromLeft: "enterFromLeft 250ms ease", + enterFromRight: "enterFromRight 250ms ease", + exitToLeft: "exitToLeft 250ms ease", + exitToRight: "exitToRight 250ms ease", }, }, - plugins: [typography, animate], + plugins: [ + typography, + animate, + plugin(({ matchUtilities }) => { + matchUtilities({ + perspective: (value) => ({ + perspective: value, + }), + }); + }), + ], }; From dcc101bb6fc3e845269cbe01138717e5e87c66a4 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 18:26:59 -0500 Subject: [PATCH 15/30] some more --- .../navigation-menu-viewport-impl.svelte | 2 +- .../navigation-menu.svelte.ts | 42 ++++++++++--------- .../demos/navigation-menu-demo.svelte | 3 ++ 3 files changed, 27 insertions(+), 20 deletions(-) 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 b1949873d..4dd43d65f 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 @@ -29,7 +29,7 @@ }); -
      +
      {#each viewportContent as [value, item] (value)} {@const isActive = viewportState.activeContentValue === value} 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 153e761fd..6ea5bf3ab 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 @@ -10,7 +10,6 @@ import { type WritableBoxedValues, afterSleep, box, - onDestroyEffect, useRefById, } from "svelte-toolbelt"; import { Context, Previous, watch } from "runed"; @@ -118,12 +117,6 @@ class NavigationMenuRootState { constructor(readonly opts: NavigationMenuRootStateProps) { useRefById(opts); - onDestroyEffect(() => { - window.clearTimeout(this.openTimer); - window.clearTimeout(this.closeTimer); - window.clearTimeout(this.skipDelayTimer); - }); - this.provider = useNavigationMenuProvider({ value: this.opts.value, dir: this.opts.dir, @@ -139,6 +132,14 @@ class NavigationMenuRootState { onItemSelect: (itemValue) => this.#onItemSelect(itemValue), onItemDismiss: () => this.#onItemDismiss(), }); + + $effect(() => { + return () => { + window.clearTimeout(this.openTimer); + window.clearTimeout(this.closeTimer); + window.clearTimeout(this.skipDelayTimer); + }; + }); } #onTriggerEnter(itemValue: string) { @@ -420,7 +421,7 @@ class NavigationMenuTriggerState { deps: () => this.focusProxyMounted, }); - watch( + watch.pre( () => this.opts.ref.current, () => { const node = this.opts.ref.current; @@ -626,13 +627,13 @@ class NavigationMenuIndicatorImplState { this.context = context.provider; this.listContext = context.list; + useResizeObserver(() => this.activeTrigger, this.handlePositionChange); + useResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange); + useRefById({ ...opts, deps: () => this.context.opts.value.current, }); - - useResizeObserver(() => this.activeTrigger, this.handlePositionChange); - useResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange); } handlePositionChange = () => { @@ -873,6 +874,9 @@ class NavigationMenuContentImplState { "aria-labelledby": this.itemContext.triggerId, "data-motion": this.motionAttribute ?? undefined, "data-orientation": getDataOrientation(this.context.opts.orientation.current), + "data-state": getDataOpenClosed( + this.context.opts.value.current === this.itemContext.opts.value.current + ), onkeydown: this.onkeydown, [NAVIGATION_MENU_CONTENT_ATTR]: "", }) as const @@ -895,15 +899,15 @@ class NavigationMenuViewportContentMounterState { this.contentContext.itemContext.contentChild = opts.child; this.contentContext.itemContext.contentProps = opts.props; - $effect(() => { - this.context.onViewportContentChange( - this.contentContext.value, - this.contentContext.itemContext - ); - }); + this.context.onViewportContentChange( + this.contentContext.value, + this.contentContext.itemContext + ); - onDestroyEffect(() => { - this.context.onViewportContentRemove(this.contentContext.value); + $effect(() => { + return () => { + this.context.onViewportContentRemove(this.contentContext.value); + }; }); } } 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 0be6925b3..6994ce5b2 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -156,6 +156,9 @@ +
      From 753b630aaf548cbfc98e10d0294ba5a1f4600c01 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 18:50:17 -0500 Subject: [PATCH 16/30] remove unused props --- .../components/navigation-menu-sub.svelte | 9 ++------- .../navigation-menu-viewport-impl.svelte | 2 +- .../components/navigation-menu-viewport.svelte | 4 +--- .../components/navigation-menu.svelte | 9 ++------- .../src/lib/bits/navigation-menu-2/types.ts | 14 -------------- 5 files changed, 6 insertions(+), 32 deletions(-) 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 index 6f9a1b33c..41522afc4 100644 --- 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 @@ -13,7 +13,6 @@ value = $bindable(""), onValueChange = noop, orientation = "horizontal", - controlledValue = false, ...restProps }: NavigationMenuSubProps = $props(); @@ -22,12 +21,8 @@ value: box.with( () => value, (v) => { - if (controlledValue) { - onValueChange(v); - } else { - value = v; - onValueChange(v); - } + value = v; + onValueChange(v); } ), orientation: box.with(() => orientation), 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 4dd43d65f..1f00db730 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 @@ -41,8 +41,8 @@ child={item.contentChild.current} onRefChange={(v) => { if (isActive && v) { - viewportState.contentNode = v; item.contentNode = v; + viewportState.contentNode = v; } }} /> 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 7dd6e0234..3ca3180c0 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 @@ -8,8 +8,6 @@ let { id = useId(), ref = $bindable(null), - children, - child, forceMount = false, ...restProps }: NavigationMenuViewportProps = $props(); @@ -19,6 +17,6 @@ {#snippet presence()} - + {/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 dd3e4d0e6..a4ba7cc4e 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 @@ -16,7 +16,6 @@ skipDelayDuration = 300, dir = "ltr", orientation = "horizontal", - controlledValue = false, ...restProps }: NavigationMenuRootProps = $props(); @@ -26,12 +25,8 @@ () => value, (v) => { rootState.handleValueChange(v); - if (controlledValue) { - onValueChange(v); - } else { - value = v; - onValueChange(v); - } + value = v; + onValueChange(v); } ), delayDuration: box.with(() => delayDuration), 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 87b4c9c13..4696acd25 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 @@ -29,13 +29,6 @@ export type NavigationMenuRootPropsWithoutHTML = 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 duration from when the mouse enters a trigger until the content opens. * @@ -79,13 +72,6 @@ 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. */ From 273ac6989eb5ba7499c4f51291ec741ec613233c Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 19:32:04 -0500 Subject: [PATCH 17/30] before complete destruction --- .../navigation-menu-content-impl.svelte | 11 +- .../components/navigation-menu-content.svelte | 15 ++- .../navigation-menu-viewport-impl.svelte | 2 +- .../navigation-menu.svelte.ts | 108 ++++++++++-------- .../demos/navigation-menu-demo.svelte | 4 +- 5 files changed, 79 insertions(+), 61 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 3a61b25fa..d005ac3fb 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 @@ -15,8 +15,8 @@ let { ref = $bindable(null), id = useId(), - child: _child, - children: _childrenProp, + child: childProp, + children: childrenProp, onInteractOutside = noop, onFocusOutside = noop, onEscapeKeydown = noop, @@ -78,11 +78,12 @@ {escapeKeydownBehavior} > {@const finalProps = mergeProps(mergedProps, dismissibleProps)} - {#if contentImplState.itemContext.contentChild.current} - {@render contentImplState.itemContext.contentChild.current?.({ props: finalProps })} + + {#if childProp} + {@render childProp({ props: finalProps })} {:else}
      - {@render contentImplState.itemContext.contentChildren.current?.()} + {@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 14772a048..30ab94cee 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 @@ -6,11 +6,11 @@ import PresenceLayer from "$lib/bits/utilities/presence-layer/presence-layer.svelte"; import { useId } from "$lib/internal/use-id.js"; import type { NavigationMenuContentProps } from "$lib/types.js"; + import Portal from "$lib/bits/utilities/portal/portal.svelte"; let { ref = $bindable(null), id = useId(), - forceMount = false, children, child, ...restProps @@ -27,7 +27,16 @@ const mergedProps = $derived(mergeProps(restProps, contentState.props)); -{#if !contentState.context.viewportRef.current} +{#if contentState.context.viewportRef.current} + + + +{/if} + + 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 1f00db730..4dd43d65f 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 @@ -41,8 +41,8 @@ child={item.contentChild.current} onRefChange={(v) => { if (isActive && v) { - item.contentNode = v; viewportState.contentNode = v; + item.contentNode = v; } }} /> 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 6ea5bf3ab..640f4e183 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 @@ -12,8 +12,8 @@ import { box, useRefById, } from "svelte-toolbelt"; -import { Context, Previous, watch } from "runed"; -import { type Snippet, untrack } from "svelte"; +import { Context, watch } from "runed"; +import { type Snippet } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; import { @@ -32,7 +32,6 @@ import type { } from "$lib/internal/types.js"; import { kbd } from "$lib/internal/kbd.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"; import { useRovingFocus } from "$lib/internal/use-roving-focus.svelte.js"; import { useArrowNavigation } from "$lib/internal/use-arrow-navigation.js"; @@ -64,7 +63,6 @@ type NavigationMenuProviderStateProps = ReadableBoxedValues<{ }; class NavigationMenuProviderState { - previousValue: PreviousWithInit; indicatorTrackRef = box(null); viewportRef = box(null); viewportContent = new SvelteMap(); @@ -76,7 +74,6 @@ class NavigationMenuProviderState { onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"]; constructor(readonly opts: NavigationMenuProviderStateProps) { - this.previousValue = new PreviousWithInit(() => this.opts.value.current); this.onTriggerEnter = opts.onTriggerEnter; this.onTriggerLeave = opts.onTriggerLeave ?? noop; this.onContentEnter = opts.onContentEnter ?? noop; @@ -421,7 +418,7 @@ class NavigationMenuTriggerState { deps: () => this.focusProxyMounted, }); - watch.pre( + watch( () => this.opts.ref.current, () => { const node = this.opts.ref.current; @@ -725,37 +722,15 @@ type NavigationMenuContentImplStateProps = WithRefProps; class NavigationMenuContentImplState { context: NavigationMenuProviderState; listContext: NavigationMenuListState; - prevMotionAttribute = new Previous(() => this.motionAttribute); - - motionAttribute: MotionAttribute | null = $derived.by(() => { - const items = untrack(() => this.listContext.listTriggers); - const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean); - if (this.context.opts.dir.current === "rtl") values.reverse(); - const index = values.indexOf(this.context.opts.value.current); - const prevIndex = values.indexOf(this.context.previousValue.current); - const isSelected = this.itemContext.opts.value.current === this.context.opts.value.current; - const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.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.current ?? null); - - 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; - })(); - return attribute; - }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + log = (...args: any[]) => { + if (this.itemContext.opts.value.current === "getting-started") { + console.log(...args); + } + }; + + motionAttribute: MotionAttribute | null = $state(null); constructor( readonly opts: NavigationMenuContentImplStateProps, @@ -769,6 +744,45 @@ class NavigationMenuContentImplState { deps: () => this.context.opts.value.current, }); + watch.pre( + [() => this.context.opts.value.current, () => this.listContext.listTriggers], + ([rootValue], [rootPreviousValue]) => { + const items = this.listContext.listTriggers; + const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean); + if (this.context.opts.dir.current === "rtl") values.reverse(); + const index = values.indexOf(rootValue); + const prevIndex = values.indexOf(rootPreviousValue ?? ""); + this.log("index", index, "prevIndex", prevIndex); + const isSelected = this.itemContext.opts.value.current === rootValue; + const wasSelected = + prevIndex === values.indexOf(this.itemContext.opts.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; + } + + const attribute = (() => { + // Don't provide a direction on the initial open + if (index !== prevIndex) { + this.log("index not equal to prev index"); + // 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; + })(); + + this.motionAttribute = attribute; + } + ); + watch.pre( [ () => this.itemContext.opts.value.current, @@ -921,30 +935,24 @@ class NavigationMenuViewportState { type NavigationMenuViewportImplStateProps = WithRefProps; class NavigationMenuViewportImplState { - size = $state<{ width: number; height: number } | null>(null); + size = $state.raw<{ 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.opts.value.current)); - activeContentValue = $state(); + activeContentValue = $state(undefined); constructor( readonly opts: NavigationMenuViewportImplStateProps, readonly context: NavigationMenuProviderState ) { - // 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( - [ - () => this.open, - () => this.context.opts.value.current, - () => this.context.previousValue.current, - ], - () => { - if (this.open) { - this.activeContentValue = this.context.opts.value.current; + () => this.context.opts.value.current, + (value, previousValue) => { + if (value !== "") { + this.activeContentValue = value; } else { - this.activeContentValue = this.context.previousValue.current; + this.activeContentValue = previousValue; } } ); 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 6994ce5b2..da8cafe5d 100644 --- a/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte +++ b/sites/docs/src/lib/components/demos/navigation-menu-demo.svelte @@ -66,7 +66,7 @@ {/snippet} - +
      - - From 47af26bb06e29d7d023faf786d10367f496f457d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sat, 8 Feb 2025 22:00:16 -0500 Subject: [PATCH 23/30] submenu work --- .../src/lib/bits/navigation-menu-2/exports.ts | 1 + .../navigation-menu.svelte.ts | 17 +- .../navigation-menu/navigation-menu.svelte.ts | 12 +- .../docs/src/routes/(main)/sink/+page.svelte | 242 ++++++++++++++++-- 4 files changed, 245 insertions(+), 27 deletions(-) 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 e25251951..3d1a85a0e 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 @@ -6,6 +6,7 @@ export { default as Link } from "./components/navigation-menu-link.svelte"; export { default as List } from "./components/navigation-menu-list.svelte"; export { default as Trigger } from "./components/navigation-menu-trigger.svelte"; export { default as Viewport } from "./components/navigation-menu-viewport.svelte"; +export { default as Sub } from "./components/navigation-menu-sub.svelte"; export type { NavigationMenuRootProps as RootProps, 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 bfd84b229..eba5e23a6 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 @@ -38,8 +38,10 @@ import { useRovingFocus } from "$lib/internal/use-roving-focus.svelte.js"; import { useArrowNavigation } from "$lib/internal/use-arrow-navigation.js"; import { boxAutoReset } from "$lib/internal/box-auto-reset.svelte.js"; import { useResizeObserver } from "$lib/internal/use-resize-observer.svelte.js"; +import { isElement } from "$lib/internal/is.js"; const NAVIGATION_MENU_ROOT_ATTR = "data-navigation-menu-root"; +const NAVIGATION_MENU_ATTR = "data-navigation-menu"; const NAVIGATION_MENU_SUB_ATTR = "data-navigation-menu-sub"; const NAVIGATION_MENU_ITEM_ATTR = "data-navigation-menu-item"; const NAVIGATION_MENU_INDICATOR_ATTR = "data-navigation-menu-indicator"; @@ -191,6 +193,7 @@ class NavigationMenuRootState { "data-orientation": getDataOrientation(this.opts.orientation.current), dir: this.opts.dir.current, [NAVIGATION_MENU_ROOT_ATTR]: "", + [NAVIGATION_MENU_ATTR]: "", }) as const ); } @@ -236,6 +239,7 @@ class NavigationMenuSubState { id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), [NAVIGATION_MENU_SUB_ATTR]: "", + [NAVIGATION_MENU_ATTR]: "", }) as const ); } @@ -805,6 +809,11 @@ class NavigationMenuContentImplState { }; onkeydown = (e: BitsKeyboardEvent) => { + // prevent parent menus handling sub-menu keydown events + const target = e.target; + if (!isElement(target)) return; + if (target.closest(`[${NAVIGATION_MENU_ATTR}]`) !== this.opts.ref.current) return; + const isMetaKey = e.altKey || e.ctrlKey || e.metaKey; const isTabKey = e.key === kbd.TAB && !isMetaKey; const candidates = getTabbableCandidates(e.currentTarget); @@ -977,8 +986,12 @@ export function useNavigationMenuList(props: NavigationMenuListStateProps) { } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - const listContext = NavigationMenuListContext.get(); - return NavigationMenuItemContext.set(new NavigationMenuItemState(props, listContext)); + try { + const listContext = NavigationMenuListContext.get(); + return NavigationMenuItemContext.set(new NavigationMenuItemState(props, listContext)); + } catch { + console.error("Error on Item"); + } } export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts index 371ffb728..71e812ac8 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/navigation-menu/navigation-menu.svelte.ts @@ -1056,10 +1056,14 @@ export function useNavigationMenuList(props: NavigationMenuListStateProps) { } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - const listState = NavigationMenuListContext.get(); - return NavigationMenuItemContext.set( - new NavigationMenuItemState(props, listState, listState.menu) - ); + try { + const listState = NavigationMenuListContext.get(); + return NavigationMenuItemContext.set( + new NavigationMenuItemState(props, listState, listState.menu) + ); + } catch { + console.log("error on item"); + } } export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) { diff --git a/sites/docs/src/routes/(main)/sink/+page.svelte b/sites/docs/src/routes/(main)/sink/+page.svelte index 5e410109b..99fb8483b 100644 --- a/sites/docs/src/routes/(main)/sink/+page.svelte +++ b/sites/docs/src/routes/(main)/sink/+page.svelte @@ -1,29 +1,229 @@ - $inspect("Value cleared", value); +{#snippet LinkGroup({ items }: { items: string[] })} +
        + {#each items as item, index (index)} +
      • + + {item} + +
      • + {/each} +
      +{/snippet} - function clearDays() { - value = { - start: undefined, - end: undefined, - }; - } - +{#snippet ListItem({ className, title, content, href }: ListItemProps)} +
    • + +
      {title}
      +

      + {content} +

      +
      +
    • +{/snippet} -
      - + + + + + Products + + + + + + + Extensibility + + + {@render LinkGroup({ + items: [ + "Fusce pellentesque555", + "Aliquam porttitor333", + "Pellentesque33933", + "Fusce pellentesque666", + "Aliquam porttitor495895", + "Pellentesque585", + ], + })} + + + + + Security + + + {@render LinkGroup({ + items: [ + "Fusce pellentesque5", + "Aliquam porttitor4", + "Pellentesque7", + ], + })} + + + + + +
        +
      • + + +
        Bits UI
        +

        + The headless components for Svelte. +

        +
        +
      • -
        - + {@render ListItem({ + href: "/docs", + title: "Introduction", + content: "Headless components for Svelte and SvelteKit", + })} + {@render ListItem({ + href: "/docs/getting-started", + title: "Getting Started", + content: "How to install and use Bits UI", + })} + {@render ListItem({ + href: "/docs/styling", + title: "Styling", + content: "How to style Bits UI components", + })} +
      +
      +
      + + + Components + + +
        + {#each components as component (component.title)} + {@render ListItem({ + href: component.href, + title: component.title, + content: component.description, + })} + {/each} +
      +
      +
      + + + Documentation + + + +
      +
      +
      +
      +
      -
      + From 6054e73f376217ca8e5aa96da8bc667f280fa6bf Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 9 Feb 2025 11:08:02 -0500 Subject: [PATCH 24/30] update nav menu --- .../navigation-menu.svelte.ts | 71 ++++++++----------- 1 file changed, 28 insertions(+), 43 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 eba5e23a6..909e5976c 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 @@ -87,15 +87,6 @@ class NavigationMenuProviderState { this.onItemDismiss = opts.onItemDismiss; this.onItemSelect = opts.onItemSelect; } - - 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< @@ -138,11 +129,11 @@ class NavigationMenuRootState { onTriggerEnter: (itemValue) => { this.#onTriggerEnter(itemValue); }, - onTriggerLeave: () => this.#onTriggerLeave(), - onContentEnter: () => this.#onContentEnter(), - onContentLeave: () => this.#onContentLeave(), - onItemSelect: (itemValue) => this.#onItemSelect(itemValue), - onItemDismiss: () => this.#onItemDismiss(), + onTriggerLeave: this.#onTriggerLeave, + onContentEnter: this.#onContentEnter, + onContentLeave: this.#onContentLeave, + onItemSelect: this.#onItemSelect, + onItemDismiss: this.#onItemDismiss, }); } @@ -156,35 +147,35 @@ class NavigationMenuRootState { () => this.#derivedDelay ); - #onTriggerEnter(itemValue: string) { + #onTriggerEnter = (itemValue: string) => { this.#debouncedFn(itemValue); - } + }; - #onTriggerLeave() { + #onTriggerLeave = () => { this.isDelaySkipped.current = false; this.#debouncedFn(""); - } + }; - #onContentEnter() { + #onContentEnter = () => { this.#debouncedFn(); - } + }; - #onContentLeave() { + #onContentLeave = () => { this.#debouncedFn(""); - } + }; - #onItemSelect(itemValue: string) { + #onItemSelect = (itemValue: string) => { this.setValue(itemValue); - } + }; - #onItemDismiss() { + #onItemDismiss = () => { this.setValue(""); - } + }; - setValue(newValue: string) { + setValue = (newValue: string) => { this.previousValue.current = this.opts.value.current; this.opts.value.current = newValue; - } + }; props = $derived.by( () => @@ -222,16 +213,16 @@ class NavigationMenuSubState { dir: this.context.opts.dir, orientation: this.opts.orientation, rootNavigationMenuRef: this.context.opts.rootNavigationMenuRef, - onTriggerEnter: (itemValue) => this.setValue(itemValue), - onItemSelect: (itemValue) => this.setValue(itemValue), + onTriggerEnter: this.setValue, + onItemSelect: this.setValue, onItemDismiss: () => this.setValue(""), previousValue: this.previousValue, }); } - setValue(newValue: string) { + setValue = (newValue: string) => { this.opts.value.current = newValue; - } + }; props = $derived.by( () => @@ -986,12 +977,9 @@ export function useNavigationMenuList(props: NavigationMenuListStateProps) { } export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - try { - const listContext = NavigationMenuListContext.get(); - return NavigationMenuItemContext.set(new NavigationMenuItemState(props, listContext)); - } catch { - console.error("Error on Item"); - } + return NavigationMenuItemContext.set( + new NavigationMenuItemState(props, NavigationMenuListContext.get()) + ); } export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { @@ -1020,12 +1008,9 @@ export function useNavigationMenuContent(props: NavigationMenuContentStateProps) } export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { - const provider = NavigationMenuProviderContext.get(); - const item = NavigationMenuItemContext.get(); - return new NavigationMenuLinkState(props, { - provider, - item, + provider: NavigationMenuProviderContext.get(), + item: NavigationMenuItemContext.get(), }); } From 725360a79db988f468c406363994833e772cc01b Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Sun, 9 Feb 2025 11:21:31 -0500 Subject: [PATCH 25/30] remove old nav menu --- packages/bits-ui/src/lib/bits/index.ts | 1 - .../components/navigation-menu-content.svelte | 53 - .../navigation-menu-indicator.svelte | 31 - .../components/navigation-menu-item.svelte | 34 - .../components/navigation-menu-link.svelte | 37 - .../components/navigation-menu-list.svelte | 38 - .../components/navigation-menu-trigger.svelte | 44 - .../navigation-menu-viewport.svelte | 38 - .../components/navigation-menu.svelte | 50 - .../src/lib/bits/navigation-menu-2/exports.ts | 21 - .../src/lib/bits/navigation-menu-2/index.ts | 1 - .../navigation-menu.svelte.ts | 1095 ------------- .../src/lib/bits/navigation-menu-2/types.ts | 204 --- .../navigation-menu-content-impl.svelte | 0 .../components/navigation-menu-content.svelte | 79 +- .../navigation-menu-indicator-impl.svelte | 0 .../navigation-menu-indicator.svelte | 26 +- .../components/navigation-menu-list.svelte | 18 +- .../components/navigation-menu-sub.svelte | 0 .../components/navigation-menu-trigger.svelte | 6 +- .../navigation-menu-viewport.svelte | 6 +- .../src/lib/bits/navigation-menu/exports.ts | 2 + .../navigation-menu/navigation-menu.svelte.ts | 1457 ++++++++--------- .../src/lib/bits/navigation-menu/types.ts | 30 +- packages/bits-ui/src/lib/index.ts | 1 - .../demos/navigation-menu-demo.svelte | 56 +- .../docs/src/routes/(main)/sink/+page.svelte | 94 +- 27 files changed, 860 insertions(+), 2562 deletions(-) delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts delete mode 100644 packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts rename packages/bits-ui/src/lib/bits/{navigation-menu-2 => navigation-menu}/components/navigation-menu-content-impl.svelte (100%) rename packages/bits-ui/src/lib/bits/{navigation-menu-2 => navigation-menu}/components/navigation-menu-indicator-impl.svelte (100%) rename packages/bits-ui/src/lib/bits/{navigation-menu-2 => navigation-menu}/components/navigation-menu-sub.svelte (100%) diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index 55fdb4165..a7fdd7b95 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -19,7 +19,6 @@ 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 deleted file mode 100644 index b49991937..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content.svelte +++ /dev/null @@ -1,53 +0,0 @@ - - -{#if contentState.context.viewportRef.current} - - - {#snippet presence()} - - - {/snippet} - - -{/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 deleted file mode 100644 index f334b8a28..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -{#if indicatorState.context.indicatorTrackRef.current} - - - {#snippet presence()} - - {/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 deleted file mode 100644 index 93eecb3e5..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-item.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - -{#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 deleted file mode 100644 index 6f482aa2b..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-link.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -{#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 deleted file mode 100644 index e13afdea0..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-list.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps, wrapperProps })} - -{: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 deleted file mode 100644 index 5b44090e1..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-trigger.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -{#if child} - {@render child({ props: mergedProps })} -{:else} - -{/if} - -{#if triggerState.open} - - - {#if triggerState.context.viewportRef.current} - - {/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 deleted file mode 100644 index 6fd503df7..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-viewport.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - {#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 deleted file mode 100644 index 092cbd9f9..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -{#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 deleted file mode 100644 index 3d1a85a0e..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/exports.ts +++ /dev/null @@ -1,21 +0,0 @@ -export { default as Root } from "./components/navigation-menu.svelte"; -export { default as Content } from "./components/navigation-menu-content.svelte"; -export { default as Indicator } from "./components/navigation-menu-indicator.svelte"; -export { default as Item } from "./components/navigation-menu-item.svelte"; -export { default as Link } from "./components/navigation-menu-link.svelte"; -export { default as List } from "./components/navigation-menu-list.svelte"; -export { default as Trigger } from "./components/navigation-menu-trigger.svelte"; -export { default as Viewport } from "./components/navigation-menu-viewport.svelte"; -export { default as Sub } from "./components/navigation-menu-sub.svelte"; - -export type { - NavigationMenuRootProps as RootProps, - NavigationMenuItemProps as ItemProps, - NavigationMenuListProps as ListProps, - NavigationMenuTriggerProps as TriggerProps, - NavigationMenuViewportProps as ViewportProps, - NavigationMenuIndicatorProps as IndicatorProps, - NavigationMenuContentProps as ContentProps, - NavigationMenuLinkProps as LinkProps, - NavigationMenuSubProps as SubProps, -} from "./types.js"; 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 deleted file mode 100644 index 960bdca7c..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 909e5976c..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/navigation-menu.svelte.ts +++ /dev/null @@ -1,1095 +0,0 @@ -/** - * Based on Radix UI's Navigation Menu - * https://www.radix-ui.com/docs/primitives/components/navigation-menu - */ -import { - type AnyFn, - type ReadableBox, - type ReadableBoxedValues, - type WithRefProps, - type WritableBox, - type WritableBoxedValues, - afterSleep, - afterTick, - box, - useRefById, -} from "svelte-toolbelt"; -import { Context, useDebounce, watch } from "runed"; -import { untrack, type Snippet } from "svelte"; -import { SvelteMap } from "svelte/reactivity"; -import { type Direction, type Orientation, useId } from "$lib/shared/index.js"; -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 { CustomEventDispatcher } from "$lib/internal/events.js"; -import { useRovingFocus } from "$lib/internal/use-roving-focus.svelte.js"; -import { useArrowNavigation } from "$lib/internal/use-arrow-navigation.js"; -import { boxAutoReset } from "$lib/internal/box-auto-reset.svelte.js"; -import { useResizeObserver } from "$lib/internal/use-resize-observer.svelte.js"; -import { isElement } from "$lib/internal/is.js"; - -const NAVIGATION_MENU_ROOT_ATTR = "data-navigation-menu-root"; -const NAVIGATION_MENU_ATTR = "data-navigation-menu"; -const NAVIGATION_MENU_SUB_ATTR = "data-navigation-menu-sub"; -const NAVIGATION_MENU_ITEM_ATTR = "data-navigation-menu-item"; -const NAVIGATION_MENU_INDICATOR_ATTR = "data-navigation-menu-indicator"; -const NAVIGATION_MENU_LIST_ATTR = "data-navigation-menu-list"; -const NAVIGATION_MENU_TRIGGER_ATTR = "data-navigation-menu-trigger"; -const NAVIGATION_MENU_CONTENT_ATTR = "data-navigation-menu-content"; -const NAVIGATION_MENU_LINK_ATTR = "data-navigation-menu-link"; - -type NavigationMenuProviderStateProps = ReadableBoxedValues<{ - dir: Direction; - orientation: Orientation; -}> & - WritableBoxedValues<{ - rootNavigationMenuRef: HTMLElement | null; - value: string; - previousValue: string; - }> & { - isRootMenu: boolean; - onTriggerEnter: (itemValue: string) => void; - onTriggerLeave?: () => void; - onContentEnter?: () => void; - onContentLeave?: () => void; - onItemSelect: (itemValue: string) => void; - onItemDismiss: () => void; - }; - -class NavigationMenuProviderState { - indicatorTrackRef = box(null); - viewportRef = box(null); - viewportContent = new SvelteMap(); - onTriggerEnter: NavigationMenuProviderStateProps["onTriggerEnter"]; - onTriggerLeave: () => void = noop; - onContentEnter: () => void = noop; - onContentLeave: () => void = noop; - onItemSelect: NavigationMenuProviderStateProps["onItemSelect"]; - onItemDismiss: NavigationMenuProviderStateProps["onItemDismiss"]; - - constructor(readonly opts: NavigationMenuProviderStateProps) { - this.onTriggerEnter = opts.onTriggerEnter; - this.onTriggerLeave = opts.onTriggerLeave ?? noop; - this.onContentEnter = opts.onContentEnter ?? noop; - this.onContentLeave = opts.onContentLeave ?? noop; - this.onItemDismiss = opts.onItemDismiss; - this.onItemSelect = opts.onItemSelect; - } -} - -type NavigationMenuRootStateProps = WithRefProps< - WritableBoxedValues<{ - value: string; - }> & - ReadableBoxedValues<{ - dir: Direction; - orientation: Orientation; - delayDuration: number; - skipDelayDuration: number; - }> ->; - -class NavigationMenuRootState { - provider: NavigationMenuProviderState; - previousValue = box(""); - isDelaySkipped: WritableBox; - #derivedDelay = $derived.by(() => { - const isOpen = this.opts?.value?.current !== ""; - if (isOpen || this.isDelaySkipped.current) { - // 150 for user to switch trigger or move into content view - return 100; - } else { - return this.opts.delayDuration.current; - } - }); - - constructor(readonly opts: NavigationMenuRootStateProps) { - this.isDelaySkipped = boxAutoReset(false, this.opts.skipDelayDuration.current); - useRefById(opts); - - this.provider = useNavigationMenuProvider({ - value: this.opts.value, - previousValue: this.previousValue, - dir: this.opts.dir, - orientation: this.opts.orientation, - rootNavigationMenuRef: this.opts.ref, - isRootMenu: true, - onTriggerEnter: (itemValue) => { - this.#onTriggerEnter(itemValue); - }, - onTriggerLeave: this.#onTriggerLeave, - onContentEnter: this.#onContentEnter, - onContentLeave: this.#onContentLeave, - onItemSelect: this.#onItemSelect, - onItemDismiss: this.#onItemDismiss, - }); - } - - #debouncedFn = useDebounce( - (val?: string) => { - // passing `undefined` meant to reset the debounce timer - if (typeof val === "string") { - this.setValue(val); - } - }, - () => this.#derivedDelay - ); - - #onTriggerEnter = (itemValue: string) => { - this.#debouncedFn(itemValue); - }; - - #onTriggerLeave = () => { - this.isDelaySkipped.current = false; - this.#debouncedFn(""); - }; - - #onContentEnter = () => { - this.#debouncedFn(); - }; - - #onContentLeave = () => { - this.#debouncedFn(""); - }; - - #onItemSelect = (itemValue: string) => { - this.setValue(itemValue); - }; - - #onItemDismiss = () => { - this.setValue(""); - }; - - setValue = (newValue: string) => { - this.previousValue.current = this.opts.value.current; - this.opts.value.current = newValue; - }; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "data-orientation": getDataOrientation(this.opts.orientation.current), - dir: this.opts.dir.current, - [NAVIGATION_MENU_ROOT_ATTR]: "", - [NAVIGATION_MENU_ATTR]: "", - }) as const - ); -} - -type NavigationMenuSubStateProps = WithRefProps< - WritableBoxedValues<{ - value: string; - }> & - ReadableBoxedValues<{ - orientation: Orientation; - }> ->; - -class NavigationMenuSubState { - previousValue = box(""); - - constructor( - readonly opts: NavigationMenuSubStateProps, - readonly context: NavigationMenuProviderState - ) { - useRefById(opts); - - useNavigationMenuProvider({ - isRootMenu: false, - value: this.opts.value, - dir: this.context.opts.dir, - orientation: this.opts.orientation, - rootNavigationMenuRef: this.context.opts.rootNavigationMenuRef, - onTriggerEnter: this.setValue, - onItemSelect: this.setValue, - onItemDismiss: () => this.setValue(""), - previousValue: this.previousValue, - }); - } - - setValue = (newValue: string) => { - this.opts.value.current = newValue; - }; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "data-orientation": getDataOrientation(this.opts.orientation.current), - [NAVIGATION_MENU_SUB_ATTR]: "", - [NAVIGATION_MENU_ATTR]: "", - }) as const - ); -} - -type NavigationMenuListStateProps = WithRefProps; - -class NavigationMenuListState { - wrapperId = box(useId()); - wrapperRef = box(null); - listTriggers = $state.raw([]); - rovingFocusGroup: ReturnType; - wrapperMounted = $state(false); - - constructor( - readonly opts: NavigationMenuListStateProps, - readonly context: NavigationMenuProviderState - ) { - useRefById(opts); - - useRefById({ - id: this.wrapperId, - ref: this.wrapperRef, - onRefChange: (node) => { - this.context.indicatorTrackRef.current = node; - }, - deps: () => this.wrapperMounted, - }); - - this.rovingFocusGroup = useRovingFocus({ - rootNodeId: opts.id, - candidateAttr: NAVIGATION_MENU_ITEM_ATTR, - candidateSelector: `:is([${NAVIGATION_MENU_TRIGGER_ATTR}], [data-list-link]):not([data-disabled])`, - loop: box.with(() => false), - orientation: this.context.opts.orientation, - }); - } - - registerTrigger(trigger: HTMLElement | null) { - if (trigger) this.listTriggers.push(trigger); - return () => { - this.listTriggers = this.listTriggers.filter((t) => t.id !== trigger!.id); - }; - } - - wrapperProps = $derived.by( - () => - ({ - id: this.wrapperId.current, - }) as const - ); - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "data-orientation": getDataOrientation(this.context.opts.orientation.current), - [NAVIGATION_MENU_LIST_ATTR]: "", - }) as const - ); -} - -type NavigationMenuItemStateProps = WithRefProps< - ReadableBoxedValues<{ - value: string; - }> ->; - -export class NavigationMenuItemState { - contentNode = $state(null); - triggerNode = $state(null); - focusProxyNode = $state(null); - restoreContentTabOrder: AnyFn = noop; - wasEscapeClose = false; - contentId = $derived.by(() => this.contentNode?.id); - triggerId = $derived.by(() => this.triggerNode?.id); - contentChildren: ReadableBox = box(undefined); - contentChild: ReadableBox }]> | undefined> = - box(undefined); - contentProps: ReadableBox> = box({}); - - constructor( - readonly opts: NavigationMenuItemStateProps, - readonly listContext: NavigationMenuListState - ) {} - - #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()); - }; - - #handleContentExit = () => { - if (!this.contentNode) return; - const candidates = getTabbableCandidates(this.contentNode); - if (candidates.length) this.restoreContentTabOrder = removeFromTabOrder(candidates); - }; - - onEntryKeydown = this.#handleContentEntry; - onFocusProxyEnter = this.#handleContentEntry; - onRootContentClose = this.#handleContentExit; - onContentFocusOutside = this.#handleContentExit; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - [NAVIGATION_MENU_ITEM_ATTR]: "", - }) as const - ); -} - -type NavigationMenuTriggerStateProps = WithRefProps & - ReadableBoxedValues<{ - disabled: boolean | null | undefined; - }>; - -class NavigationMenuTriggerState { - focusProxyId = box(useId()); - focusProxyRef = box(null); - context: NavigationMenuProviderState; - itemContext: NavigationMenuItemState; - listContext: NavigationMenuListState; - hasPointerMoveOpened = $state(false); - wasClickClose = $state(false); - open = $derived.by( - () => this.itemContext.opts.value.current === this.context.opts.value.current - ); - focusProxyMounted = $state(false); - - constructor( - readonly opts: NavigationMenuTriggerStateProps, - context: { - provider: NavigationMenuProviderState; - item: NavigationMenuItemState; - list: NavigationMenuListState; - } - ) { - this.context = context.provider; - this.itemContext = context.item; - this.listContext = context.list; - - useRefById({ - ...opts, - onRefChange: (node) => { - this.itemContext.triggerNode = node; - }, - }); - - useRefById({ - id: this.focusProxyId, - ref: this.focusProxyRef, - onRefChange: (node) => { - this.itemContext.focusProxyNode = node; - }, - deps: () => this.focusProxyMounted, - }); - - watch( - () => this.opts.ref.current, - () => { - const node = this.opts.ref.current; - if (!node) return; - return this.listContext.registerTrigger(node); - } - ); - } - - onpointerenter = (_: BitsPointerEvent) => { - this.wasClickClose = false; - this.itemContext.wasEscapeClose = false; - }; - - onpointermove = whenMouse(() => { - if ( - this.opts.disabled.current || - this.wasClickClose || - this.itemContext.wasEscapeClose || - this.hasPointerMoveOpened - ) { - return; - } - this.context.onTriggerEnter(this.itemContext.opts.value.current); - this.hasPointerMoveOpened = true; - }); - - onpointerleave = whenMouse(() => { - if (this.opts.disabled.current) return; - this.context.onTriggerLeave(); - this.hasPointerMoveOpened = false; - }); - - onclick = (_: BitsMouseEvent) => { - this.context.onItemSelect(this.itemContext.opts.value.current); - this.wasClickClose = this.open; - }; - - onkeydown = (e: BitsKeyboardEvent) => { - const verticalEntryKey = - this.context.opts.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; - const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[ - this.context.opts.orientation.current - ]; - if (this.open && e.key === entryKey) { - this.itemContext.onEntryKeydown(); - // prevent focus group from handling the event - e.preventDefault(); - return; - } - this.itemContext.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); - }; - - focusProxyOnFocus = (e: BitsFocusEvent) => { - const content = this.itemContext.contentNode; - const prevFocusedElement = e.relatedTarget as HTMLElement | null; - const wasTriggerFocused = - this.opts.ref.current && prevFocusedElement === this.opts.ref.current; - const wasFocusFromContent = content?.contains(prevFocusedElement); - - if (wasTriggerFocused || !wasFocusFromContent) { - this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end"); - } - }; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - disabled: this.opts.disabled.current, - "data-disabled": getDataDisabled(Boolean(this.opts.disabled.current)), - "data-state": getDataOpenClosed(this.open), - "data-value": this.itemContext.opts.value.current, - "aria-expanded": getAriaExpanded(this.open), - "aria-controls": this.itemContext.contentId, - [NAVIGATION_MENU_TRIGGER_ATTR]: "", - onpointermove: this.onpointermove, - onpointerleave: this.onpointerleave, - onpointerenter: this.onpointerenter, - onclick: this.onclick, - onkeydown: this.onkeydown, - }) as const - ); - - focusProxyProps = $derived.by( - () => - ({ - id: this.focusProxyId.current, - tabindex: 0, - onfocus: this.focusProxyOnFocus, - }) as const - ); - - restructureSpanProps = $derived.by( - () => - ({ - "aria-owns": this.itemContext.contentId, - }) as const - ); -} - -type NavigationMenuLinkStateProps = WithRefProps & - ReadableBoxedValues<{ - active: boolean; - onSelect: (e: Event) => void; - }>; - -const LINK_SELECT_EVENT = new CustomEventDispatcher("bitsLinkSelect", { - bubbles: true, - cancelable: true, -}); - -const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDismiss", { - cancelable: true, - bubbles: true, -}); - -class NavigationMenuLinkState { - isFocused = $state(false); - constructor( - readonly opts: NavigationMenuLinkStateProps, - readonly context: { - provider: NavigationMenuProviderState; - item: NavigationMenuItemState; - } - ) { - useRefById(opts); - } - - onclick = (e: BitsMouseEvent) => { - const currTarget = e.currentTarget; - - LINK_SELECT_EVENT.listen(currTarget, (e) => this.opts.onSelect.current(e), { once: true }); - const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget); - - if (!linkSelectEvent.defaultPrevented && !e.metaKey) { - ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget); - } - }; - - onkeydown = (e: BitsKeyboardEvent) => { - if (this.context.item.contentNode) return; - this.context.item.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); - }; - - onfocus = (_: BitsFocusEvent) => { - this.isFocused = true; - }; - - onblur = (_: BitsFocusEvent) => { - this.isFocused = false; - }; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "data-active": this.opts.active.current ? "" : undefined, - "aria-current": this.opts.active.current ? "page" : undefined, - "data-focused": this.isFocused ? "" : undefined, - onclick: this.onclick, - onkeydown: this.onkeydown, - onfocus: this.onfocus, - onblur: this.onblur, - [NAVIGATION_MENU_LINK_ATTR]: "", - }) as const - ); -} - -type NavigationMenuIndicatorStateProps = WithRefProps; - -class NavigationMenuIndicatorState { - context: NavigationMenuProviderState; - isVisible = $derived.by(() => Boolean(this.context.opts.value.current)); - - constructor(context: NavigationMenuProviderState) { - this.context = context; - } -} - -class NavigationMenuIndicatorImplState { - context: NavigationMenuProviderState; - listContext: NavigationMenuListState; - position = $state.raw<{ size: number; offset: number } | null>(null); - isHorizontal = $derived.by(() => this.context.opts.orientation.current === "horizontal"); - isVisible = $derived.by(() => !!this.context.opts.value.current); - activeTrigger = $derived.by(() => { - const items = this.listContext.listTriggers; - const triggerNode = items.find( - (item) => item.getAttribute("data-value") === this.context.opts.value.current - ); - return triggerNode ?? null; - }); - shouldRender = $derived.by(() => this.position !== null); - - constructor( - readonly opts: NavigationMenuIndicatorStateProps, - context: { - provider: NavigationMenuProviderState; - list: NavigationMenuListState; - } - ) { - this.context = context.provider; - this.listContext = context.list; - - useResizeObserver(() => this.activeTrigger, this.handlePositionChange); - useResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange); - - useRefById({ - ...opts, - deps: () => this.context.opts.value.current, - }); - } - - handlePositionChange = () => { - if (!this.activeTrigger) return; - this.position = { - size: this.isHorizontal - ? this.activeTrigger.offsetWidth - : this.activeTrigger.offsetHeight, - offset: this.isHorizontal - ? this.activeTrigger.offsetLeft - : this.activeTrigger.offsetTop, - }; - }; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "data-state": this.isVisible ? "visible" : "hidden", - "data-orientation": getDataOrientation(this.context.opts.orientation.current), - style: { - position: "absolute", - ...(this.isHorizontal - ? { - left: 0, - width: `${this.position?.size}px`, - transform: `translateX(${this.position?.offset}px)`, - } - : { - top: 0, - height: `${this.position?.size}px`, - transform: `translateY(${this.position?.offset}px)`, - }), - }, - [NAVIGATION_MENU_INDICATOR_ATTR]: "", - }) as const - ); -} - -type NavigationMenuContentStateProps = WithRefProps; - -class NavigationMenuContentState { - context: NavigationMenuProviderState; - itemContext: NavigationMenuItemState; - listContext: NavigationMenuListState; - open = $derived.by( - () => this.itemContext.opts.value.current === this.context.opts.value.current - ); - mounted = $state(false); - value = $derived.by(() => this.itemContext.opts.value.current); - // We persist the last active content value as the viewport may be animating out - // and we want the content to remain mounted for the lifecycle of the viewport. - isLastActiveValue = $derived.by(() => { - if (this.context.viewportRef.current) { - if (!this.context.opts.value.current && this.context.opts.previousValue.current) { - return ( - this.context.opts.previousValue.current === this.itemContext.opts.value.current - ); - } - } - return false; - }); - - constructor( - readonly opts: NavigationMenuContentStateProps, - context: { - provider: NavigationMenuProviderState; - item: NavigationMenuItemState; - list: NavigationMenuListState; - } - ) { - this.context = context.provider; - this.itemContext = context.item; - this.listContext = context.list; - - useRefById({ - ...opts, - onRefChange: (node) => { - this.itemContext.contentNode = node; - }, - deps: () => this.mounted, - }); - } - - onpointerenter = (_: BitsPointerEvent) => { - this.context.onContentEnter(); - }; - - onpointerleave = whenMouse(() => { - this.context.onContentLeave(); - }); - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - onpointerenter: this.onpointerenter, - onpointerleave: this.onpointerleave, - }) as const - ); -} - -type MotionAttribute = "to-start" | "to-end" | "from-start" | "from-end"; -type NavigationMenuContentImplStateProps = WithRefProps; - -class NavigationMenuContentImplState { - context: NavigationMenuProviderState; - listContext: NavigationMenuListState; - prevMotionAttribute: MotionAttribute | null = $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.opts.dir.current === "rtl") values.reverse(); - const index = values.indexOf(this.context.opts.value.current); - const prevIndex = values.indexOf(this.context.opts.previousValue.current); - const isSelected = this.itemContext.opts.value.current === this.context.opts.value.current; - const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.value.current); - - // We only want to update selected and the last selected content - // this avoids animations being interrupted outside of that range - if (!isSelected && !wasSelected) return untrack(() => this.prevMotionAttribute); - - const attribute = (() => { - // Don't provide a direction on the initial open - if (index !== prevIndex) { - // If we're moving to this item from another - if (isSelected && prevIndex !== -1) - return index > prevIndex ? "from-end" : "from-start"; - // If we're leaving this item for another - if (wasSelected && index !== -1) return index > prevIndex ? "to-start" : "to-end"; - } - // Otherwise we're entering from closed or leaving the list - // entirely and should not animate in any direction - return null; - })(); - - untrack(() => (this.prevMotionAttribute = attribute)); - return attribute; - }); - - constructor( - readonly opts: NavigationMenuContentImplStateProps, - readonly itemContext: NavigationMenuItemState - ) { - this.listContext = itemContext.listContext; - this.context = itemContext.listContext.context; - - useRefById({ - ...opts, - deps: () => this.context.opts.value.current, - }); - - watch( - [ - () => this.itemContext.opts.value.current, - () => this.itemContext.triggerNode, - () => this.opts.ref.current, - ], - () => { - const content = this.opts.ref.current; - if (!(content && this.context.opts.isRootMenu)) return; - - const handleClose = () => { - this.context.onItemDismiss(); - this.itemContext.onRootContentClose(); - if (content.contains(document.activeElement)) { - this.itemContext.triggerNode?.focus(); - } - }; - const removeListener = ROOT_CONTENT_DISMISS_EVENT.listen(content, handleClose); - - return () => { - removeListener(); - }; - } - ); - } - - 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.opts.rootNavigationMenuRef.current?.contains(target)) { - e.preventDefault(); - return; - } - this.context.onItemDismiss(); - }; - - onInteractOutside = (e: PointerEvent) => { - const target = e.target as HTMLElement; - const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target)); - const isRootViewport = - this.context.opts.isRootMenu && this.context.viewportRef.current?.contains(target); - if (isTrigger || isRootViewport || !this.context.opts.isRootMenu) e.preventDefault(); - }; - - onkeydown = (e: BitsKeyboardEvent) => { - // prevent parent menus handling sub-menu keydown events - const target = e.target; - if (!isElement(target)) return; - if (target.closest(`[${NAVIGATION_MENU_ATTR}]`) !== this.opts.ref.current) return; - - const isMetaKey = e.altKey || e.ctrlKey || e.metaKey; - const isTabKey = e.key === kbd.TAB && !isMetaKey; - const candidates = getTabbableCandidates(e.currentTarget); - - if (isTabKey) { - const focusedElement = document.activeElement; - const index = candidates.findIndex((candidate) => candidate === focusedElement); - const isMovingBackwards = e.shiftKey; - const nextCandidates = isMovingBackwards - ? candidates.slice(0, index).reverse() - : candidates.slice(index + 1, candidates.length); - if (focusFirst(nextCandidates)) { - // prevent browser tab keydown because we've handled focus - e.preventDefault(); - return; - } else { - // If we can't focus that means we're at the edges - // so focus the proxy and let browser handle - // tab/shift+tab keypress on the proxy instead - handleProxyFocus(this.itemContext.focusProxyNode); - return; - } - } - - let activeEl: HTMLElement = document.activeElement as HTMLElement; - - if (this.itemContext.contentNode) { - const focusedNode = - this.itemContext.contentNode.querySelector("[data-focused]"); - if (focusedNode) { - activeEl = focusedNode; - } - } - - if (activeEl === this.itemContext.triggerNode) return; - - const newSelectedElement = useArrowNavigation(e, activeEl, undefined, { - itemsArray: candidates, - attributeName: `[${NAVIGATION_MENU_LINK_ATTR}]`, - loop: false, - enableIgnoredElement: true, - }); - - newSelectedElement?.focus(); - }; - - onEscapeKeydown = (_: KeyboardEvent) => { - this.context.onItemDismiss(); - this.itemContext.triggerNode?.focus(); - // prevent the dropdown from reopening after the escape key has been pressed - this.itemContext.wasEscapeClose = true; - }; - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "aria-labelledby": this.itemContext.triggerId, - "data-motion": this.motionAttribute ?? undefined, - "data-orientation": getDataOrientation(this.context.opts.orientation.current), - "data-state": getDataOpenClosed( - this.context.opts.value.current === this.itemContext.opts.value.current - ), - onkeydown: this.onkeydown, - [NAVIGATION_MENU_CONTENT_ATTR]: "", - }) as const - ); -} - -class NavigationMenuViewportState { - open = $derived.by(() => !!this.context.opts.value.current); - 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)); - activeContentValue = $derived.by(() => this.context.opts.value.current); - - constructor( - readonly opts: NavigationMenuViewportImplStateProps, - readonly context: NavigationMenuProviderState - ) { - useRefById({ - ...opts, - onRefChange: (node) => { - this.context.viewportRef.current = node; - }, - deps: () => this.open, - }); - - watch([() => this.activeContentValue, () => this.open], () => { - afterTick(() => { - const currNode = this.context.viewportRef.current; - if (!currNode) return; - const el = - (currNode.querySelector("[data-state=open]") - ?.children?.[0] as HTMLElement | null) ?? null; - - this.contentNode = el; - }); - }); - - /** - * Update viewport size to match the active content node. - * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform. - * For example, if content animates in from `scale(0.5)` the dimensions would be anything - * from `0.5` to `1` of the intended size. - */ - useResizeObserver( - () => this.contentNode, - () => { - if (this.contentNode) { - this.size = { - width: this.contentNode.offsetWidth, - height: this.contentNode.offsetHeight, - }; - } - } - ); - } - - props = $derived.by( - () => - ({ - id: this.opts.id.current, - "data-state": getDataOpenClosed(this.open), - "data-orientation": getDataOrientation(this.context.opts.orientation.current), - style: { - pointerEvents: !this.open && this.context.opts.isRootMenu ? "none" : undefined, - "--bits-navigation-menu-viewport-width": this.viewportWidth, - "--bits-navigation-menu-viewport-height": this.viewportHeight, - }, - onpointerenter: this.context.onContentEnter, - onpointerleave: this.context.onContentLeave, - }) as const - ); -} - -type NavigationMenuViewportImplStateProps = WithRefProps; - -const NavigationMenuProviderContext = new Context( - "NavigationMenu.Root" -); - -export const NavigationMenuItemContext = new Context( - "NavigationMenu.Item" -); - -const NavigationMenuListContext = new Context("NavigationMenu.List"); - -const NavigationMenuContentContext = new Context( - "NavigationMenu.Content" -); - -export function useNavigationMenuRoot(props: NavigationMenuRootStateProps) { - return new NavigationMenuRootState(props); -} - -export function useNavigationMenuProvider(props: NavigationMenuProviderStateProps) { - return NavigationMenuProviderContext.set(new NavigationMenuProviderState(props)); -} - -export function useNavigationMenuSub(props: NavigationMenuSubStateProps) { - return new NavigationMenuSubState(props, NavigationMenuProviderContext.get()); -} - -export function useNavigationMenuList(props: NavigationMenuListStateProps) { - return NavigationMenuListContext.set( - new NavigationMenuListState(props, NavigationMenuProviderContext.get()) - ); -} - -export function useNavigationMenuItem(props: NavigationMenuItemStateProps) { - return NavigationMenuItemContext.set( - new NavigationMenuItemState(props, NavigationMenuListContext.get()) - ); -} - -export function useNavigationMenuIndicatorImpl(props: NavigationMenuIndicatorStateProps) { - return new NavigationMenuIndicatorImplState(props, { - provider: NavigationMenuProviderContext.get(), - list: NavigationMenuListContext.get(), - }); -} - -export function useNavigationMenuTrigger(props: NavigationMenuTriggerStateProps) { - return new NavigationMenuTriggerState(props, { - provider: NavigationMenuProviderContext.get(), - item: NavigationMenuItemContext.get(), - list: NavigationMenuListContext.get(), - }); -} - -export function useNavigationMenuContent(props: NavigationMenuContentStateProps) { - return NavigationMenuContentContext.set( - new NavigationMenuContentState(props, { - provider: NavigationMenuProviderContext.get(), - item: NavigationMenuItemContext.get(), - list: NavigationMenuListContext.get(), - }) - ); -} - -export function useNavigationMenuLink(props: NavigationMenuLinkStateProps) { - return new NavigationMenuLinkState(props, { - provider: NavigationMenuProviderContext.get(), - item: NavigationMenuItemContext.get(), - }); -} - -export function useNavigationMenuContentImpl( - props: NavigationMenuContentImplStateProps, - itemState?: NavigationMenuItemState -) { - return new NavigationMenuContentImplState(props, itemState ?? NavigationMenuItemContext.get()); -} - -export function useNavigationMenuViewport(props: NavigationMenuViewportImplStateProps) { - return new NavigationMenuViewportState(props, NavigationMenuProviderContext.get()); -} - -export function useNavigationMenuIndicator() { - return new NavigationMenuIndicatorState(NavigationMenuProviderContext.get()); -} - -// - -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); -} - -/** - * - * We apply the `aria-hidden` attribute to elements that should not be visible to screen readers - * under specific circumstances, mostly when in a "modal" context or when they are strictly for - * utility purposes, like the focus guards. - * - * When these elements receive focus before we can remove the aria-hidden attribute, we need to - * handle the focus in a way that does not cause an error to be logged. - * - * This function handles the focus of the guard element first by momentary removing the - * `aria-hidden` attribute, focusing the guard (which will cause something else to focus), and then - * restoring the attribute. - */ -function handleProxyFocus( - guard: HTMLElement | null, - focusOptions?: Parameters[0] -) { - if (!guard) return; - const ariaHidden = guard.getAttribute("aria-hidden"); - guard.removeAttribute("aria-hidden"); - guard.focus(focusOptions); - afterSleep(0, () => { - if (ariaHidden === null) { - guard.setAttribute("aria-hidden", ""); - } else { - guard.setAttribute("aria-hidden", ariaHidden); - } - }); -} 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 deleted file mode 100644 index 4696acd25..000000000 --- a/packages/bits-ui/src/lib/bits/navigation-menu-2/types.ts +++ /dev/null @@ -1,204 +0,0 @@ -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, - 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; - - /** - * 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 = 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; - -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; - - /** - * Behavior when the escape key is pressed while the menu content is open. - */ - escapeKeydownBehavior?: EscapeBehaviorType; - - /** - * Behavior when an interaction occurs outside the content. - */ - interactOutsideBehavior?: InteractOutsideBehaviorType; - - /** - * Whether to forcefully mount the content, regardless of the open state. - * This is useful when wanting to use more custom transition and animation - * libraries. - * - * @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/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content-impl.svelte similarity index 100% rename from packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-content-impl.svelte rename to packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content-impl.svelte diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte index 2de2ab376..b49991937 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-content.svelte @@ -1,82 +1,53 @@ - - +{#if contentState.context.viewportRef.current} + + + {#snippet presence()} + + + {/snippet} + + +{/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/components/navigation-menu-indicator-impl.svelte similarity index 100% rename from packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-indicator-impl.svelte rename to packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator-impl.svelte diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte index 0829e60a3..f334b8a28 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-indicator.svelte @@ -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/components/navigation-menu-list.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte index 4d509970d..e13afdea0 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-list.svelte @@ -3,6 +3,7 @@ import type { NavigationMenuListProps } from "../types.js"; import { useNavigationMenuList } from "../navigation-menu.svelte.js"; import { useId } from "$lib/internal/use-id.js"; + import Mounted from "$lib/bits/utilities/mounted.svelte"; let { id = useId(), @@ -18,19 +19,20 @@ () => ref, (v) => (ref = v) ), - indicatorTrackRef: box(null), }); const mergedProps = $derived(mergeProps(restProps, listState.props)); - const indicatorTrackProps = $derived(mergeProps(listState.indicatorTrackProps, {})); + const wrapperProps = $derived(mergeProps(listState.wrapperProps)); -
      - {#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-sub.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-sub.svelte similarity index 100% rename from packages/bits-ui/src/lib/bits/navigation-menu-2/components/navigation-menu-sub.svelte rename to packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-sub.svelte diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte index 797121362..5b44090e1 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-trigger.svelte @@ -36,9 +36,9 @@ {/if} {#if triggerState.open} + - - {#if triggerState.menu.viewportNode} - + {#if triggerState.context.viewportRef.current} + {/if} {/if} diff --git a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte index 99f6f4d31..6fd503df7 100644 --- a/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte +++ b/packages/bits-ui/src/lib/bits/navigation-menu/components/navigation-menu-viewport.svelte @@ -1,16 +1,16 @@