mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 14:08:12 +00:00
Initial End to End functionality
This commit is contained in:
@@ -3,8 +3,12 @@ export * from './lib/feedback/ConfirmModal';
|
||||
export * from './lib/feedback/ApiErrorAlert';
|
||||
export * from './lib/feedback/notify';
|
||||
export * from './lib/feedback/FeatureUnavailable';
|
||||
export * from './lib/feedback/EmptyState';
|
||||
export * from './lib/feedback/ErrorState';
|
||||
export * from './lib/layout/AppHeader';
|
||||
export * from './lib/layout/AppSidebar';
|
||||
export * from './lib/layout/AppTopNav';
|
||||
export * from './lib/layout/nav-utils';
|
||||
export * from './lib/layout/BrandAvatar';
|
||||
export * from './lib/layout/ColorSchemeToggle';
|
||||
export * from './lib/layout/LanguageSwitcher';
|
||||
|
||||
47
libs/ui/src/lib/feedback/EmptyState.tsx
Normal file
47
libs/ui/src/lib/feedback/EmptyState.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Button, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconInbox, type Icon } from '@tabler/icons-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: Icon;
|
||||
action?: { label: string; onClick: () => void; icon?: ReactNode };
|
||||
}
|
||||
|
||||
/**
|
||||
* The "nothing here" state.
|
||||
*
|
||||
* Distinct from an error: an empty queue is a normal, often good, outcome. It
|
||||
* says so plainly and offers the next useful action rather than leaving a bare
|
||||
* grey panel that reads as a failure.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: StateIcon = IconInbox,
|
||||
action,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<Paper p="xl" withBorder>
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||
<StateIcon size={28} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} fz="lg" ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text c="dimmed" size="sm" ta="center" maw={440}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button mt="xs" variant="light" leftSection={action.icon} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
60
libs/ui/src/lib/feedback/ErrorState.tsx
Normal file
60
libs/ui/src/lib/feedback/ErrorState.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Button, Code, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconRefresh, type Icon } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ErrorStateProps {
|
||||
title: string;
|
||||
/** What actually failed, in the API's words where available. */
|
||||
description?: string;
|
||||
/**
|
||||
* Request/trace id. Shown verbatim and selectable so a user can quote it in
|
||||
* a support ticket — "it didn't work" is not something anyone can act on.
|
||||
*/
|
||||
correlationId?: string;
|
||||
onRetry?: () => void;
|
||||
icon?: Icon;
|
||||
}
|
||||
|
||||
/** The error state every screen shows: what broke, how to retry, what to quote. */
|
||||
export function ErrorState({
|
||||
title,
|
||||
description,
|
||||
correlationId,
|
||||
onRetry,
|
||||
icon: CustomIcon = IconAlertTriangle,
|
||||
}: ErrorStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Paper p="xl" withBorder role="alert">
|
||||
<Stack align="center" gap="sm" py="lg">
|
||||
<ThemeIcon size={48} radius="xl" color="red" variant="light">
|
||||
<CustomIcon size={26} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} fz="lg" ta="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text c="dimmed" size="sm" ta="center" maw={460}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{correlationId && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('error.reference', 'Reference')}: <Code>{correlationId}</Code>
|
||||
</Text>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Button
|
||||
mt="xs"
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t('error.retry', 'Try again')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -53,8 +53,10 @@ export function AppHeader({
|
||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
{/* 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
|
||||
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -84,7 +86,7 @@ export function AppHeader({
|
||||
>
|
||||
<Burger
|
||||
opened={navOpened}
|
||||
onClick={() => {}}
|
||||
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
||||
size="sm"
|
||||
aria-label="Toggle navigation"
|
||||
styles={{
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AppShell,
|
||||
Badge,
|
||||
NavLink,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -14,13 +15,32 @@ import {
|
||||
IconChevronRight,
|
||||
} from '@tabler/icons-react';
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BrandMark } from '@ema-platform/auth';
|
||||
import {
|
||||
badgeLabel,
|
||||
isBranchActive,
|
||||
isItemActive,
|
||||
toSections,
|
||||
type NavEntries,
|
||||
} from './nav-utils';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
icon: Icon;
|
||||
to?: string;
|
||||
/**
|
||||
* One level only. A parent with children is a disclosure, not a destination,
|
||||
* so give it either `to` or `children` — not both.
|
||||
*/
|
||||
children?: NavItem[];
|
||||
/**
|
||||
* Pending count, or `'dot'` for "something is waiting" without a number.
|
||||
* Zero renders nothing: a badge reading 0 is worse than no badge.
|
||||
*/
|
||||
badge?: number | 'dot';
|
||||
/** Hidden unless the user holds at least one of these. */
|
||||
permissions?: string[];
|
||||
/** Not yet connected to real data — surfaced as a "Soon" badge. */
|
||||
soon?: boolean;
|
||||
}
|
||||
@@ -32,15 +52,174 @@ export interface NavSection {
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/** Both shapes are accepted so callers can migrate to sections gradually. */
|
||||
export type NavEntries = NavItem[] | NavSection[];
|
||||
export type { NavEntries } from './nav-utils';
|
||||
|
||||
function toSections(entries: NavEntries): NavSection[] {
|
||||
if (entries.length === 0) return [];
|
||||
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
|
||||
return isSectioned
|
||||
? (entries as NavSection[])
|
||||
: [{ items: entries as NavItem[] }];
|
||||
/** Shown when an item is disabled, so no control is ever dead without a reason. */
|
||||
function itemTooltip(item: NavItem, soonLabel: string): string | undefined {
|
||||
return item.soon ? soonLabel : undefined;
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
item: NavItem;
|
||||
collapsed: boolean;
|
||||
activePath: string;
|
||||
onNavigate: (item: NavItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One nav entry, at either level.
|
||||
*
|
||||
* A parent with children renders as a Mantine NavLink disclosure that starts
|
||||
* open when the current route is inside it, so the user can always see where
|
||||
* they are without hunting. Collapsed to the icon rail there is no room to
|
||||
* nest, so a parent becomes a hover flyout instead of silently losing its
|
||||
* children.
|
||||
*/
|
||||
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ItemIcon = item.icon;
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const active = isItemActive(item, activePath);
|
||||
const branchActive = isBranchActive(item, activePath);
|
||||
const soonLabel = `${t(item.label)} — ${t('nav.soon', 'Soon')}`;
|
||||
const badge = badgeLabel(item.badge);
|
||||
|
||||
const badgeNode = badge !== null && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle={item.badge === 'dot'}
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="sm"
|
||||
aria-label={
|
||||
item.badge === 'dot'
|
||||
? t('nav.pending', 'Items pending')
|
||||
: t('nav.pendingCount', { count: Number(item.badge), defaultValue: '{{count}} pending' })
|
||||
}
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const rightSection = item.soon ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{t('nav.soon', 'Soon')}
|
||||
</Badge>
|
||||
) : (
|
||||
badgeNode || undefined
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
const trigger = (
|
||||
<UnstyledButton
|
||||
onClick={() => !hasChildren && onNavigate(item)}
|
||||
aria-label={t(item.label)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: rem(40),
|
||||
borderRadius: rem(10),
|
||||
opacity: item.soon ? 0.55 : 1,
|
||||
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
|
||||
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={20} stroke={1.6} />
|
||||
{badge !== null && (
|
||||
<Badge
|
||||
size="xs"
|
||||
circle
|
||||
variant="filled"
|
||||
color="red"
|
||||
style={{ position: 'absolute', top: rem(6), right: rem(10) }}
|
||||
/>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
// Children would be unreachable behind an icon, so open them in a flyout.
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Popover position="right-start" withArrow shadow="md" trapFocus>
|
||||
<Popover.Target>{trigger}</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
<Text size="xs" fw={700} c="dimmed" px="xs" py={4}>
|
||||
{t(item.label)}
|
||||
</Text>
|
||||
{item.children?.map((child) => (
|
||||
<NavLink
|
||||
key={child.label}
|
||||
active={isItemActive(child, activePath)}
|
||||
label={t(child.label)}
|
||||
leftSection={<child.icon size={17} stroke={1.6} />}
|
||||
onClick={() => onNavigate(child)}
|
||||
variant="light"
|
||||
styles={{ root: { borderRadius: rem(8) } }}
|
||||
/>
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={itemTooltip(item, soonLabel) ?? t(item.label)}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
{trigger}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
active={hasChildren ? branchActive && !active : active}
|
||||
label={t(item.label)}
|
||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||
rightSection={rightSection}
|
||||
// Auto-expands the group the user is currently inside.
|
||||
defaultOpened={hasChildren ? branchActive : undefined}
|
||||
onClick={() => !hasChildren && onNavigate(item)}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
||||
label: { fontWeight: 500 },
|
||||
}}
|
||||
>
|
||||
{hasChildren
|
||||
? item.children?.map((child) => (
|
||||
<NavLink
|
||||
key={child.label}
|
||||
active={isItemActive(child, activePath)}
|
||||
label={t(child.label)}
|
||||
leftSection={<child.icon size={17} stroke={1.6} />}
|
||||
rightSection={
|
||||
child.soon ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{t('nav.soon', 'Soon')}
|
||||
</Badge>
|
||||
) : (
|
||||
badgeLabel(child.badge) !== null && (
|
||||
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||
{badgeLabel(child.badge)}
|
||||
</Badge>
|
||||
)
|
||||
) || undefined
|
||||
}
|
||||
onClick={() => onNavigate(child)}
|
||||
variant="light"
|
||||
styles={{ root: { borderRadius: rem(8) }, label: { fontWeight: 500 } }}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
@@ -51,6 +230,12 @@ interface AppSidebarProps {
|
||||
onNavigate: (item: NavItem) => void;
|
||||
brandName: string;
|
||||
brandSubtitle: string;
|
||||
/**
|
||||
* Rendered in the brand header. Passed in rather than imported so this
|
||||
* library stays free of a dependency on `@ema-platform/auth`, which depends
|
||||
* on it — the two formed an import cycle.
|
||||
*/
|
||||
brandLogo?: ReactNode;
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
@@ -61,13 +246,10 @@ export function AppSidebar({
|
||||
onNavigate,
|
||||
brandName,
|
||||
brandSubtitle,
|
||||
brandLogo,
|
||||
}: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const activeNavItem = (item: NavItem) =>
|
||||
!!item.to &&
|
||||
(activePath === item.to || activePath.startsWith(`${item.to}/`));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Brand header */}
|
||||
@@ -83,7 +265,7 @@ export function AppSidebar({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BrandMark size={32} />
|
||||
{brandLogo}
|
||||
{!collapsed && (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
@@ -141,61 +323,15 @@ export function AppSidebar({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{section.items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const active = activeNavItem(item);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip
|
||||
{section.items.map((item) => (
|
||||
<SidebarItem
|
||||
key={item.label}
|
||||
label={item.soon ? `${t(item.label)} — ${t('nav.soon', 'Soon')}` : t(item.label)}
|
||||
position="right"
|
||||
withArrow
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => onNavigate(item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: rem(40),
|
||||
borderRadius: rem(10),
|
||||
opacity: item.soon ? 0.55 : 1,
|
||||
color: active ? 'var(--mantine-color-blue-6)' : undefined,
|
||||
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={20} stroke={1.6} />
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
active={active}
|
||||
label={t(item.label)}
|
||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||
// Tells a reviewer at a glance which screens are wired up.
|
||||
rightSection={
|
||||
item.soon ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{t('nav.soon', 'Soon')}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onNavigate(item)}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
|
||||
label: { fontWeight: 500 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
activePath={activePath}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
187
libs/ui/src/lib/layout/AppTopNav.tsx
Normal file
187
libs/ui/src/lib/layout/AppTopNav.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NavItem } from './AppSidebar';
|
||||
import {
|
||||
badgeLabel,
|
||||
isBranchActive,
|
||||
isItemActive,
|
||||
toSections,
|
||||
type NavEntries,
|
||||
} from './nav-utils';
|
||||
|
||||
interface AppTopNavProps {
|
||||
navItems: NavEntries;
|
||||
activePath: string;
|
||||
onNavigate: (item: NavItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal navigation for the top-bar layout.
|
||||
*
|
||||
* Every destination used to render as a sibling button in one horizontally
|
||||
* scrolling strip, so with twenty-odd of them most were off-screen and the
|
||||
* grouping that the sidebar already had was thrown away. Here each section
|
||||
* collapses to a single labelled dropdown, which fits and keeps the same
|
||||
* information architecture as the sidebar.
|
||||
*/
|
||||
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const sections = toSections(navItems);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={rem(2)}
|
||||
h="100%"
|
||||
wrap="nowrap"
|
||||
role="navigation"
|
||||
aria-label={t('nav.primary', 'Primary')}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
|
||||
if (!section.label) {
|
||||
return section.items.map((item) => (
|
||||
<TopNavButton
|
||||
key={item.label}
|
||||
label={t(item.label)}
|
||||
active={isBranchActive(item, activePath)}
|
||||
badge={badgeLabel(item.badge)}
|
||||
soon={item.soon}
|
||||
onClick={() => onNavigate(item)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const sectionActive = section.items.some((item) =>
|
||||
isBranchActive(item, activePath),
|
||||
);
|
||||
const pending = section.items.reduce((sum, item) => {
|
||||
const own = typeof item.badge === 'number' ? item.badge : 0;
|
||||
const nested = (item.children ?? []).reduce(
|
||||
(n, child) => n + (typeof child.badge === 'number' ? child.badge : 0),
|
||||
0,
|
||||
);
|
||||
return sum + own + nested;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
key={section.label ?? `section-${index}`}
|
||||
trigger="click-hover"
|
||||
openDelay={80}
|
||||
closeDelay={140}
|
||||
position="bottom-start"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
>
|
||||
<Menu.Target>
|
||||
<TopNavButton
|
||||
label={t(section.label)}
|
||||
active={sectionActive}
|
||||
badge={badgeLabel(pending)}
|
||||
withChevron
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{section.items.map((item) =>
|
||||
item.children?.length ? (
|
||||
<Menu.Sub key={item.label} position="right-start">
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<item.icon size={16} stroke={1.6} />}>
|
||||
{t(item.label)}
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
{item.children.map((child) => (
|
||||
<Menu.Item
|
||||
key={child.label}
|
||||
leftSection={<child.icon size={16} stroke={1.6} />}
|
||||
onClick={() => onNavigate(child)}
|
||||
disabled={child.soon}
|
||||
>
|
||||
{t(child.label)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key={item.label}
|
||||
leftSection={<item.icon size={16} stroke={1.6} />}
|
||||
rightSection={
|
||||
item.soon ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{t('nav.soon', 'Soon')}
|
||||
</Badge>
|
||||
) : (
|
||||
badgeLabel(item.badge) !== null && (
|
||||
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||
{badgeLabel(item.badge)}
|
||||
</Badge>
|
||||
)
|
||||
) || undefined
|
||||
}
|
||||
onClick={() => onNavigate(item)}
|
||||
// `soon` screens have no backend; the Menu.Item's own
|
||||
// disabled styling plus the badge explains why.
|
||||
disabled={item.soon}
|
||||
aria-current={isItemActive(item, activePath) ? 'page' : undefined}
|
||||
>
|
||||
{t(item.label)}
|
||||
</Menu.Item>
|
||||
),
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface TopNavButtonProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
badge?: string | null;
|
||||
soon?: boolean;
|
||||
withChevron?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function TopNavButton({
|
||||
label,
|
||||
active,
|
||||
badge,
|
||||
soon,
|
||||
withChevron,
|
||||
onClick,
|
||||
}: TopNavButtonProps) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(6),
|
||||
padding: `0 ${rem(14)}`,
|
||||
height: '100%',
|
||||
borderBottom: '2px solid',
|
||||
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
|
||||
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: rem(14),
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: soon ? 0.55 : 1,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{badge !== null && badge !== undefined && (
|
||||
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{withChevron && <IconChevronDown size={14} stroke={2} />}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
79
libs/ui/src/lib/layout/nav-utils.ts
Normal file
79
libs/ui/src/lib/layout/nav-utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { NavItem, NavSection } from './AppSidebar';
|
||||
|
||||
/** Both shapes are accepted so callers can migrate to sections gradually. */
|
||||
export type NavEntries = NavItem[] | NavSection[];
|
||||
|
||||
export function toSections(entries: NavEntries): NavSection[] {
|
||||
if (entries.length === 0) return [];
|
||||
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
|
||||
return isSectioned
|
||||
? (entries as NavSection[])
|
||||
: [{ items: entries as NavItem[] }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a nav entry points at the current route.
|
||||
*
|
||||
* Prefix-matches on a path boundary so `/licensing` lights up for
|
||||
* `/licensing/FREIGHT_FORWARDER` but not for an unrelated `/licensing-report`.
|
||||
*/
|
||||
export function isItemActive(item: NavItem, activePath: string): boolean {
|
||||
if (!item.to) return false;
|
||||
return activePath === item.to || activePath.startsWith(`${item.to}/`);
|
||||
}
|
||||
|
||||
/** True when the item, or anything nested under it, matches the route. */
|
||||
export function isBranchActive(item: NavItem, activePath: string): boolean {
|
||||
if (isItemActive(item, activePath)) return true;
|
||||
return (item.children ?? []).some((child) => isItemActive(child, activePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes entries the user may not see.
|
||||
*
|
||||
* An item with no `permissions` is visible to everyone. A parent survives if
|
||||
* it is itself permitted and at least one child is — a disclosure that opens
|
||||
* onto nothing is worse than no disclosure at all. Sections left empty are
|
||||
* dropped so their heading does not hang over a gap.
|
||||
*/
|
||||
export function filterByPermissions(
|
||||
sections: NavSection[],
|
||||
granted: readonly string[],
|
||||
): NavSection[] {
|
||||
const permitted = new Set(granted);
|
||||
const allows = (item: NavItem) =>
|
||||
!item.permissions?.length ||
|
||||
item.permissions.some((permission) => permitted.has(permission));
|
||||
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items
|
||||
.filter(allows)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: item.children.filter(allows) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => !item.children || item.children.length > 0),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
/** Flattens parents and children into one list, for search and breadcrumbs. */
|
||||
export function flattenNav(sections: NavSection[]): NavItem[] {
|
||||
return sections.flatMap((section) =>
|
||||
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Badge text, or null when there is nothing worth showing. */
|
||||
export function badgeLabel(badge: NavItem['badge']): string | null {
|
||||
if (badge === 'dot') return '';
|
||||
if (typeof badge === 'number' && badge > 0) {
|
||||
// Three digits of pending work is already "a lot"; the exact number
|
||||
// stops being actionable and starts breaking the layout.
|
||||
return badge > 99 ? '99+' : String(badge);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user