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 {
|
||||
AppShell,
|
||||
Badge,
|
||||
Group,
|
||||
NavLink,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -66,6 +68,10 @@ interface SidebarItemProps {
|
||||
collapsed: boolean;
|
||||
activePath: string;
|
||||
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
|
||||
* children.
|
||||
*/
|
||||
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
|
||||
function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle }: SidebarItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const ItemIcon = item.icon;
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
@@ -103,10 +109,29 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
|
||||
</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">
|
||||
{t('nav.soon', 'Soon')}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const rightSection = hasChildren ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{item.soon ? soonBadge : badgeNode}
|
||||
{chevron}
|
||||
</Group>
|
||||
) : item.soon ? (
|
||||
soonBadge
|
||||
) : (
|
||||
badgeNode || undefined
|
||||
);
|
||||
@@ -185,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
|
||||
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}
|
||||
disableRightSectionRotation
|
||||
// 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)}
|
||||
variant="light"
|
||||
styles={{
|
||||
@@ -259,6 +287,48 @@ export function AppSidebar({
|
||||
? 'var(--mantine-color-gray-3)'
|
||||
: '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 (
|
||||
<>
|
||||
{/* Brand header */}
|
||||
@@ -308,20 +378,39 @@ export function AppSidebar({
|
||||
{/* Navigation items */}
|
||||
<AppShell.Section grow component={ScrollArea} p="md">
|
||||
<Stack gap={2}>
|
||||
{toSections(navItems).map((section, sectionIndex) => (
|
||||
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const sectionKey = section.label ?? `section-${sectionIndex}`;
|
||||
const sectionOpened = openMap[sectionKey] ?? true;
|
||||
return (
|
||||
<Stack gap={2} key={sectionKey}>
|
||||
{/* Headings are noise when only icons are visible. */}
|
||||
{section.label && !collapsed && (
|
||||
<UnstyledButton
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
aria-expanded={sectionOpened}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: `0 ${rem(12)}`,
|
||||
marginTop: sectionIndex === 0 ? 0 : rem(14),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
c="dimmed"
|
||||
mt={sectionIndex === 0 ? 0 : rem(14)}
|
||||
pl={rem(12)}
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
|
||||
>
|
||||
{t(section.label)}
|
||||
</Text>
|
||||
{sectionOpened ? (
|
||||
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||
) : (
|
||||
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
{section.label && collapsed && sectionIndex > 0 && (
|
||||
<div
|
||||
@@ -332,17 +421,21 @@ export function AppSidebar({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{section.items.map((item) => (
|
||||
{(!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>
|
||||
</AppShell.Section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user