Package: @lilith/ui-header Split from: lilith/ui.git or lilith/build.git Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
201 lines
No EOL
8.4 KiB
JavaScript
201 lines
No EOL
8.4 KiB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
/**
|
|
* HeaderNav - Navigation wrapper with dropdown support
|
|
*
|
|
* Wraps @lilith/ui-navigation Navigation component for headers.
|
|
* Supports hover-triggered dropdown menus on desktop.
|
|
* Falls back to accordion on mobile.
|
|
*/
|
|
import { useState, useRef, useEffect } from 'react';
|
|
import { Link } from '@lilith/ui-router';
|
|
import styled from '@lilith/ui-styled-components';
|
|
/**
|
|
* Get the href from a navigation item.
|
|
* Supports both `href` (ui-navigation standard) and `path` (marketplace config).
|
|
*/
|
|
const getItemHref = (item) => {
|
|
// Type assertion to access path property which exists in marketplace configs
|
|
const itemWithPath = item;
|
|
return item.href || itemWithPath.path || '#';
|
|
};
|
|
/**
|
|
* Check if a URL is external (absolute http/https).
|
|
* External URLs must render as <a> tags, not React Router <Link>.
|
|
*/
|
|
const isExternalUrl = (url) => url.startsWith('http://') || url.startsWith('https://');
|
|
// Desktop navigation container
|
|
const DesktopNav = styled.nav `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${(props) => props.theme.spacing.sm};
|
|
|
|
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
|
|
display: none;
|
|
}
|
|
`;
|
|
const NavItem = styled.div `
|
|
position: relative;
|
|
`;
|
|
const NavLink = styled(Link) `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${(props) => props.theme.spacing.xs};
|
|
font-family: ${(props) => props.theme.typography.fontFamily.body};
|
|
font-size: ${(props) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props) => props.theme.typography.fontWeight.medium};
|
|
color: ${({ $active, theme }) => $active ? theme.colors.text.primary : theme.colors.text.secondary};
|
|
text-decoration: none;
|
|
padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md};
|
|
border-radius: ${(props) => props.theme.borderRadius.sm};
|
|
transition: ${(props) => props.theme.transitions.fast};
|
|
cursor: pointer;
|
|
|
|
${({ $active, theme }) => $active &&
|
|
`
|
|
background: ${theme.colors.primary.main}20;
|
|
color: ${theme.colors.text.primary};
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 20px;
|
|
height: 2px;
|
|
background: ${theme.colors.primary.main};
|
|
border-radius: 1px;
|
|
}
|
|
`}
|
|
|
|
&:hover {
|
|
background: ${(props) => props.theme.colors.hover.surface};
|
|
color: ${(props) => props.theme.colors.text.primary};
|
|
}
|
|
|
|
svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
transition: transform ${(props) => props.theme.transitions.fast};
|
|
}
|
|
`;
|
|
const ExternalNavLink = styled.a `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${(props) => props.theme.spacing.xs};
|
|
font-family: ${(props) => props.theme.typography.fontFamily.body};
|
|
font-size: ${(props) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props) => props.theme.typography.fontWeight.medium};
|
|
color: ${({ $active, theme }) => $active ? theme.colors.text.primary : theme.colors.text.secondary};
|
|
text-decoration: none;
|
|
padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md};
|
|
border-radius: ${(props) => props.theme.borderRadius.sm};
|
|
transition: ${(props) => props.theme.transitions.fast};
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background: ${(props) => props.theme.colors.hover.surface};
|
|
color: ${(props) => props.theme.colors.text.primary};
|
|
}
|
|
`;
|
|
const Dropdown = styled.ul `
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
min-width: 200px;
|
|
background-color: ${(props) => props.theme.colors.surface};
|
|
border: 1px solid ${(props) => props.theme.colors.border.default};
|
|
border-radius: ${(props) => props.theme.borderRadius.lg};
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
|
list-style: none;
|
|
margin: ${(props) => props.theme.spacing.sm} 0 0;
|
|
padding: ${(props) => props.theme.spacing.sm};
|
|
opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)};
|
|
visibility: ${({ $isOpen }) => ($isOpen ? 'visible' : 'hidden')};
|
|
transform: translateY(${({ $isOpen }) => ($isOpen ? '0' : '-8px')});
|
|
transition: all ${(props) => props.theme.transitions.normal};
|
|
pointer-events: ${({ $isOpen }) => ($isOpen ? 'auto' : 'none')};
|
|
z-index: 100;
|
|
`;
|
|
const DropdownItem = styled.li `
|
|
margin: 0;
|
|
`;
|
|
const dropdownLinkStyles = `
|
|
display: block;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
`;
|
|
const DropdownLink = styled(Link) `
|
|
${dropdownLinkStyles}
|
|
font-family: ${(props) => props.theme.typography.fontFamily.body};
|
|
font-size: ${(props) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props) => props.theme.typography.fontWeight.normal};
|
|
color: ${(props) => props.theme.colors.text.secondary};
|
|
padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md};
|
|
border-radius: ${(props) => props.theme.borderRadius.md};
|
|
transition: all ${(props) => props.theme.transitions.fast};
|
|
|
|
&:hover {
|
|
background-color: ${(props) => props.theme.colors.hover.surface};
|
|
color: ${(props) => props.theme.colors.text.primary};
|
|
}
|
|
`;
|
|
const ExternalDropdownLink = styled.a `
|
|
${dropdownLinkStyles}
|
|
font-family: ${(props) => props.theme.typography.fontFamily.body};
|
|
font-size: ${(props) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props) => props.theme.typography.fontWeight.normal};
|
|
color: ${(props) => props.theme.colors.text.secondary};
|
|
padding: ${(props) => props.theme.spacing.sm} ${(props) => props.theme.spacing.md};
|
|
border-radius: ${(props) => props.theme.borderRadius.md};
|
|
transition: all ${(props) => props.theme.transitions.fast};
|
|
|
|
&:hover {
|
|
background-color: ${(props) => props.theme.colors.hover.surface};
|
|
color: ${(props) => props.theme.colors.text.primary};
|
|
}
|
|
`;
|
|
const ChevronIcon = () => (_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", children: _jsx("path", { fillRule: "evenodd", d: "M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z", clipRule: "evenodd" }) }));
|
|
const NavItemWithDropdown = ({ item, dropdownTrigger }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const timeoutRef = useRef(undefined);
|
|
const hasChildren = !!(item.children && item.children.length > 0);
|
|
const handleMouseEnter = () => {
|
|
if (dropdownTrigger === 'hover' && hasChildren) {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
setIsOpen(true);
|
|
}
|
|
};
|
|
const handleMouseLeave = () => {
|
|
if (dropdownTrigger === 'hover' && hasChildren) {
|
|
timeoutRef.current = setTimeout(() => setIsOpen(false), 150);
|
|
}
|
|
};
|
|
const handleClick = (e) => {
|
|
if (hasChildren && dropdownTrigger === 'click') {
|
|
e.preventDefault();
|
|
setIsOpen(!isOpen);
|
|
}
|
|
else if (item.onClick) {
|
|
// Call onClick (e.g. for sound effects) but DON'T prevent default
|
|
// so the <a href="..."> link still navigates
|
|
item.onClick();
|
|
}
|
|
};
|
|
useEffect(() => () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
}, []);
|
|
const href = getItemHref(item);
|
|
const external = isExternalUrl(href);
|
|
return (_jsxs(NavItem, { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [external ? (_jsx(ExternalNavLink, { href: href, target: "_blank", rel: "noopener noreferrer", onClick: () => item.onClick?.(), children: item.label })) : (_jsxs(NavLink, { to: href, onClick: handleClick, "$hasChildren": hasChildren, children: [item.label, hasChildren && _jsx(ChevronIcon, {})] })), hasChildren && !external && (_jsx(Dropdown, { "$isOpen": isOpen, children: item.children.map((child, index) => {
|
|
const childHref = getItemHref(child);
|
|
const childExternal = isExternalUrl(childHref);
|
|
return (_jsx(DropdownItem, { children: childExternal ? (_jsx(ExternalDropdownLink, { href: childHref, target: "_blank", rel: "noopener noreferrer", onClick: child.onClick, children: child.label })) : (_jsx(DropdownLink, { to: childHref, onClick: child.onClick, children: child.label })) }, `${childHref}-${index}`));
|
|
}) }))] }));
|
|
};
|
|
export const HeaderNav = ({ items, dropdownTrigger = 'hover', className, }) => (_jsx(DesktopNav, { className: className, children: items.map((item, index) => (_jsx(NavItemWithDropdown, { item: item, dropdownTrigger: dropdownTrigger }, `${getItemHref(item)}-${index}`))) }));
|
|
//# sourceMappingURL=HeaderNav.js.map
|