import type {
ButtonHTMLAttributes,
FocusEvent,
HTMLAttributes,
KeyboardEvent,
ReactElement,
ReactNode,
} from "react";
import {
Children,
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cx } from "../../utils/cx.utils";
import { Icon } from "../icons/icon";
import type { IconName } from "../icons/icons";
import "./navigation.component.css";
/** Orientation of a {@link Navigation} list. */
export type NavigationOrientation = "vertical" | "horizontal";
interface NavigationContextValue {
rootOrientation: NavigationOrientation;
onSelect?: (target: string, index: number) => void;
}
const NavigationContext = createContext({ rootOrientation: "vertical" });
interface NavigationListContextValue {
orientation: NavigationOrientation;
}
const NavigationListContext = createContext({
orientation: "vertical",
});
const KEY_MAP: Record<
NavigationOrientation,
{ next: string; prev: string; open: string; close: string }
> = {
vertical: { next: "ArrowDown", prev: "ArrowUp", open: "ArrowRight", close: "ArrowLeft" },
horizontal: { next: "ArrowRight", prev: "ArrowLeft", open: "ArrowDown", close: "ArrowUp" },
};
const TRIGGER_SELECTOR = ":scope > li > .eink-navigation-item__trigger";
function focusItemAt(list: HTMLUListElement, index: number) {
const items = Array.from(list.querySelectorAll(TRIGGER_SELECTOR));
if (items.length === 0) {
return;
}
const clamped = (index + items.length) % items.length;
items[clamped]?.focus();
}
function indexOfTrigger(list: HTMLUListElement, trigger: Element) {
return Array.from(list.querySelectorAll(TRIGGER_SELECTOR)).indexOf(
trigger as HTMLButtonElement,
);
}
/** Props accepted by {@link Navigation.Item}. */
export interface NavigationItemProps
extends Omit, "onClick" | "type"> {
/** Item label. */
label: ReactNode;
/** Optional icon shown before the label. */
icon?: IconName;
/**
* Destination this item represents, passed to `onSelect` when clicked.
* Not required on items that open a submenu.
*/
target?: string;
/** Nested {@link Navigation.Item} elements. When present, this item opens a submenu instead of calling `onSelect`. */
children?: ReactNode;
}
/** @internal Props injected by {@link NavigationListItems} into each rendered {@link Navigation.Item}. */
interface NavigationItemInternalProps {
__navIndex?: number;
__tabIndex?: 0 | -1;
__onFocus?: () => void;
}
/**
* Single entry of a {@link Navigation} list. Can only be used inside
* {@link Navigation}, which reads its `target` to report clicks. When given
* nested `Navigation.Item` elements as `children`, it opens a submenu
* instead of calling `onSelect`.
*
* @example
* ```tsx
*
*
*
*
* ```
*/
function NavigationItem(props: NavigationItemProps & NavigationItemInternalProps) {
const {
className,
label,
icon,
target,
children,
__navIndex = 0,
__tabIndex = -1,
__onFocus,
...rest
} = props;
const { onSelect, rootOrientation } = useContext(NavigationContext);
const { orientation: listOrientation } = useContext(NavigationListContext);
const isInlineSubmenu = rootOrientation === "vertical";
const [isOpen, setIsOpen] = useState(false);
const itemRef = useRef(null);
const triggerRef = useRef(null);
const submenuRef = useRef(null);
const subItems = Children.toArray(children).filter(isValidElement);
const hasSubmenu = subItems.length > 0;
const keys = KEY_MAP[listOrientation];
const closeSubmenu = (refocus: boolean) => {
setIsOpen(false);
if (refocus) {
triggerRef.current?.focus();
}
};
useEffect(() => {
if (!isOpen) {
return;
}
submenuRef.current?.querySelector(".eink-navigation-item__trigger")?.focus();
const handleOutsideClick = (event: MouseEvent) => {
if (!itemRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isOpen]);
const handleClick = () => {
if (hasSubmenu) {
setIsOpen((current) => !current);
return;
}
if (target !== undefined) {
onSelect?.(target, __navIndex);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (hasSubmenu && !isOpen && event.key === keys.open) {
event.preventDefault();
event.stopPropagation();
setIsOpen(true);
} else if (hasSubmenu && isOpen && (event.key === keys.close || event.key === "Escape")) {
event.preventDefault();
event.stopPropagation();
closeSubmenu(true);
}
};
const handleBlur = (event: FocusEvent) => {
if (isOpen && !itemRef.current?.contains(event.relatedTarget as Node)) {
setIsOpen(false);
}
};
return (
{hasSubmenu && isOpen && (
// biome-ignore lint/a11y/noStaticElementInteractions: positioning wrapper for the submenu's own Escape/close-key handling, not an interactive element itself.
);
}
/** Props accepted by {@link Navigation}. */
export interface NavigationProps
extends Omit, "children" | "onSelect"> {
/** Layout direction of the top-level list. Defaults to `"vertical"`. */
orientation?: NavigationOrientation;
/** Whether the outer container has a border. Defaults to `true`. */
withBorder?: boolean;
/** Whether the outer container and its items have a background color. Defaults to `false`, rendering the navigation transparent. */
withBackground?: boolean;
/** Whether the outer container stretches to fill its parent's width. Defaults to `true`. */
fullWidth?: boolean;
/** {@link Navigation.Item} elements making up the list. */
children: ReactNode;
/** Called with the `target` and index of the {@link Navigation.Item} that was clicked. */
onSelect?: (target: string, index: number) => void;
}
/**
* List of navigation entries, laid out vertically or horizontally.
* {@link Navigation.Item}s that nest further items open a submenu instead of
* navigating: in the vertical orientation, submenus unfold inline below
* their trigger, indented to the right to read as a hierarchy; in the
* horizontal orientation, top-level submenus open as a dropdown flyout.
* Supports roving-tabindex keyboard navigation: arrow keys move between
* siblings, the orientation's perpendicular arrow key opens/closes a
* submenu, and Home/End/Escape behave as expected.
*
* @example
* ```tsx
* router.push(target)}>
*
*
*
*
*
*
*
* ```
*/
export function Navigation({
className,
orientation = "vertical",
withBorder = true,
withBackground = false,
fullWidth = true,
onSelect,
children,
...rest
}: NavigationProps) {
return (
);
}
Navigation.Item = NavigationItem;