889 lines
34 KiB
JavaScript
889 lines
34 KiB
JavaScript
/**
|
|
* Based on Radix UI's Navigation Menu
|
|
* https://www.radix-ui.com/docs/primitives/components/navigation-menu
|
|
*/
|
|
import { afterSleep, afterTick, attachRef, DOMContext, getWindow, simpleBox, boxWith, } 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, boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, } 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 = simpleBox(null);
|
|
viewportRef = simpleBox(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 = simpleBox("");
|
|
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": 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 = simpleBox("");
|
|
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": 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 = simpleBox(useId());
|
|
wrapperRef = simpleBox(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: boxWith(() => 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": 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 = simpleBox(undefined);
|
|
contentChild = simpleBox(undefined);
|
|
contentProps = simpleBox({});
|
|
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 = simpleBox(useId());
|
|
focusProxyRef = simpleBox(null);
|
|
focusProxyAttachment = attachRef(this.focusProxyRef, (v) => (this.itemContext.focusProxyNode = v));
|
|
context;
|
|
itemContext;
|
|
listContext;
|
|
hasPointerMoveOpened = simpleBox(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": boolToEmptyStrOrUndef(Boolean(this.opts.disabled.current)),
|
|
"data-state": getDataOpenClosed(this.open),
|
|
"data-value": this.itemContext.opts.value.current,
|
|
"aria-expanded": boolToStr(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": 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": 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": 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);
|
|
}
|
|
});
|
|
}
|