mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-01 08:53:29 +00:00
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import {
|
|
Anchor,
|
|
Box,
|
|
Burger,
|
|
Group,
|
|
Indicator,
|
|
Menu,
|
|
UnstyledButton,
|
|
rem,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconBell,
|
|
IconChevronRight,
|
|
IconLogout,
|
|
IconUserCircle,
|
|
} from '@tabler/icons-react';
|
|
import type { ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
import { ColorSchemeToggle } from './ColorSchemeToggle';
|
|
import { BrandAvatar } from './BrandAvatar';
|
|
|
|
export interface Breadcrumb {
|
|
label: string;
|
|
path: string;
|
|
}
|
|
|
|
interface AppHeaderProps {
|
|
onToggleNav: () => void;
|
|
onToggleSidebar: () => void;
|
|
navOpened: boolean;
|
|
breadcrumbs: Breadcrumb[];
|
|
onNavigate: (path: string) => void;
|
|
onLogout: () => void;
|
|
userName?: string;
|
|
userInitials?: string;
|
|
supportedLanguages: readonly string[];
|
|
onNotificationsClick?: () => void;
|
|
notificationCount?: number;
|
|
/**
|
|
* Rendered at the far left. The sidebar layout carries the brand in the
|
|
* sidebar itself; the top-bar layout has no sidebar, so it passes the brand
|
|
* here rather than leaving the chrome unbranded.
|
|
*/
|
|
brand?: ReactNode;
|
|
/**
|
|
* Breakpoint from which the burger is hidden. The top-bar layout only needs
|
|
* it on small screens, where the drawer replaces the nav strip.
|
|
*/
|
|
burgerHiddenFrom?: string;
|
|
}
|
|
|
|
export function AppHeader({
|
|
onToggleNav,
|
|
onToggleSidebar,
|
|
navOpened,
|
|
breadcrumbs,
|
|
onNavigate,
|
|
onLogout,
|
|
userName = 'User',
|
|
userInitials = '?',
|
|
supportedLanguages,
|
|
onNotificationsClick,
|
|
notificationCount,
|
|
brand,
|
|
burgerHiddenFrom,
|
|
}: AppHeaderProps) {
|
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
|
|
|
return (
|
|
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
|
<Group gap="md" wrap="nowrap">
|
|
{brand}
|
|
{/* Hamburger — styled like user-management Top.tsx */}
|
|
{/* The Burger itself owns the click so the control is a real, keyboard
|
|
reachable <button>; the Box is chrome only. It previously wrapped a
|
|
no-op button, which no keyboard user could operate. */}
|
|
<Box
|
|
hiddenFrom={burgerHiddenFrom}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: rem(38),
|
|
height: rem(38),
|
|
borderRadius: rem(12),
|
|
background: 'var(--mantine-color-body)',
|
|
color: 'var(--mantine-color-gray-6)',
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
|
transition: 'all 150ms ease',
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
|
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
|
e.currentTarget.style.boxShadow =
|
|
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
|
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
|
e.currentTarget.style.boxShadow =
|
|
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
|
}}
|
|
>
|
|
<Burger
|
|
opened={navOpened}
|
|
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
|
size="sm"
|
|
aria-label="Toggle navigation"
|
|
styles={{
|
|
root: { border: 'none', background: 'transparent' },
|
|
burger: { '--burger-color': 'currentColor' },
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Breadcrumbs — card container with pill-style crumbs */}
|
|
<Group
|
|
gap={2}
|
|
wrap="nowrap"
|
|
visibleFrom="xs"
|
|
style={{
|
|
borderRadius: rem(12),
|
|
background: 'var(--mantine-color-body)',
|
|
padding: '4px 10px',
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{breadcrumbs.map((crumb, i) => {
|
|
const isLast = i === breadcrumbs.length - 1;
|
|
return (
|
|
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
|
|
{i > 0 && (
|
|
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
|
|
)}
|
|
{isLast ? (
|
|
<span
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
borderRadius: rem(999),
|
|
padding: '2px 12px',
|
|
fontSize: rem(12),
|
|
fontWeight: 600,
|
|
background: 'var(--mantine-color-primary-light)',
|
|
color: 'var(--mantine-color-primary-7)',
|
|
boxShadow: '0 0 0 1px var(--mantine-color-primary-2)',
|
|
whiteSpace: 'nowrap',
|
|
lineHeight: '22px',
|
|
}}
|
|
>
|
|
{crumb.label}
|
|
</span>
|
|
) : (
|
|
<Anchor
|
|
size="sm"
|
|
c="gray.6"
|
|
onClick={() => onNavigate(crumb.path)}
|
|
style={{
|
|
cursor: 'pointer',
|
|
borderRadius: rem(999),
|
|
padding: '2px 10px',
|
|
fontSize: rem(12),
|
|
fontWeight: 500,
|
|
whiteSpace: 'nowrap',
|
|
textDecoration: 'none',
|
|
lineHeight: '22px',
|
|
transition: 'all 150ms ease',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
|
e.currentTarget.style.color = 'var(--mantine-color-primary-7)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'transparent';
|
|
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
|
}}
|
|
>
|
|
{crumb.label}
|
|
</Anchor>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Group gap="sm" wrap="nowrap">
|
|
<LanguageSwitcher supportedLanguages={supportedLanguages} />
|
|
<ColorSchemeToggle />
|
|
|
|
{/* Notification bell */}
|
|
<UnstyledButton
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: rem(38),
|
|
height: rem(38),
|
|
borderRadius: rem(12),
|
|
background: 'var(--mantine-color-body)',
|
|
color: 'var(--mantine-color-gray-6)',
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
|
transition: 'all 150ms ease',
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
position: 'relative',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
|
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
|
e.currentTarget.style.boxShadow =
|
|
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
|
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
|
e.currentTarget.style.boxShadow =
|
|
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
|
}}
|
|
onClick={onNotificationsClick}
|
|
aria-label="Notifications"
|
|
>
|
|
<Indicator
|
|
color="red"
|
|
size={notificationCount ? 16 : 9}
|
|
offset={notificationCount ? 2 : 5}
|
|
withBorder
|
|
disabled={notificationCount === 0}
|
|
label={notificationCount || undefined}
|
|
>
|
|
<IconBell size={19} />
|
|
</Indicator>
|
|
</UnstyledButton>
|
|
|
|
{/* Profile avatar menu */}
|
|
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
|
|
<Menu.Target>
|
|
<UnstyledButton
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: rem(38),
|
|
height: rem(38),
|
|
borderRadius: rem(12),
|
|
transition: 'all 150ms ease',
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
background: 'transparent',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.transform = 'scale(1.05)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.transform = 'scale(1)';
|
|
}}
|
|
>
|
|
<BrandAvatar initials={userInitials} size={38} />
|
|
</UnstyledButton>
|
|
</Menu.Target>
|
|
<Menu.Dropdown
|
|
style={{
|
|
borderRadius: rem(12),
|
|
padding: rem(6),
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
|
}}
|
|
>
|
|
<Menu.Label
|
|
style={{
|
|
padding: rem(10),
|
|
fontWeight: 600,
|
|
fontSize: rem(13),
|
|
}}
|
|
>
|
|
{userName}
|
|
</Menu.Label>
|
|
<Menu.Item
|
|
leftSection={<IconUserCircle size={16} />}
|
|
onClick={() => onNavigate('/profile')}
|
|
style={{ borderRadius: rem(8) }}
|
|
>
|
|
Profile
|
|
</Menu.Item>
|
|
<Menu.Divider />
|
|
<Menu.Item
|
|
color="red"
|
|
leftSection={<IconLogout size={16} />}
|
|
onClick={onLogout}
|
|
style={{ borderRadius: rem(8) }}
|
|
>
|
|
Logout
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
</Group>
|
|
);
|
|
}
|