@datalayer/core
Version:
[](https://datalayer.io)
281 lines (280 loc) • 15.8 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
/*
* Copyright (c) 2023-2025 Datalayer, Inc.
* Distributed under the terms of the Modified BSD License.
*/
import React, { Children, createContext, forwardRef, isValidElement, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
import { createPortal } from 'react-dom';
import { Button, ButtonGroup, Text, ThemeProvider, useWindowSize, } from '@primer/react-brand';
import { ChevronDownIcon, ChevronUpIcon } from '@primer/octicons-react';
import { default as clsx } from 'clsx';
import { useId } from '../../hooks';
import { useKeyboardEscape, useOnClickOutside, useProvidedRefOrCreate, useContainsFocus, } from '../../hooks';
/**
* Design tokens
*/
import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/components/sub-nav/base.css';
import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/components/sub-nav/colors-with-modes.css';
/** * Main Stylesheet (as a CSS Module) */
import styles from './SubNav.module.css';
const testIds = {
root: 'SubNav-root',
get button() {
return `${this.root}-button`;
},
get overlay() {
return `${this.root}-overlay`;
},
get link() {
return `${this.root}-link`;
},
get heading() {
return `${this.root}-heading`;
},
get action() {
return `${this.root}-action`;
},
get subMenu() {
return `${this.root}-sub-menu`;
},
};
export const SubNavSubMenuVariants = ['dropdown', 'anchor'];
const SubNavContext = createContext(undefined);
export const useSubNavContext = () => {
const context = useContext(SubNavContext);
if (!context) {
throw new Error('useSubNavContext must be used within a SubNavProvider');
}
return context;
};
function SubNavProvider({ children }) {
const anchoredNavOuterPortalRef = React.useRef(null);
const anchoredNavPortalRef = React.useRef(null);
const value = useMemo(() => ({
portalRef: anchoredNavPortalRef,
}), []);
useEffect(() => {
const menuContainer = anchoredNavOuterPortalRef.current;
const observer = new IntersectionObserver(([entry]) => {
entry.target.classList.toggle(styles['SubNav__anchor-menu-outer-container--stuck'], entry.intersectionRatio < 1);
}, { threshold: [1] });
if (menuContainer) {
observer.observe(menuContainer);
}
return () => {
if (menuContainer) {
observer.unobserve(menuContainer);
}
};
}, []);
return (_jsxs(SubNavContext.Provider, { value: value, children: [children, _jsx("div", { className: styles['SubNav__anchor-menu-outer-container'], ref: anchoredNavOuterPortalRef, children: _jsx("div", { className: clsx(styles['SubNav__anchor-menu-container']), ref: anchoredNavPortalRef }) })] }));
}
const _SubNavRoot = memo(({ id, children, className, 'data-testid': testId, fullWidth, hasShadow, }) => {
const rootRef = React.useRef(null);
const navRef = React.useRef(null);
const overlayRef = React.useRef(null);
const [isOpenAtNarrow, setIsOpenAtNarrow] = useState(false);
const idForLinkContainer = useId();
const [hasAnchoredNav, setHasAnchoredNav] = useState(false);
const { isLarge } = useWindowSize();
const childrenArr = Children.toArray(children);
const closeMenuCallback = useCallback(() => {
if (isLarge)
return;
setIsOpenAtNarrow(false);
}, [isLarge]);
const handleMenuToggle = useCallback(() => {
if (isLarge)
return;
setIsOpenAtNarrow(prev => !prev);
}, [isLarge]);
useOnClickOutside(rootRef, closeMenuCallback);
useKeyboardEscape(closeMenuCallback);
useEffect(() => {
if (isOpenAtNarrow && !isLarge) {
document.body.style.overflow = 'hidden';
}
else {
document.body.style.overflow = 'auto';
}
}, [isOpenAtNarrow, isLarge]);
const activeLink = childrenArr.find(child => {
if (isValidElement(child)) {
return child.props['aria-current'];
}
});
useEffect(() => {
// check if there is an anchored nav in the SubNav.SubMenu child
const hasAnchorVariant = childrenArr.some(child => {
if (isValidElement(child) && child.type === SubNavLink) {
const [, subMenu] = child.props.children;
if (subMenu?.props?.variant === 'anchor') {
return true;
}
}
});
setHasAnchoredNav(hasAnchorVariant);
}, [childrenArr]);
const { heading: HeadingChild, links: LinkChildren, action: ActionChild, } = childrenArr.reduce((acc, child) => {
if (isValidElement(child)) {
if (child.type === SubNavHeading) {
acc.heading = child;
}
else if (child.type === SubNavLink) {
const [link, subMenu] = child.props.children;
if (subMenu?.props?.variant === 'anchor') {
acc.links.push(React.cloneElement(child, {
children: [link],
onClick: child.props['aria-current']
? closeMenuCallback
: child.props.onClick,
}));
}
else {
acc.links.push(React.cloneElement(child, {
onClick: child.props['aria-current']
? closeMenuCallback
: child.props.onClick,
}));
}
}
else if (child.type === _SubNavAction) {
acc.action = child;
}
}
return acc;
}, { heading: undefined, links: [], action: undefined });
const activeLinklabel = typeof activeLink?.props.children === 'string'
? activeLink.props.children
: activeLink?.props.children[0];
// needed to prevent rendering of anchor subnav inside the narrow <Button variant="invisible"> element
const MaybeSubNav = activeLink?.props.children?.[1]?.props?.variant === 'anchor' &&
activeLink.props.children?.[1];
return (_jsx("div", { className: clsx(styles['SubNav__container'], hasAnchoredNav && styles['SubNav__container--with-anchor-nav']), children: _jsx(SubNavProvider, { children: _jsx("nav", { ref: navRef, id: id, className: clsx(styles.SubNav, isOpenAtNarrow && styles['SubNav--open'], hasShadow && styles['SubNav--has-shadow'], fullWidth && styles['SubNav--full-width'], className), "data-testid": testId || testIds.root, children: _jsxs("div", { ref: rootRef, className: styles['SubNav--header-container-outer'], children: [_jsxs("div", { className: styles['SubNav__header-container'], children: [HeadingChild && (_jsx("div", { className: styles['SubNav__heading-container'], children: HeadingChild })), !isLarge && (_jsxs("button", { className: clsx(styles['SubNav__overlay-toggle'], isOpenAtNarrow && styles['SubNav__overlay-toggle--open']), "data-testid": testIds.button, onClick: isOpenAtNarrow ? closeMenuCallback : handleMenuToggle, "aria-expanded": isOpenAtNarrow ? 'true' : 'false', "aria-controls": idForLinkContainer, children: [activeLinklabel && (_jsxs("span", { className: "visually-hidden", children: ["Navigation menu. Current page:", ' '] })), _jsxs("span", { className: clsx(styles['SubNav__overlay-toggle-content'], !activeLinklabel &&
styles['SubNav__overlay-toggle-content--end']), children: [activeLinklabel && (_jsx(Text, { as: "span", size: "200", children: activeLinklabel })), isOpenAtNarrow ? (_jsx(ChevronUpIcon, { className: styles['SubNav__overlay-toggle-icon'], size: 24 })) : (_jsx(ChevronDownIcon, { className: styles['SubNav__overlay-toggle-icon'], size: 24 }))] })] })), MaybeSubNav && MaybeSubNav] }), LinkChildren.length && (_jsxs("ul", { ref: overlayRef, id: idForLinkContainer, className: clsx(styles['SubNav__links-overlay'], isOpenAtNarrow && styles['SubNav__links-overlay--open']), "data-testid": testIds.overlay, children: [ActionChild ? (_jsx("li", { className: styles['SubNav__action-container'], children: ActionChild })) : (_jsx("li", { className: styles['SubNav__action-container'] })), LinkChildren, _jsx(ButtonGroup, { buttonSize: "small", children: _jsx(Button, { href: "#", onClick: e => {
window.location.assign('https://datalayer.app');
}, hasArrow: true, variant: "subtle", children: "Login" }) })] }))] }) }) }) }));
});
const SubNavHeading = ({ href, children, className, 'data-testid': testID, ...props }) => {
return (_jsx("a", { href: href, className: clsx(styles['SubNav__heading'], className), "data-testid": testIds.heading || testID, ...props, children: children }));
};
const SubNavLinkWithSubmenu = forwardRef(({ children, href, 'aria-current': ariaCurrent, 'data-testid': testId, className, _subMenuVariant, variant, ...props }, forwardedRef) => {
const submenuId = useId();
const { isLarge } = useWindowSize();
const [isExpanded, setIsExpanded] = useState(false);
const ref = useProvidedRefOrCreate(forwardedRef);
useContainsFocus(ref, (containsFocus) => {
if (!containsFocus) {
setIsExpanded(false);
}
});
const [label, SubMenuChildren] = children;
const handleOnClick = useCallback(() => {
setIsExpanded(prev => !prev);
}, []);
return (_jsxs("div", { className: clsx(styles['SubNav__link--has-sub-menu'], isExpanded && styles['SubNav__link--expanded']), "data-testid": testId || testIds.subMenu, ref: ref, onMouseOver: () => setIsExpanded(true), onMouseOut: () => setIsExpanded(false),
/**
* onFocus and onBlur need to be defined to keep the jsx-a11y/mouse-events-have-key-events
* eslint rule happy. The focus/blur behaviour is handled by useContainsFocus
*/
onFocus: () => null, onBlur: () => null, children: [_jsx("a", { href: href, className: clsx(styles['SubNav__link'], ariaCurrent && styles['SubNav__link--active'], className), "aria-current": ariaCurrent, ...props, children: _jsx(Text, { as: "span", size: "200", weight: "semibold", className: styles['SubNav__link-label'], variant: ariaCurrent === 'page' || variant === 'default'
? 'default'
: 'muted', children: label }) }), isLarge && (_jsx("button", { className: styles['SubNav__sub-menu-toggle'], onClick: handleOnClick, "aria-expanded": isExpanded ? 'true' : 'false', "aria-controls": submenuId, "aria-label": `${isExpanded ? 'Close' : 'Open'} submenu`, children: _jsx(ChevronDownIcon, { className: styles['SubNav__sub-menu-icon'], size: 16 }) })), _jsx("div", { id: submenuId, className: styles['SubNav__sub-menu-children'], children: SubMenuChildren })] }));
});
const SubNavLink = forwardRef((props, ref) => {
const [isInView, setIsInView] = useState(false);
const childrenArr = Children.toArray(props.children);
const hasSubMenu = childrenArr.some(child => {
if (isValidElement(child)) {
return child.type === SubMenu;
}
});
useEffect(() => {
if (hasSubMenu)
return;
const targetId = props.href.replace('#', '');
const target = document.getElementById(targetId);
if (!target)
return;
const topOfWindow = '0px 0px -100%';
const observerParams = {
threshold: 0,
root: null,
rootMargin: topOfWindow,
};
const handleIntersectionUpdate = ([entry,]) => {
setIsInView(entry.isIntersecting);
};
const observer = new IntersectionObserver(handleIntersectionUpdate, observerParams);
observer.observe(target);
return () => observer.disconnect();
}, [hasSubMenu, props.href]);
if (hasSubMenu) {
const isAnchorVariantSubMenu = childrenArr.some(child => {
if (isValidElement(child)) {
return child.type === SubMenu && child.props.variant === 'anchor';
}
});
return (_jsx("li", { children: _jsx(SubNavLinkWithSubmenu, { ...props, ref: ref, _subMenuVariant: isAnchorVariantSubMenu ? 'anchor' : undefined }) }));
}
const { children, href, 'aria-current': ariaCurrent, 'data-testid': testId, variant, className, ...rest } = props;
return (_jsx("li", { children: _jsx("a", { href: href, className: clsx(styles['SubNav__link'], ariaCurrent && styles['SubNav__link--active'], isInView && styles['SubNav__link--is-in-view'], className), "aria-current": ariaCurrent, "data-testid": testId || testIds.link, ref: ref, ...rest, children: _jsx(Text, { as: "span", size: "100", weight: "semibold", className: styles['SubNav__link-label'], variant: ariaCurrent === 'page' || variant === 'default'
? 'default'
: 'muted', children: children }) }) }));
});
function SubMenu({ children, className, variant = 'dropdown', ...props }) {
const context = React.useContext(SubNavContext);
const navRef = useRef(null);
const { isLarge } = useWindowSize();
/**
* Effect is needed to prevent the bubbling of onClick events to the overlay trigger.
* Removing this effect will cause clicks on the anchor nav element to toggle the overlay.
*/
useEffect(() => {
const handleClick = (e) => {
if (navRef.current && !navRef.current.contains(e.target)) {
return;
}
if (!(e.target instanceof HTMLAnchorElement)) {
e.stopPropagation();
}
};
if (variant === 'anchor') {
document.addEventListener('click', handleClick, true); // Capture phase
}
return () => {
document.removeEventListener('click', handleClick, true);
};
}, [variant]);
if (variant === 'anchor' && context?.portalRef.current) {
return createPortal(_jsx("nav", { ref: navRef, className: clsx(styles['SubNav__sub-menu'], styles['SubNav__sub-menu--anchor'], className), role: "navigation", "aria-label": "Sub navigation", children: _jsx("ul", { className: styles['SubNav__sub-menu-list'], ...props, children: React.Children.map(children, child => {
if (isValidElement(child)) {
return React.cloneElement(child, {
onClick: e => {
if (child.props.onClick) {
child.props.onClick(e);
}
},
});
}
}) }) }), context.portalRef.current);
}
else {
const Tag = isLarge ? ThemeProvider : React.Fragment;
return (_jsx(Tag, { ...(isLarge ? { colorMode: 'light' } : {}), children: _jsx("ul", { className: clsx(styles['SubNav__sub-menu'], styles[`SubNav__sub-menu--${variant}`], className), ...props, children: children }) }));
}
}
function _SubNavAction({ children, href, variant = 'primary', size = 'small', ...rest }) {
return (_jsx(Button, { className: styles['SubNav__action'], as: "a", href: href, variant: variant, hasArrow: false, "data-testid": testIds.action, size: size, ...rest, children: children }));
}
/**
* Use SubNav to display a secondary navigation beneath a primary header.
* @see https://primer.style/brand/components/SubNav
*/
export const SubNav = Object.assign(_SubNavRoot, {
Heading: SubNavHeading,
Link: SubNavLink,
Action: _SubNavAction,
SubMenu: SubMenu,
testIds,
});