UNPKG

@datalayer/core

Version:

[![Datalayer](https://assets.datalayer.tech/datalayer-25.svg)](https://datalayer.io)

281 lines (280 loc) 15.8 kB
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, });