UNPKG

bits-ui

Version:

The headless components for Svelte.

889 lines (888 loc) 35 kB
/** * Based on Radix UI's Navigation Menu * https://www.radix-ui.com/docs/primitives/components/navigation-menu */ import { afterSleep, afterTick, box, attachRef, DOMContext, getWindow, } from "svelte-toolbelt"; import { Context, useDebounce, watch } from "runed"; import { untrack } from "svelte"; import { SvelteMap } from "svelte/reactivity"; import { useId } from "../../shared/index.js"; import { createBitsAttrs, getAriaExpanded, getDataDisabled, getDataOpenClosed, getDataOrientation, } from "../../internal/attrs.js"; import { noop } from "../../internal/noop.js"; import { getTabbableCandidates } from "../../internal/focus.js"; import { kbd } from "../../internal/kbd.js"; import { CustomEventDispatcher } from "../../internal/events.js"; import { useArrowNavigation } from "../../internal/use-arrow-navigation.js"; import { boxAutoReset } from "../../internal/box-auto-reset.svelte.js"; import { isElement } from "../../internal/is.js"; import { RovingFocusGroup } from "../../internal/roving-focus-group.js"; import { SvelteResizeObserver } from "../../internal/svelte-resize-observer.svelte.js"; const navigationMenuAttrs = createBitsAttrs({ component: "navigation-menu", parts: [ "root", "sub", "item", "list", "trigger", "content", "link", "viewport", "menu", "indicator", ], }); const NavigationMenuProviderContext = new Context("NavigationMenu.Root"); export const NavigationMenuItemContext = new Context("NavigationMenu.Item"); const NavigationMenuListContext = new Context("NavigationMenu.List"); const NavigationMenuContentContext = new Context("NavigationMenu.Content"); const NavigationMenuSubContext = new Context("NavigationMenu.Sub"); class NavigationMenuProviderState { static create(opts) { return NavigationMenuProviderContext.set(new NavigationMenuProviderState(opts)); } opts; indicatorTrackRef = box(null); viewportRef = box(null); viewportContent = new SvelteMap(); onTriggerEnter; onTriggerLeave = noop; onContentEnter = noop; onContentLeave = noop; onItemSelect; onItemDismiss; activeItem = null; prevActiveItem = null; constructor(opts) { this.opts = opts; this.onTriggerEnter = opts.onTriggerEnter; this.onTriggerLeave = opts.onTriggerLeave ?? noop; this.onContentEnter = opts.onContentEnter ?? noop; this.onContentLeave = opts.onContentLeave ?? noop; this.onItemDismiss = opts.onItemDismiss; this.onItemSelect = opts.onItemSelect; } setActiveItem = (item) => { this.prevActiveItem = this.activeItem; this.activeItem = item; }; } export class NavigationMenuRootState { static create(opts) { return new NavigationMenuRootState(opts); } opts; attachment; provider; previousValue = box(""); isDelaySkipped; #derivedDelay = $derived.by(() => { const isOpen = this.opts?.value?.current !== ""; if (isOpen || this.isDelaySkipped.current) { // 150 for user to switch trigger or move into content view return 100; } else { return this.opts.delayDuration.current; } }); constructor(opts) { this.opts = opts; this.attachment = attachRef(this.opts.ref); this.isDelaySkipped = boxAutoReset(false, { afterMs: this.opts.skipDelayDuration.current, getWindow: () => getWindow(opts.ref.current), }); this.provider = NavigationMenuProviderState.create({ value: this.opts.value, previousValue: this.previousValue, dir: this.opts.dir, orientation: this.opts.orientation, rootNavigationMenuRef: this.opts.ref, isRootMenu: true, onTriggerEnter: (itemValue, itemState) => { this.#onTriggerEnter(itemValue, itemState); }, onTriggerLeave: this.#onTriggerLeave, onContentEnter: this.#onContentEnter, onContentLeave: this.#onContentLeave, onItemSelect: this.#onItemSelect, onItemDismiss: this.#onItemDismiss, }); } #debouncedFn = useDebounce((val, itemState) => { // passing `undefined` meant to reset the debounce timer if (typeof val === "string") { this.setValue(val, itemState); } }, () => this.#derivedDelay); #onTriggerEnter = (itemValue, itemState) => { this.#debouncedFn(itemValue, itemState); }; #onTriggerLeave = () => { this.isDelaySkipped.current = false; this.#debouncedFn("", null); }; #onContentEnter = () => { this.#debouncedFn(undefined, null); }; #onContentLeave = () => { if (this.provider.activeItem && this.provider.activeItem.opts.openOnHover.current === false) { return; } this.#debouncedFn("", null); }; #onItemSelect = (itemValue, itemState) => { this.setValue(itemValue, itemState); }; #onItemDismiss = () => { this.setValue("", null); }; setValue = (newValue, itemState) => { this.previousValue.current = this.opts.value.current; this.opts.value.current = newValue; this.provider.setActiveItem(itemState); // When all menus are closed, we want to reset previousValue to prevent // weird transitions from old positions when opening fresh if (newValue === "") { this.previousValue.current = ""; } }; props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), dir: this.opts.dir.current, [navigationMenuAttrs.root]: "", [navigationMenuAttrs.menu]: "", ...this.attachment, })); } export class NavigationMenuSubState { static create(opts) { return new NavigationMenuSubState(opts, NavigationMenuProviderContext.get()); } opts; context; previousValue = box(""); subProvider; attachment; constructor(opts, context) { this.opts = opts; this.context = context; this.attachment = attachRef(this.opts.ref); this.subProvider = NavigationMenuProviderState.create({ isRootMenu: false, value: this.opts.value, dir: this.context.opts.dir, orientation: this.opts.orientation, rootNavigationMenuRef: this.opts.ref, onTriggerEnter: this.setValue, onItemSelect: this.setValue, onItemDismiss: () => this.setValue("", null), previousValue: this.previousValue, }); } setValue = (newValue, itemState) => { this.previousValue.current = this.opts.value.current; this.opts.value.current = newValue; this.subProvider.setActiveItem(itemState); // When all menus are closed, we want to reset previousValue to prevent // weird transitions from old positions when opening fresh if (newValue === "") { this.previousValue.current = ""; } }; props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.opts.orientation.current), [navigationMenuAttrs.sub]: "", [navigationMenuAttrs.menu]: "", ...this.attachment, })); } export class NavigationMenuListState { static create(opts) { return NavigationMenuListContext.set(new NavigationMenuListState(opts, NavigationMenuProviderContext.get())); } wrapperId = box(useId()); wrapperRef = box(null); opts; context; attachment; wrapperAttachment = attachRef(this.wrapperRef, (v) => (this.context.indicatorTrackRef.current = v)); listTriggers = $state.raw([]); rovingFocusGroup; wrapperMounted = $state(false); constructor(opts, context) { this.opts = opts; this.context = context; this.attachment = attachRef(this.opts.ref); this.rovingFocusGroup = new RovingFocusGroup({ rootNode: opts.ref, candidateSelector: `${navigationMenuAttrs.selector("trigger")}:not([data-disabled]), ${navigationMenuAttrs.selector("link")}:not([data-disabled])`, loop: box.with(() => false), orientation: this.context.opts.orientation, }); } registerTrigger(trigger) { if (trigger) this.listTriggers.push(trigger); return () => { this.listTriggers = this.listTriggers.filter((t) => t.id !== trigger.id); }; } wrapperProps = $derived.by(() => ({ id: this.wrapperId.current, ...this.wrapperAttachment, })); props = $derived.by(() => ({ id: this.opts.id.current, "data-orientation": getDataOrientation(this.context.opts.orientation.current), [navigationMenuAttrs.list]: "", ...this.attachment, })); } export class NavigationMenuItemState { static create(opts) { return NavigationMenuItemContext.set(new NavigationMenuItemState(opts, NavigationMenuListContext.get())); } opts; attachment; listContext; contentNode = $state(null); triggerNode = $state(null); focusProxyNode = $state(null); restoreContentTabOrder = noop; wasEscapeClose = false; contentId = $derived.by(() => this.contentNode?.id); triggerId = $derived.by(() => this.triggerNode?.id); contentChildren = box(undefined); contentChild = box(undefined); contentProps = box({}); domContext; constructor(opts, listContext) { this.opts = opts; this.listContext = listContext; this.domContext = new DOMContext(opts.ref); this.attachment = attachRef(this.opts.ref); } #handleContentEntry = (side = "start") => { if (!this.contentNode) return; this.restoreContentTabOrder(); const candidates = getTabbableCandidates(this.contentNode); if (candidates.length) focusFirst(side === "start" ? candidates : candidates.reverse(), () => this.domContext.getActiveElement()); }; #handleContentExit = () => { if (!this.contentNode) return; const candidates = getTabbableCandidates(this.contentNode); if (candidates.length) this.restoreContentTabOrder = removeFromTabOrder(candidates); }; onEntryKeydown = this.#handleContentEntry; onFocusProxyEnter = this.#handleContentEntry; onRootContentClose = this.#handleContentExit; onContentFocusOutside = this.#handleContentExit; props = $derived.by(() => ({ id: this.opts.id.current, [navigationMenuAttrs.item]: "", ...this.attachment, })); } export class NavigationMenuTriggerState { static create(opts) { return new NavigationMenuTriggerState(opts, { provider: NavigationMenuProviderContext.get(), item: NavigationMenuItemContext.get(), list: NavigationMenuListContext.get(), sub: NavigationMenuSubContext.getOr(null), }); } opts; attachment; focusProxyId = box(useId()); focusProxyRef = box(null); focusProxyAttachment = attachRef(this.focusProxyRef, (v) => (this.itemContext.focusProxyNode = v)); context; itemContext; listContext; hasPointerMoveOpened = box(false); wasClickClose = false; focusProxyMounted = $state(false); open = $derived.by(() => this.itemContext.opts.value.current === this.context.opts.value.current); constructor(opts, context) { this.opts = opts; this.attachment = attachRef(this.opts.ref, (v) => (this.itemContext.triggerNode = v)); this.hasPointerMoveOpened = boxAutoReset(false, { afterMs: 300, getWindow: () => getWindow(opts.ref.current), }); this.context = context.provider; this.itemContext = context.item; this.listContext = context.list; watch(() => this.opts.ref.current, () => { const node = this.opts.ref.current; if (!node) return; return this.listContext.registerTrigger(node); }); } onpointerenter = (_) => { this.wasClickClose = false; this.itemContext.wasEscapeClose = false; }; onpointermove = whenMouse(() => { if (this.opts.disabled.current || this.wasClickClose || this.itemContext.wasEscapeClose || this.hasPointerMoveOpened.current || !this.itemContext.opts.openOnHover.current) { return; } this.context.onTriggerEnter(this.itemContext.opts.value.current, this.itemContext); this.hasPointerMoveOpened.current = true; }); onpointerleave = whenMouse(() => { if (this.opts.disabled.current || !this.itemContext.opts.openOnHover.current) return; this.context.onTriggerLeave(); this.hasPointerMoveOpened.current = false; }); onclick = () => { // if opened via pointer move, we prevent the click event if (this.hasPointerMoveOpened.current) return; const shouldClose = this.open && (!this.itemContext.opts.openOnHover.current || this.context.opts.isRootMenu); if (shouldClose) { this.context.onItemSelect("", null); } else if (!this.open) { this.context.onItemSelect(this.itemContext.opts.value.current, this.itemContext); } this.wasClickClose = shouldClose; }; onkeydown = (e) => { const verticalEntryKey = this.context.opts.dir.current === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT; const entryKey = { horizontal: kbd.ARROW_DOWN, vertical: verticalEntryKey }[this.context.opts.orientation.current]; if (this.open && e.key === entryKey) { this.itemContext.onEntryKeydown(); // prevent focus group from handling the event e.preventDefault(); return; } this.itemContext.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); }; focusProxyOnFocus = (e) => { const content = this.itemContext.contentNode; const prevFocusedElement = e.relatedTarget; const wasTriggerFocused = this.opts.ref.current && prevFocusedElement === this.opts.ref.current; const wasFocusFromContent = content?.contains(prevFocusedElement); if (wasTriggerFocused || !wasFocusFromContent) { this.itemContext.onFocusProxyEnter(wasTriggerFocused ? "start" : "end"); } }; props = $derived.by(() => ({ id: this.opts.id.current, disabled: this.opts.disabled.current, "data-disabled": 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, [navigationMenuAttrs.trigger]: "", onpointermove: this.onpointermove, onpointerleave: this.onpointerleave, onpointerenter: this.onpointerenter, onclick: this.onclick, onkeydown: this.onkeydown, ...this.attachment, })); focusProxyProps = $derived.by(() => ({ id: this.focusProxyId.current, tabindex: 0, onfocus: this.focusProxyOnFocus, ...this.focusProxyAttachment, })); } const LINK_SELECT_EVENT = new CustomEventDispatcher("bitsLinkSelect", { bubbles: true, cancelable: true, }); const ROOT_CONTENT_DISMISS_EVENT = new CustomEventDispatcher("bitsRootContentDismiss", { cancelable: true, bubbles: true, }); export class NavigationMenuLinkState { static create(opts) { return new NavigationMenuLinkState(opts, { provider: NavigationMenuProviderContext.get(), item: NavigationMenuItemContext.get(), }); } opts; context; attachment; isFocused = $state(false); constructor(opts, context) { this.opts = opts; this.context = context; this.attachment = attachRef(this.opts.ref); } onclick = (e) => { const currTarget = e.currentTarget; LINK_SELECT_EVENT.listen(currTarget, (e) => this.opts.onSelect.current(e), { once: true }); const linkSelectEvent = LINK_SELECT_EVENT.dispatch(currTarget); if (!linkSelectEvent.defaultPrevented && !e.metaKey) { ROOT_CONTENT_DISMISS_EVENT.dispatch(currTarget); } }; onkeydown = (e) => { if (this.context.item.contentNode) return; this.context.item.listContext.rovingFocusGroup.handleKeydown(this.opts.ref.current, e); }; onfocus = (_) => { this.isFocused = true; }; onblur = (_) => { this.isFocused = false; }; #handlePointerDismiss = () => { // only close submenu if this link is not inside the currently open submenu content const currentlyOpenValue = this.context.provider.opts.value.current; const isInsideOpenSubmenu = this.context.item.opts.value.current === currentlyOpenValue; const activeItem = this.context.item.listContext.context.activeItem; if (activeItem && !activeItem.opts.openOnHover.current) return; if (currentlyOpenValue && !isInsideOpenSubmenu) { this.context.provider.onItemDismiss(); } }; onpointerenter = () => { this.#handlePointerDismiss(); }; onpointermove = whenMouse(() => { this.#handlePointerDismiss(); }); props = $derived.by(() => ({ id: this.opts.id.current, "data-active": this.opts.active.current ? "" : undefined, "aria-current": this.opts.active.current ? "page" : undefined, "data-focused": this.isFocused ? "" : undefined, onclick: this.onclick, onkeydown: this.onkeydown, onfocus: this.onfocus, onblur: this.onblur, onpointerenter: this.onpointerenter, onpointermove: this.onpointermove, [navigationMenuAttrs.link]: "", ...this.attachment, })); } export class NavigationMenuIndicatorState { static create() { return new NavigationMenuIndicatorState(NavigationMenuProviderContext.get()); } context; isVisible = $derived.by(() => Boolean(this.context.opts.value.current)); constructor(context) { this.context = context; } } export class NavigationMenuIndicatorImplState { static create(opts) { return new NavigationMenuIndicatorImplState(opts, { provider: NavigationMenuProviderContext.get(), list: NavigationMenuListContext.get(), }); } opts; attachment; context; listContext; position = $state.raw(null); isHorizontal = $derived.by(() => this.context.opts.orientation.current === "horizontal"); isVisible = $derived.by(() => !!this.context.opts.value.current); activeTrigger = $derived.by(() => { const items = this.listContext.listTriggers; const triggerNode = items.find((item) => item.getAttribute("data-value") === this.context.opts.value.current); return triggerNode ?? null; }); shouldRender = $derived.by(() => this.position !== null); constructor(opts, context) { this.opts = opts; this.context = context.provider; this.listContext = context.list; this.attachment = attachRef(this.opts.ref); new SvelteResizeObserver(() => this.activeTrigger, this.handlePositionChange); new SvelteResizeObserver(() => this.context.indicatorTrackRef.current, this.handlePositionChange); } handlePositionChange = () => { if (!this.activeTrigger) return; this.position = { size: this.isHorizontal ? this.activeTrigger.offsetWidth : this.activeTrigger.offsetHeight, offset: this.isHorizontal ? this.activeTrigger.offsetLeft : this.activeTrigger.offsetTop, }; }; props = $derived.by(() => ({ id: this.opts.id.current, "data-state": this.isVisible ? "visible" : "hidden", "data-orientation": 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)`, }), }, [navigationMenuAttrs.indicator]: "", ...this.attachment, })); } export class NavigationMenuContentState { static create(opts) { return NavigationMenuContentContext.set(new NavigationMenuContentState(opts, { provider: NavigationMenuProviderContext.get(), item: NavigationMenuItemContext.get(), list: NavigationMenuListContext.get(), })); } opts; context; itemContext; listContext; attachment; mounted = $state(false); open = $derived.by(() => this.itemContext.opts.value.current === this.context.opts.value.current); value = $derived.by(() => this.itemContext.opts.value.current); // We persist the last active content value as the viewport may be animating out // and we want the content to remain mounted for the lifecycle of the viewport. isLastActiveValue = $derived.by(() => { if (this.context.viewportRef.current) { if (!this.context.opts.value.current && this.context.opts.previousValue.current) { return (this.context.opts.previousValue.current === this.itemContext.opts.value.current); } } return false; }); constructor(opts, context) { this.opts = opts; this.context = context.provider; this.itemContext = context.item; this.listContext = context.list; this.attachment = attachRef(this.opts.ref, (v) => (this.itemContext.contentNode = v)); } onpointerenter = (_) => { this.context.onContentEnter(); }; onpointerleave = whenMouse(() => { if (!this.itemContext.opts.openOnHover.current) return; this.context.onContentLeave(); }); props = $derived.by(() => ({ id: this.opts.id.current, onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave, ...this.attachment, })); } export class NavigationMenuContentImplState { static create(opts, itemState) { return new NavigationMenuContentImplState(opts, itemState ?? NavigationMenuItemContext.get()); } opts; itemContext; context; listContext; attachment; prevMotionAttribute = $state(null); motionAttribute = $derived.by(() => { const items = this.listContext.listTriggers; const values = items.map((item) => item.getAttribute("data-value")).filter(Boolean); if (this.context.opts.dir.current === "rtl") values.reverse(); const index = values.indexOf(this.context.opts.value.current); const prevIndex = values.indexOf(this.context.opts.previousValue.current); const isSelected = this.itemContext.opts.value.current === this.context.opts.value.current; const wasSelected = prevIndex === values.indexOf(this.itemContext.opts.value.current); // When all menus are closed, we want to reset motion state to prevent residual animations if (!this.context.opts.value.current && !this.context.opts.previousValue.current) { untrack(() => (this.prevMotionAttribute = null)); return null; } // We only want to update selected and the last selected content // this avoids animations being interrupted outside of that range if (!isSelected && !wasSelected) return untrack(() => this.prevMotionAttribute); const attribute = (() => { // Don't provide a direction on the initial open if (index !== prevIndex) { // If we're moving to this item from another if (isSelected && prevIndex !== -1) return index > prevIndex ? "from-end" : "from-start"; // If we're leaving this item for another if (wasSelected && index !== -1) return index > prevIndex ? "to-start" : "to-end"; } // Otherwise we're entering from closed or leaving the list // entirely and should not animate in any direction return null; })(); untrack(() => (this.prevMotionAttribute = attribute)); return attribute; }); domContext; constructor(opts, itemContext) { this.opts = opts; this.attachment = attachRef(this.opts.ref); this.itemContext = itemContext; this.listContext = itemContext.listContext; this.context = itemContext.listContext.context; this.domContext = new DOMContext(opts.ref); watch([ () => this.itemContext.opts.value.current, () => this.itemContext.triggerNode, () => this.opts.ref.current, ], () => { const content = this.opts.ref.current; if (!(content && this.context.opts.isRootMenu)) return; const handleClose = () => { this.context.onItemDismiss(); this.itemContext.onRootContentClose(); if (content.contains(this.domContext.getActiveElement())) { this.itemContext.triggerNode?.focus(); } }; const removeListener = ROOT_CONTENT_DISMISS_EVENT.listen(content, handleClose); return () => { removeListener(); }; }); } onFocusOutside = (e) => { this.itemContext.onContentFocusOutside(); const target = e.target; // only dismiss content when focus moves outside of the menu if (this.context.opts.rootNavigationMenuRef.current?.contains(target)) { e.preventDefault(); return; } this.context.onItemDismiss(); }; onInteractOutside = (e) => { const target = e.target; const isTrigger = this.listContext.listTriggers.some((trigger) => trigger.contains(target)); const isRootViewport = this.context.opts.isRootMenu && this.context.viewportRef.current?.contains(target); if (!this.context.opts.isRootMenu && !isTrigger) { this.context.onItemDismiss(); return; } if (isTrigger || isRootViewport) { e.preventDefault(); return; } if (!this.itemContext.opts.openOnHover.current) { this.context.onItemSelect("", null); } }; onkeydown = (e) => { // prevent parent menus handling sub-menu keydown events const target = e.target; if (!isElement(target)) return; if (target.closest(navigationMenuAttrs.selector("menu")) !== this.context.opts.rootNavigationMenuRef.current) return; const isMetaKey = e.altKey || e.ctrlKey || e.metaKey; const isTabKey = e.key === kbd.TAB && !isMetaKey; const candidates = getTabbableCandidates(e.currentTarget); if (isTabKey) { const focusedElement = this.domContext.getActiveElement(); const index = candidates.findIndex((candidate) => candidate === focusedElement); const isMovingBackwards = e.shiftKey; const nextCandidates = isMovingBackwards ? candidates.slice(0, index).reverse() : candidates.slice(index + 1, candidates.length); if (focusFirst(nextCandidates, () => this.domContext.getActiveElement())) { // prevent browser tab keydown because we've handled focus e.preventDefault(); return; } else { // If we can't focus that means we're at the edges // so focus the proxy and let browser handle // tab/shift+tab keypress on the proxy instead handleProxyFocus(this.itemContext.focusProxyNode); return; } } let activeEl = this.domContext.getActiveElement(); if (this.itemContext.contentNode) { const focusedNode = this.itemContext.contentNode.querySelector("[data-focused]"); if (focusedNode) { activeEl = focusedNode; } } if (activeEl === this.itemContext.triggerNode) return; const newSelectedElement = useArrowNavigation(e, activeEl, undefined, { itemsArray: candidates, candidateSelector: navigationMenuAttrs.selector("link"), loop: false, enableIgnoredElement: true, }); newSelectedElement?.focus(); }; onEscapeKeydown = (_) => { this.context.onItemDismiss(); this.itemContext.triggerNode?.focus(); // prevent the dropdown from reopening after the escape key has been pressed this.itemContext.wasEscapeClose = true; }; props = $derived.by(() => ({ id: this.opts.id.current, "aria-labelledby": this.itemContext.triggerId, "data-motion": this.motionAttribute ?? undefined, "data-orientation": getDataOrientation(this.context.opts.orientation.current), "data-state": getDataOpenClosed(this.context.opts.value.current === this.itemContext.opts.value.current), onkeydown: this.onkeydown, [navigationMenuAttrs.content]: "", ...this.attachment, })); } export class NavigationMenuViewportState { static create(opts) { return new NavigationMenuViewportState(opts, NavigationMenuProviderContext.get()); } opts; context; attachment; open = $derived.by(() => !!this.context.opts.value.current); viewportWidth = $derived.by(() => (this.size ? `${this.size.width}px` : undefined)); viewportHeight = $derived.by(() => (this.size ? `${this.size.height}px` : undefined)); activeContentValue = $derived.by(() => this.context.opts.value.current); size = $state(null); contentNode = $state(null); mounted = $state(false); constructor(opts, context) { this.opts = opts; this.context = context; this.attachment = attachRef(this.opts.ref, (v) => (this.context.viewportRef.current = v)); watch([() => this.activeContentValue, () => this.open], () => { afterTick(() => { const currNode = this.context.viewportRef.current; if (!currNode) return; const el = currNode.querySelector("[data-state=open]") ?.children?.[0] ?? null; this.contentNode = el; }); }); /** * Update viewport size to match the active content node. * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform. * For example, if content animates in from `scale(0.5)` the dimensions would be anything * from `0.5` to `1` of the intended size. */ new SvelteResizeObserver(() => this.contentNode, () => { if (this.contentNode) { this.size = { width: this.contentNode.offsetWidth, height: this.contentNode.offsetHeight, }; } }); // reset size when viewport closes to prevent residual size animations watch(() => this.mounted, () => { if (!this.mounted && this.size) { this.size = null; } }); } props = $derived.by(() => ({ id: this.opts.id.current, "data-state": getDataOpenClosed(this.open), "data-orientation": 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, }, [navigationMenuAttrs.viewport]: "", onpointerenter: this.context.onContentEnter, onpointerleave: this.context.onContentLeave, ...this.attachment, })); } // function focusFirst(candidates, getActiveElement) { const previouslyFocusedElement = getActiveElement(); return candidates.some((candidate) => { // if focus is already where we want to go, we don't want to keep going through the candidates if (candidate === previouslyFocusedElement) return true; candidate.focus(); return getActiveElement() !== previouslyFocusedElement; }); } function removeFromTabOrder(candidates) { candidates.forEach((candidate) => { candidate.dataset.tabindex = candidate.getAttribute("tabindex") || ""; candidate.setAttribute("tabindex", "-1"); }); return () => { candidates.forEach((candidate) => { const prevTabIndex = candidate.dataset.tabindex; candidate.setAttribute("tabindex", prevTabIndex); }); }; } function whenMouse(handler) { return (e) => (e.pointerType === "mouse" ? handler(e) : undefined); } /** * * We apply the `aria-hidden` attribute to elements that should not be visible to screen readers * under specific circumstances, mostly when in a "modal" context or when they are strictly for * utility purposes, like the focus guards. * * When these elements receive focus before we can remove the aria-hidden attribute, we need to * handle the focus in a way that does not cause an error to be logged. * * This function handles the focus of the guard element first by momentary removing the * `aria-hidden` attribute, focusing the guard (which will cause something else to focus), and then * restoring the attribute. */ function handleProxyFocus(guard, focusOptions) { if (!guard) return; const ariaHidden = guard.getAttribute("aria-hidden"); guard.removeAttribute("aria-hidden"); guard.focus(focusOptions); afterSleep(0, () => { if (ariaHidden === null) { guard.setAttribute("aria-hidden", ""); } else { guard.setAttribute("aria-hidden", ariaHidden); } }); }