Files
emaui/libs/ui/src/lib/layout/AppHeader.tsx
estifanos d4a5ba7673 UI fixes
2026-08-21 07:38:01 +00:00

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>
);
}