UNPKG

@datalayer/core

Version:

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

258 lines (257 loc) 20.3 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; /* * Copyright (c) 2023-2025 Datalayer, Inc. * Distributed under the terms of the Modified BSD License. */ import React, { useState, useCallback, useRef, forwardRef, useMemo, useEffect, } from 'react'; import clsx from 'clsx'; import { SearchIcon, XIcon, LinkExternalIcon } from '@primer/octicons-react'; import { Button, FormControl, Text, TextInput, Label, } from '@primer/react-brand'; import { CircleYellowIcon } from '@datalayer/icons-react'; import { NavigationVisbilityObserver } from './NavigationVisbilityObserver'; import { useCoreStore } from '../../state'; import { useOnClickOutside, useFocusTrap, useKeyboardEscape, useWindowSize, useNavigate, } from '../../hooks'; import { useRunStore } from '../../state'; import { useId } from '../../hooks'; /** * Design tokens */ import '@primer/brand-primitives/lib/design-tokens/css/tokens/functional/components/subdomain-nav-bar/colors-with-modes.css'; /** * Main Stylesheet (as a CSS Module) */ import styles from './SubdomainNavBar.module.css'; const testIds = { root: 'SubdomainNavBar', get innerContainer() { return `${this.root}-inner-container`; }, get menuButton() { return `${this.root}-menuButton`; }, get menuLinks() { return `${this.root}-menuLinks`; }, get liveRegion() { return `${this.root}-search-live-region`; }, }; function Root({ children, className, fixed = true, fullWidth = false, logoHref = 'https://datalayer.app', title, titleHref = '/', ...rest }) { const navigate = useNavigate(); const runStore = useRunStore(); const { configuration } = useCoreStore(); const { isMedium } = useWindowSize(); const isDev = runStore.isDev; const [menuHidden, setMenuHidden] = useState(true); const [searchVisible, setSearchVisible] = useState(false); const [startOfContentButtonFocused, setStartOfContentButtonFocused] = useState(false); const mainElRef = useRef(null); const startOfContentID = useId('dla-start-of-content'); const handleMobileMenuClick = () => setMenuHidden(!menuHidden); const handleSearchVisibility = () => setSearchVisible(!searchVisible); const focusTrapRef = useRef(null); useEffect(() => { const mainEl = document.querySelector('main'); if (mainEl) { mainEl.id = mainEl.id || startOfContentID; mainElRef.current = mainEl; } }, [startOfContentID]); useFocusTrap({ containerRef: focusTrapRef, restoreFocusOnCleanUp: true, disabled: menuHidden, }); useKeyboardEscape(() => { setMenuHidden(true); }); useEffect(() => { if (isMedium) { setMenuHidden(true); } }, [isMedium, menuHidden]); useEffect(() => { const newOverflowState = menuHidden ? 'auto' : 'hidden'; document.body.style.overflow = newOverflowState; }, [menuHidden]); const setStartOfContentButtonFocusedTrue = useCallback(() => setStartOfContentButtonFocused(true), []); const setStartOfContentButtonFocusedFalse = useCallback(() => setStartOfContentButtonFocused(false), []); const hasLinks = useMemo(() => React.Children.toArray(children).filter(child => React.isValidElement(child) && typeof child.type !== 'string' && child.type === Link), // eslint-disable-next-line react-hooks/exhaustive-deps []).length > 0; const menuItems = useMemo(() => React.Children.toArray(children) .map((child, index) => { if (React.isValidElement(child) && typeof child.type !== 'string') { if (child.type === Link) { return React.cloneElement(child, { 'data-navitemid': child.props.children, href: child.props.href, children: child.props.children, style: { [`--animation-order`]: index, }, }); } return null; } }) .filter(Boolean), [children]); return (_jsxs(_Fragment, { children: [_jsxs("div", { className: clsx(styles['SubdomainNavBar-outer-container'], fixed && styles['SubdomainNavBar-outer-container--fixed']), children: [_jsx(Button, { as: "a", href: `#${mainElRef.current?.id || startOfContentID}`, variant: "primary", className: clsx(styles['SubdomainNavBar-skip-to-content'], !startOfContentButtonFocused && 'visually-hidden'), onFocus: setStartOfContentButtonFocusedTrue, onBlur: setStartOfContentButtonFocusedFalse, children: "Skip to content" }), _jsx("header", { className: clsx(styles['SubdomainNavBar'], className), "data-testid": testIds.root, ...rest, children: _jsxs("div", { ref: focusTrapRef, className: clsx(styles['SubdomainNavBar-inner-container'], searchVisible && styles['SubdomainNavBar-inner-container--search-open'], !fullWidth && styles['SubdomainNavBar-inner-container--centered']), "data-testid": testIds.innerContainer, children: [_jsx("nav", { "aria-label": "global breadcrumb", children: _jsxs("ol", { className: styles['SubdomainNavBar-title-area'], children: [_jsx("li", { children: _jsx("a", { href: "javascript: return false;", "aria-label": "Datalayer Home", className: styles['SubdomainNavBar-logo-mark'], onClick: e => navigate('/', e), children: _jsx("img", { src: configuration.brand.logoUrl, style: { height: 25 } }) }) }), isDev && (_jsx("li", { children: _jsx(Label, { leadingVisual: _jsx(CircleYellowIcon, {}), color: "green-blue", children: "DEV" }) }))] }) }), hasLinks && (_jsx("nav", { id: "menu-navigation", "aria-label": title, className: styles['SubdomainNavBar-primary-nav'], "data-testid": testIds.menuLinks, children: _jsx(NavigationVisbilityObserver, { className: clsx(styles['SubdomainNavBar-primary-nav-list--invisible']), children: menuItems }) })), _jsxs("div", { className: clsx(styles['SubdomainNavBar-secondary-nav']), children: [React.Children.toArray(children) .map(child => { if (React.isValidElement(child) && typeof child.type !== 'string') { if (child.type === Search) { return React.cloneElement(child, { active: searchVisible, handlerFn: handleSearchVisibility, title, }); } return null; } }) .filter(Boolean), hasLinks && (_jsxs("button", { "aria-expanded": !menuHidden, "aria-label": "Menu", "aria-controls": "menu-navigation", "aria-haspopup": "true", className: clsx(styles['SubdomainNavBar-menu-button'], styles['SubdomainNavBar-mobile-menu-button'], !menuHidden && styles['SubdomainNavBar-menu-button--close']), "data-testid": testIds.menuButton, onClick: handleMobileMenuClick, children: [_jsx("div", { className: clsx(styles['SubdomainNavBar-menu-button-bar']) }), _jsx("div", { className: clsx(styles['SubdomainNavBar-menu-button-bar']) }), _jsx("div", { className: clsx(styles['SubdomainNavBar-menu-button-bar']) })] })), isMedium && (_jsx("div", { className: clsx(styles['SubdomainNavBar-button-area'], styles['SubdomainNavBar-button-area--visible']), children: _jsxs("div", { className: styles['SubdomainNavBar-button-area-inner'], children: [React.Children.toArray(children) .map(child => { if (React.isValidElement(child) && typeof child.type !== 'string') { if (child.type === PrimaryAction) { return child; } return null; } }) .filter(Boolean), React.Children.toArray(children) .map(child => { if (React.isValidElement(child) && typeof child.type !== 'string') { if (child.type === SecondaryAction) { return child; } return null; } }) .filter(Boolean)] }) })), !isMedium && (_jsxs("div", { className: clsx(styles['SubdomainNavBar-menu-wrapper'], menuHidden && styles['SubdomainNavBar-menu-wrapper--close']), children: [_jsxs("div", { children: [title && titleHref && (_jsx(Text, { as: "p", children: _jsx("a", { href: titleHref, "aria-label": `${title} home`, className: clsx(styles['SubdomainNavBar-link'], styles['SubdomainNavBar-link--title']), children: title }) })), hasLinks && !menuHidden && (_jsx(NavigationVisbilityObserver, { showOnlyOnNarrow: true, className: clsx(styles['SubdomainNavBar-primary-nav-list--visible']), children: menuItems }))] }), _jsx("div", { className: clsx(styles['SubdomainNavBar-button-area'], styles['SubdomainNavBar-button-area--visible']), children: _jsxs("div", { className: styles['SubdomainNavBar-button-area-inner'], children: [React.Children.toArray(children) .map(child => { if (React.isValidElement(child) && typeof child.type !== 'string') { if (child.type === PrimaryAction) { return child; } return null; } }) .filter(Boolean), React.Children.toArray(children) .map(child => { if (React.isValidElement(child) && typeof child.type !== 'string') { if (child.type === SecondaryAction) { return child; } return null; } }) .filter(Boolean)] }) })] }))] })] }) })] }), !mainElRef.current && _jsx("div", { id: `${startOfContentID}`, tabIndex: -1 })] })); } function Link({ href, className, children, isExternal, target, ...rest }) { return (_jsx("li", { className: clsx(styles['SubdomainNavBar-primary-nav-list-item'], className), ...rest, children: _jsxs("a", { href: href, target: target ?? '_self', className: clsx(styles['SubdomainNavBar-link']), children: [_jsx("span", { className: clsx(styles['SubdomainNavBar-link-text']), children: children }), isExternal && (_jsx(LinkExternalIcon, { size: 16, "aria-label": "External link" }))] }) })); } const SearchInternal = ({ active, title, searchResults, searchTerm, handlerFn, onSubmit, onChange, }, ref) => { const dialogRef = useRef(null); useFocusTrap({ containerRef: dialogRef, restoreFocusOnCleanUp: true, disabled: !active, }); useOnClickOutside(dialogRef, handlerFn); const [activeDescendant, setActiveDescendant] = useState(-1); const [listboxActive, setListboxActive] = useState(); const [liveRegion, setLiveRegion] = useState(false); const handleClose = useCallback((event = null) => { if (handlerFn) handlerFn(event); setActiveDescendant(-1); }, [handlerFn]); useOnClickOutside(dialogRef, handleClose); useKeyboardEscape(() => { // Close the dialog if combobox is already collapsed if (!listboxActive && active) { handleClose(); return false; } setListboxActive(false); setActiveDescendant(-1); }); const handleAriaFocus = useCallback(event => { const supportedKeys = ['ArrowDown', 'ArrowUp', 'Escape', 'Enter']; const currentCount = activeDescendant; const searchResultsLength = searchResults ? searchResults.length : 0; const dialog = dialogRef.current; let count; // Prevent any other keys outside of supported from being prevented. // Only prevent "Enter" if activeDescendant is greater than -1. if (!supportedKeys.includes(event.key) || (event.key === 'Enter' && activeDescendant === -1) || !dialog) { return false; } event.preventDefault(); if (event.key === 'ArrowDown') { // If count reaches last search result item, reset to -1 count = currentCount < searchResultsLength - 1 ? currentCount + 1 : -1; setActiveDescendant(count); } else if (event.key === 'ArrowUp') { // Reset to last search result item if count = currentCount === -1 ? searchResultsLength - 1 : currentCount - 1; setActiveDescendant(count); } if (['ArrowDown', 'ArrowUp'].includes(event.key)) { dialog .querySelector(`#subdomainnavbar-search-result-${count}`) ?.scrollIntoView(); } if (event.key === 'Enter') { const link = dialog.querySelector(`#subdomainnavbar-search-result-${activeDescendant} a`); link.click(); } }, [searchResults, activeDescendant]); const searchLiveRegion = useCallback(() => { // Adding a non-breaking space and then removing it will force screen readers to announce the text, // as it thinks that there was a change within the live region. setLiveRegion(true); setTimeout(() => { if (active) setLiveRegion(false); }, 200); }, [active]); useEffect(() => { // We want to set "listboxActive" when search results are present, // or the user pressed "Escape". We watch for "searchTerm", as we - // want the listbox to become active if they pressed "Escape", and - // adjusted their existing value. const search = searchResults && searchResults.length ? true : false; setListboxActive(search); searchLiveRegion(); }, [searchResults, searchTerm, searchLiveRegion]); return (_jsxs(_Fragment, { children: [_jsx("div", { className: clsx(styles['SubdomainNavBar-search-trigger']), children: _jsx("button", { "aria-label": "search", className: styles['SubdomainNavBar-search-button'], onClick: handlerFn, "data-testid": "toggle-search", children: _jsx(SearchIcon, {}) }) }), active && (_jsxs("div", { ref: dialogRef, role: "dialog", "aria-label": `Search ${title}`, "aria-modal": "true", tabIndex: -1, className: clsx(styles['SubdomainNavBar-search-dialog']), children: [_jsxs("div", { className: clsx(styles['SubdomainNavBar-search-dialog-control-area']), children: [_jsx("form", { className: clsx(styles['SubdomainNavBar-search-form']), onSubmit: onSubmit, role: "search", children: _jsxs(FormControl, { fullWidth: true, size: "medium", children: [_jsx(FormControl.Label, { visuallyHidden: true, children: "Search" }), _jsx(TextInput, { ref: ref, className: clsx(styles['SubdomainNavBar-search-text-input']), name: "search", role: "combobox", "aria-expanded": listboxActive, "aria-controls": "listbox-search-results", placeholder: `Search ${title}`, onChange: onChange, defaultValue: searchTerm, invisible: true, leadingVisual: _jsx(SearchIcon, { size: 16 }), "aria-activedescendant": activeDescendant === -1 ? undefined : `subdomainnavbar-search-result-${activeDescendant}`, onKeyDown: handleAriaFocus })] }) }), _jsx("button", { "aria-label": "Close", className: clsx(styles['SubdomainNavBar-menu-button'], styles['SubdomainNavBar-menu-button--close']), onClick: handleClose, children: _jsx(XIcon, { size: 24 }) })] }), _jsxs("div", { id: "listbox-search-results", children: [listboxActive && (_jsxs("div", { className: clsx(styles['SubdomainNavBar-search-results-container']), children: [_jsxs(Text, { id: "subdomainnavbar-search-results-heading", className: styles['SubdomainNavBar-search-results-heading'], children: ["Results for \u201C", searchTerm, "\u201D"] }), _jsx("ul", { role: "listbox", tabIndex: 0, "aria-labelledby": "subdomainnavbar-search-results-heading", className: clsx(styles['SubdomainNavBar-search-results']), children: searchResults?.map((result, index) => (_jsxs("li", { id: `subdomainnavbar-search-result-${index}`, className: styles['SubdomainNavBar-search-result-item'], role: "option", "aria-selected": index === activeDescendant, children: [_jsx("div", { className: styles['SubdomainNavBar-search-result-item-container'], children: _jsx("a", { href: result.url, children: result.title }) }), _jsx(Text, { as: "p", size: "200", id: `subdomainnavbar-search-result-item-desc${index}`, className: styles['SubdomainNavBar-search-result-item-desc'], children: result.description }), _jsxs("div", { children: [_jsx(Text, { size: "100", className: styles['SubdomainNavBar-search-result-item-desc'], children: result.date }), result.category && (_jsxs(_Fragment, { children: [_jsxs(Text, { size: "100", className: styles['SubdomainNavBar-search-result-item-desc'], children: [' ', "\u2022", ' '] }), _jsx(Text, { size: "100", className: styles['SubdomainNavBar-search-result-item-desc'], children: result.category })] }))] })] }, `${result.title}-${index}`))) })] })), _jsxs("div", { "aria-live": "polite", "aria-atomic": "true", "data-testid": testIds.liveRegion, className: "visually-hidden", children: [`${searchResults?.length} suggestions.`, liveRegion && _jsx("span", { children: "\u00A0" })] })] })] }))] })); }; const Search = forwardRef(SearchInternal); function PrimaryAction({ children, ...rest }) { const { hasArrow = false, size = 'small' } = rest; return (_jsx(Button, { as: "a", href: "javascript: return false;", className: clsx(styles['SubdomainNavBar-cta-button']), variant: "primary", size: size, hasArrow: hasArrow, ...rest, children: children })); } function SecondaryAction({ children, ...rest }) { const { hasArrow = false, size = 'small' } = rest; return (_jsx(Button, { as: "a", href: "javascript: return false;", className: clsx(styles['SubdomainNavBar-cta-button'], styles['SubdomainNavBar-cta-button--secondary']), size: size, hasArrow: hasArrow, ...rest, children: children })); } export const SubdomainNavBar = Object.assign(Root, { Link, Search, PrimaryAction, SecondaryAction, testIds, });