INIT
This commit is contained in:
+888
@@ -0,0 +1,888 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user