@datalayer/core
Version:
[](https://datalayer.io)
258 lines (257 loc) • 20.3 kB
JavaScript
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,
});