mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
UI
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { Fragment } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
Badge,
|
Badge,
|
||||||
|
Group,
|
||||||
NavLink,
|
NavLink,
|
||||||
Popover,
|
Popover,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useMantineColorScheme,
|
useMantineColorScheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
IconChevronDown,
|
||||||
IconChevronLeft,
|
IconChevronLeft,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
@@ -66,6 +68,10 @@ interface SidebarItemProps {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
activePath: string;
|
activePath: string;
|
||||||
onNavigate: (item: NavItem) => void;
|
onNavigate: (item: NavItem) => void;
|
||||||
|
/** Whether this item's children are expanded. Ignored when it has none. */
|
||||||
|
opened: boolean;
|
||||||
|
/** Called with the new expanded state when the header is toggled. */
|
||||||
|
onToggle: (opened: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +83,7 @@ interface SidebarItemProps {
|
|||||||
* nest, so a parent becomes a hover flyout instead of silently losing its
|
* nest, so a parent becomes a hover flyout instead of silently losing its
|
||||||
* children.
|
* children.
|
||||||
*/
|
*/
|
||||||
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
|
function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle }: SidebarItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
const hasChildren = Boolean(item.children?.length);
|
const hasChildren = Boolean(item.children?.length);
|
||||||
@@ -103,10 +109,29 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
||||||
const rightSection = item.soon ? (
|
// Parent headers always carry a chevron so the expand/collapse state is
|
||||||
|
// never ambiguous, even when a badge is also present.
|
||||||
|
const chevron = hasChildren ? (
|
||||||
|
opened ? (
|
||||||
|
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||||
|
) : (
|
||||||
|
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const soonBadge = (
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
{t('nav.soon', 'Soon')}
|
{t('nav.soon', 'Soon')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightSection = hasChildren ? (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{item.soon ? soonBadge : badgeNode}
|
||||||
|
{chevron}
|
||||||
|
</Group>
|
||||||
|
) : item.soon ? (
|
||||||
|
soonBadge
|
||||||
) : (
|
) : (
|
||||||
badgeNode || undefined
|
badgeNode || undefined
|
||||||
);
|
);
|
||||||
@@ -185,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
|
|||||||
label={t(item.label)}
|
label={t(item.label)}
|
||||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||||
rightSection={rightSection}
|
rightSection={rightSection}
|
||||||
// Auto-expands the group the user is currently inside.
|
disableRightSectionRotation
|
||||||
defaultOpened={hasChildren ? branchActive : undefined}
|
// Controlled so the group re-opens if the active route moves inside it
|
||||||
|
// later (see the auto-expand effect in AppSidebar), not just on mount.
|
||||||
|
opened={hasChildren ? opened : undefined}
|
||||||
|
onChange={hasChildren ? onToggle : undefined}
|
||||||
onClick={() => !hasChildren && onNavigate(item)}
|
onClick={() => !hasChildren && onNavigate(item)}
|
||||||
variant="light"
|
variant="light"
|
||||||
styles={{
|
styles={{
|
||||||
@@ -259,6 +287,48 @@ export function AppSidebar({
|
|||||||
? 'var(--mantine-color-gray-3)'
|
? 'var(--mantine-color-gray-3)'
|
||||||
: 'var(--mantine-color-gray-7)';
|
: 'var(--mantine-color-gray-7)';
|
||||||
|
|
||||||
|
const sections = useMemo(() => toSections(navItems), [navItems]);
|
||||||
|
|
||||||
|
// Expand/collapse state per collapsible header, keyed by section label for
|
||||||
|
// NavSection headings and by item label for a parent item's children.
|
||||||
|
// Sections default open (today every section is always visible); a nested
|
||||||
|
// item's children default to open only when the active route is inside it,
|
||||||
|
// matching the previous `defaultOpened` behaviour.
|
||||||
|
const [openMap, setOpenMap] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// If the active route moves into a header the user had collapsed, expand
|
||||||
|
// it back open so the highlighted item stays visible. Never collapses
|
||||||
|
// anything — that stays purely a manual, per-header action.
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenMap((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...prev };
|
||||||
|
sections.forEach((section, sectionIndex) => {
|
||||||
|
const sectionKey = section.label ?? `section-${sectionIndex}`;
|
||||||
|
if (
|
||||||
|
section.label &&
|
||||||
|
next[sectionKey] !== true &&
|
||||||
|
section.items.some((item) => isBranchActive(item, activePath))
|
||||||
|
) {
|
||||||
|
next[sectionKey] = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
section.items.forEach((item) => {
|
||||||
|
if (item.children?.length && next[item.label] !== true && isBranchActive(item, activePath)) {
|
||||||
|
next[item.label] = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [activePath, sections]);
|
||||||
|
|
||||||
|
const toggleSection = (key: string) =>
|
||||||
|
setOpenMap((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
|
||||||
|
const setItemOpened = (key: string, next: boolean) =>
|
||||||
|
setOpenMap((prev) => ({ ...prev, [key]: next }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Brand header */}
|
{/* Brand header */}
|
||||||
@@ -308,41 +378,64 @@ export function AppSidebar({
|
|||||||
{/* Navigation items */}
|
{/* Navigation items */}
|
||||||
<AppShell.Section grow component={ScrollArea} p="md">
|
<AppShell.Section grow component={ScrollArea} p="md">
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
{toSections(navItems).map((section, sectionIndex) => (
|
{sections.map((section, sectionIndex) => {
|
||||||
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
|
const sectionKey = section.label ?? `section-${sectionIndex}`;
|
||||||
{/* Headings are noise when only icons are visible. */}
|
const sectionOpened = openMap[sectionKey] ?? true;
|
||||||
{section.label && !collapsed && (
|
return (
|
||||||
<Text
|
<Stack gap={2} key={sectionKey}>
|
||||||
size="xs"
|
{/* Headings are noise when only icons are visible. */}
|
||||||
fw={700}
|
{section.label && !collapsed && (
|
||||||
c="dimmed"
|
<UnstyledButton
|
||||||
mt={sectionIndex === 0 ? 0 : rem(14)}
|
onClick={() => toggleSection(sectionKey)}
|
||||||
pl={rem(12)}
|
aria-expanded={sectionOpened}
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
style={{
|
||||||
>
|
display: 'flex',
|
||||||
{t(section.label)}
|
alignItems: 'center',
|
||||||
</Text>
|
justifyContent: 'space-between',
|
||||||
)}
|
width: '100%',
|
||||||
{section.label && collapsed && sectionIndex > 0 && (
|
padding: `0 ${rem(12)}`,
|
||||||
<div
|
marginTop: sectionIndex === 0 ? 0 : rem(14),
|
||||||
style={{
|
}}
|
||||||
height: 1,
|
>
|
||||||
margin: `${rem(8)} ${rem(6)}`,
|
<Text
|
||||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
size="xs"
|
||||||
}}
|
fw={700}
|
||||||
/>
|
c="dimmed"
|
||||||
)}
|
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||||
{section.items.map((item) => (
|
>
|
||||||
<SidebarItem
|
{t(section.label)}
|
||||||
key={item.label}
|
</Text>
|
||||||
item={item}
|
{sectionOpened ? (
|
||||||
collapsed={collapsed}
|
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||||
activePath={activePath}
|
) : (
|
||||||
onNavigate={onNavigate}
|
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||||
/>
|
)}
|
||||||
))}
|
</UnstyledButton>
|
||||||
</Stack>
|
)}
|
||||||
))}
|
{section.label && collapsed && sectionIndex > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
margin: `${rem(8)} ${rem(6)}`,
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(!section.label || sectionOpened || collapsed) &&
|
||||||
|
section.items.map((item) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
collapsed={collapsed}
|
||||||
|
activePath={activePath}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
opened={openMap[item.label] ?? isBranchActive(item, activePath)}
|
||||||
|
onToggle={(next) => setItemOpened(item.label, next)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user