mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(ui): add Classic, Smart, and Compact desktop navigation styles (#1642)
* feat(ui): add Classic, Smart, and Compact desktop navigation styles Introduce a shared app-nav registry and reachable model so TopBar, the command palette, and mobile menus share one destination source. Smart bar is the default; Appearance gains a Navigation subsection with quick links. * fix(e2e): stop clearing top-nav prefs on every reload The desktop-navigation suite used addInitScript to wipe mode storage, which re-ran on reload and undid the classic/compact values under test. * fix(ui): harden nav PR docs and Compact quick-link coverage Drop the partial docs-refresh import that left missing image assets and a stale Display screenshot, keep navigation-scoped operator docs against main, and add an E2E path that proves Compact quick-link add, persist, and render after reload. * fix(ui): polish Compact quick links and menu mastheads Align Smart/Compact menus with Theme chrome, keep pin labels always visible, and drive add capacity from persisted pins (max five) with a trailing + picker and per-pin remove. * test(e2e): exact-match Compact Networking pin locator Avoid Playwright strict-mode clash with Actions for Networking. * fix(ui): simplify Compact quick link removal and fix + button trailing Remove the (...) dropdown per quick link in Compact mode. Right-click context menu remains as the sole on-bar removal affordance. Fix the + add-button to trail quick links rather than pinning to the far right by removing flex-1 from the quick-link rail.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Plus, Loader2, ChevronLeft, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
@@ -44,6 +44,9 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { useTopNavQuickLinks } from '@/hooks/use-top-nav-quick-links';
|
||||
import { getAppNavItem } from '@/lib/navigation/appNavRegistry';
|
||||
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
@@ -195,6 +198,8 @@ export default function EditorLayout() {
|
||||
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const [topNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign] = useTopNavAlign();
|
||||
const [topNavMode] = useTopNavMode();
|
||||
const { persistedIds: quickLinkIds, addQuickLink, removeQuickLink } = useTopNavQuickLinks();
|
||||
|
||||
// Use a ref to break the circular dependency:
|
||||
// useViewNavigationState needs onNavigateToDashboard -> resetEditorState
|
||||
@@ -220,10 +225,22 @@ export default function EditorLayout() {
|
||||
handleMutePrefillConsumed,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
navModel,
|
||||
openMuteRulesWithPrefill,
|
||||
reachCtx,
|
||||
} = navState;
|
||||
|
||||
const visibleQuickLinks = useMemo(() => {
|
||||
const candidateSet = new Set(navModel.quickLinkCandidates.map((item) => item.value));
|
||||
return quickLinkIds
|
||||
.filter((id) => candidateSet.has(id))
|
||||
.map((id) => {
|
||||
const item = getAppNavItem(id);
|
||||
return item ? { value: item.value, label: item.label, icon: item.icon } : null;
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}, [quickLinkIds, navModel.quickLinkCandidates]);
|
||||
|
||||
const {
|
||||
notifications,
|
||||
tickerConnected,
|
||||
@@ -908,6 +925,13 @@ export default function EditorLayout() {
|
||||
userMenu={userMenuEl}
|
||||
showLabels={topNavLabels}
|
||||
navAlign={topNavAlign}
|
||||
navMode={topNavMode}
|
||||
navModel={navModel}
|
||||
quickLinks={visibleQuickLinks}
|
||||
persistedQuickLinkIds={quickLinkIds}
|
||||
onAddQuickLink={(value) => addQuickLink(value as typeof quickLinkIds[number])}
|
||||
onRemoveQuickLink={(value) => removeQuickLink(value as typeof quickLinkIds[number])}
|
||||
onOpenSettings={() => openSettings()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -962,6 +986,7 @@ export default function EditorLayout() {
|
||||
stackUpdates={stackUpdates}
|
||||
urlHydratingStack={urlHydratingStack}
|
||||
isFileLoading={isFileLoading}
|
||||
quickLinkCandidates={navModel.quickLinkCandidates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1033,6 +1058,7 @@ export default function EditorLayout() {
|
||||
headerActions={mobileMastheadActions}
|
||||
selectedSection={mobileSettingsSection}
|
||||
onSelectedSectionChange={setMobileSettingsSection}
|
||||
quickLinkCandidates={navModel.quickLinkCandidates}
|
||||
/>
|
||||
);
|
||||
case 'security':
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
// CapabilityGate wrappers render
|
||||
@@ -109,6 +110,7 @@ export interface ViewRouterProps {
|
||||
stackUpdates: Record<string, StackUpdateInfo>;
|
||||
urlHydratingStack: string | null;
|
||||
isFileLoading: boolean;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
export function ViewRouter({
|
||||
@@ -142,6 +144,7 @@ export function ViewRouter({
|
||||
stackUpdates,
|
||||
urlHydratingStack,
|
||||
isFileLoading,
|
||||
quickLinkCandidates,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
@@ -153,6 +156,7 @@ export function ViewRouter({
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Terminal, CloudDownload, Home, HardDrive, ScrollText,
|
||||
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -18,20 +13,18 @@ import { HUB_ONLY_VIEWS } from '@/lib/router/routeTypes';
|
||||
import { readUrlRouteState } from '@/lib/router/readUrlRouteState';
|
||||
import {
|
||||
authzReady,
|
||||
isViewHidden,
|
||||
normalizeHiddenView,
|
||||
type ReachabilityContext,
|
||||
} from '@/lib/routing/reachability';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import { buildNavigationModel } from '@/lib/navigation/buildNavigationModel';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
export type { ActiveView };
|
||||
export { HUB_ONLY_VIEWS };
|
||||
|
||||
export interface NavItem {
|
||||
value: ActiveView;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
/** @deprecated Prefer NavDestination from appNavRegistry; alias kept for mobile/palette imports. */
|
||||
export type NavItem = NavDestination;
|
||||
|
||||
interface UseViewNavigationStateOptions {
|
||||
onNavigateToDashboard?: () => void;
|
||||
@@ -124,39 +117,8 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const navItems = useMemo((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||
];
|
||||
if (!isViewHidden('fleet', reachCtx)) {
|
||||
items.push({ value: 'fleet', label: 'Fleet', icon: Radar });
|
||||
}
|
||||
items.push(
|
||||
{ value: 'resources', label: 'Resources', icon: HardDrive },
|
||||
{ value: 'networking', label: 'Networking', icon: Network },
|
||||
{ value: 'security', label: 'Security', icon: ShieldCheck },
|
||||
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
||||
);
|
||||
if (!isViewHidden('global-observability', reachCtx)) {
|
||||
items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
|
||||
}
|
||||
if (!isViewHidden('auto-updates', reachCtx)) {
|
||||
items.push({ value: 'auto-updates', label: 'Update', icon: RefreshCw });
|
||||
}
|
||||
if (!isViewHidden('scheduled-ops', reachCtx)) {
|
||||
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||
}
|
||||
// Visual discovery fail-closed: omit Console until /meta settles and the
|
||||
// flag is on. URL normalization still waits on experimentalReady inside
|
||||
// isViewHidden so enabled deep links are not rewritten during cold load.
|
||||
if (experimentalReady && experimental && !isViewHidden('host-console', reachCtx)) {
|
||||
items.push({ value: 'host-console', label: 'Console', icon: Terminal });
|
||||
}
|
||||
if (!isViewHidden('audit-log', reachCtx)) {
|
||||
items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||
}
|
||||
return items;
|
||||
}, [reachCtx, experimentalReady, experimental]);
|
||||
const navModel = useMemo(() => buildNavigationModel(reachCtx), [reachCtx]);
|
||||
const navItems = navModel.allPageItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authzReady(reachCtx)) return;
|
||||
@@ -183,6 +145,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
openMuteRulesWithPrefill,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
navModel,
|
||||
reachCtx,
|
||||
} as const;
|
||||
}
|
||||
|
||||
+593
-140
@@ -1,161 +1,614 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Menu, MoreHorizontal, Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import type { TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import type { TopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
import type { NavGroupBucket, ReachableNavigationModel } from '@/lib/navigation/buildNavigationModel';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TopBarNavItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
value: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface TopBarProps {
|
||||
activeView: string;
|
||||
navItems: TopBarNavItem[];
|
||||
onNavigate: (value: string) => void;
|
||||
mobileNavOpen: boolean;
|
||||
onMobileNavOpenChange: (open: boolean) => void;
|
||||
search?: ReactNode;
|
||||
themeSwitch?: ReactNode;
|
||||
notifications: ReactNode;
|
||||
userMenu: ReactNode;
|
||||
/** Show text labels beside the desktop nav icons. When false, the bar is icon-only. */
|
||||
showLabels?: boolean;
|
||||
/** Desktop nav placement in icon-only mode. Ignored while labels are shown (always left). */
|
||||
navAlign?: TopNavAlign;
|
||||
activeView: string;
|
||||
/** Flat page destinations for Classic strip and the mobile sheet. */
|
||||
navItems: TopBarNavItem[];
|
||||
onNavigate: (value: string) => void;
|
||||
mobileNavOpen: boolean;
|
||||
onMobileNavOpenChange: (open: boolean) => void;
|
||||
search?: ReactNode;
|
||||
themeSwitch?: ReactNode;
|
||||
notifications: ReactNode;
|
||||
userMenu: ReactNode;
|
||||
showLabels?: boolean;
|
||||
navAlign?: TopNavAlign;
|
||||
navMode?: TopNavMode;
|
||||
navModel?: ReachableNavigationModel;
|
||||
/** Visible (reachable) quick links for Compact mode. */
|
||||
quickLinks?: NavDestination[];
|
||||
/** Persisted pin IDs (including temporarily unreachable). Capacity is length. */
|
||||
persistedQuickLinkIds?: readonly string[];
|
||||
onAddQuickLink?: (value: string) => void;
|
||||
onRemoveQuickLink?: (value: string) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
const navButtonClass = (isActive: boolean) =>
|
||||
cn(
|
||||
'relative inline-flex h-full shrink-0 items-center gap-2 px-4',
|
||||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
);
|
||||
|
||||
function ActiveUnderline({ active }: { active: boolean }) {
|
||||
if (!active) return null;
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBarMenuMasthead({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
|
||||
<div className="relative flex items-center px-[var(--density-row-x)] py-[var(--density-tile-y)]">
|
||||
<span className="font-heading text-xl leading-none text-stat-value">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelMenuContent({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56 overflow-hidden rounded-md p-0">
|
||||
<TopBarMenuMasthead title={title} />
|
||||
<div className="border-t border-card-border/60 p-1">{children}</div>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopNavButton({
|
||||
item,
|
||||
isActive,
|
||||
showLabels,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: TopBarNavItem;
|
||||
isActive: boolean;
|
||||
showLabels: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.value)}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={navButtonClass(isActive)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
{showLabels && <span className="hidden xl:inline">{item.label}</span>}
|
||||
<ActiveUnderline active={isActive} />
|
||||
</button>
|
||||
);
|
||||
if (showLabels) return <Fragment>{button}</Fragment>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedMenuItems({
|
||||
groups,
|
||||
activeView,
|
||||
onSelect,
|
||||
onAddQuickLink,
|
||||
persistedIds,
|
||||
atCapacity,
|
||||
}: {
|
||||
groups: NavGroupBucket[];
|
||||
activeView: string;
|
||||
onSelect: (value: string) => void;
|
||||
/** When set, Compact launcher rows get a context Add action. */
|
||||
onAddQuickLink?: (value: string) => void;
|
||||
persistedIds?: ReadonlySet<string>;
|
||||
atCapacity?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{groups.map((group, index) => (
|
||||
<Fragment key={group.group}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
{group.label}
|
||||
</DropdownMenuLabel>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeView === item.value;
|
||||
const canContextAdd =
|
||||
Boolean(onAddQuickLink)
|
||||
&& item.value !== 'settings'
|
||||
&& !persistedIds?.has(item.value)
|
||||
&& !atCapacity;
|
||||
|
||||
const menuItem = (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onSelect={() => onSelect(item.value)}
|
||||
data-active={isActive ? 'true' : undefined}
|
||||
className={cn(
|
||||
'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]',
|
||||
isActive && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
if (!canContextAdd) return menuItem;
|
||||
|
||||
return (
|
||||
<ContextMenu key={item.value}>
|
||||
<ContextMenuTrigger asChild>{menuItem}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onAddQuickLink?.(item.value)}
|
||||
>
|
||||
Add to quick links
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicStrip({
|
||||
navItems,
|
||||
activeView,
|
||||
showLabels,
|
||||
onNavigate,
|
||||
}: {
|
||||
navItems: TopBarNavItem[];
|
||||
activeView: string;
|
||||
showLabels: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{navItems.map((item) => (
|
||||
<DesktopNavButton
|
||||
key={item.value}
|
||||
item={item}
|
||||
isActive={activeView === item.value}
|
||||
showLabels={showLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SmartStrip({
|
||||
primaryItems,
|
||||
overflowGroups,
|
||||
activeView,
|
||||
showLabels,
|
||||
onNavigate,
|
||||
}: {
|
||||
primaryItems: TopBarNavItem[];
|
||||
overflowGroups: NavGroupBucket[];
|
||||
activeView: string;
|
||||
showLabels: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
}) {
|
||||
const overflowValues = useMemo(
|
||||
() => new Set<string>(overflowGroups.flatMap((g) => g.items.map((i) => i.value))),
|
||||
[overflowGroups],
|
||||
);
|
||||
const moreActive = overflowValues.has(activeView);
|
||||
const hasOverflow = overflowGroups.some((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{primaryItems.map((item) => (
|
||||
<DesktopNavButton
|
||||
key={item.value}
|
||||
item={item}
|
||||
isActive={activeView === item.value}
|
||||
showLabels={showLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{hasOverflow && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More navigation"
|
||||
aria-current={moreActive ? 'page' : undefined}
|
||||
className={navButtonClass(moreActive)}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
<span>More</span>
|
||||
<ActiveUnderline active={moreActive} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="More">
|
||||
<GroupedMenuItems
|
||||
groups={overflowGroups}
|
||||
activeView={activeView}
|
||||
onSelect={onNavigate}
|
||||
/>
|
||||
</PanelMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactQuickLink({
|
||||
item,
|
||||
isActive,
|
||||
onNavigate,
|
||||
onRemove,
|
||||
}: {
|
||||
item: NavDestination;
|
||||
isActive: boolean;
|
||||
onNavigate: (value: string) => void;
|
||||
onRemove: (value: string) => void;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(item.value)}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn('relative inline-flex h-full shrink-0 items-stretch', navButtonClass(isActive))}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="inline">{item.label}</span>
|
||||
<ActiveUnderline active={isActive} />
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onRemove(item.value)}>Remove</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactStrip({
|
||||
launcherGroups,
|
||||
quickLinks,
|
||||
quickLinkCandidates,
|
||||
persistedQuickLinkIds,
|
||||
activeView,
|
||||
onNavigate,
|
||||
onAddQuickLink,
|
||||
onRemoveQuickLink,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
launcherGroups: NavGroupBucket[];
|
||||
quickLinks: NavDestination[];
|
||||
quickLinkCandidates: NavDestination[];
|
||||
persistedQuickLinkIds: readonly string[];
|
||||
activeView: string;
|
||||
onNavigate: (value: string) => void;
|
||||
onAddQuickLink?: (value: string) => void;
|
||||
onRemoveQuickLink?: (value: string) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
const launcherValues = useMemo(
|
||||
() => new Set<string>(launcherGroups.flatMap((g) => g.items.map((i) => i.value))),
|
||||
[launcherGroups],
|
||||
);
|
||||
const quickValues = useMemo(
|
||||
() => new Set<string>(quickLinks.map((i) => i.value)),
|
||||
[quickLinks],
|
||||
);
|
||||
const persistedSet = useMemo(
|
||||
() => new Set<string>(persistedQuickLinkIds),
|
||||
[persistedQuickLinkIds],
|
||||
);
|
||||
const atCapacity = persistedQuickLinkIds.length >= MAX_QUICK_LINKS;
|
||||
const unpinnedCandidates = useMemo(
|
||||
() => quickLinkCandidates.filter((item) => !persistedSet.has(item.value)),
|
||||
[quickLinkCandidates, persistedSet],
|
||||
);
|
||||
const addEnabled = !atCapacity && unpinnedCandidates.length > 0;
|
||||
const addDisabledReason = atCapacity
|
||||
? 'Remove a quick link to free a slot'
|
||||
: 'No more destinations available';
|
||||
|
||||
const launcherActive =
|
||||
launcherValues.has(activeView) && !quickValues.has(activeView);
|
||||
|
||||
const selectDestination = (value: string) => {
|
||||
if (value === 'settings') {
|
||||
onOpenSettings?.();
|
||||
return;
|
||||
}
|
||||
onNavigate(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 self-stretch items-stretch">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation launcher"
|
||||
aria-current={launcherActive ? 'page' : undefined}
|
||||
data-sn-launcher-active={launcherActive ? 'true' : 'false'}
|
||||
className={navButtonClass(launcherActive)}
|
||||
>
|
||||
<Menu className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
<ActiveUnderline active={launcherActive} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="Navigate">
|
||||
<GroupedMenuItems
|
||||
groups={launcherGroups}
|
||||
activeView={activeView}
|
||||
onSelect={selectDestination}
|
||||
onAddQuickLink={onAddQuickLink}
|
||||
persistedIds={persistedSet}
|
||||
atCapacity={atCapacity}
|
||||
/>
|
||||
</PanelMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div
|
||||
data-sn-quick-link-rail
|
||||
className="flex min-w-0 self-stretch items-stretch overflow-x-auto [scrollbar-width:none]"
|
||||
>
|
||||
{quickLinks.map((item) => (
|
||||
<CompactQuickLink
|
||||
key={item.value}
|
||||
item={item}
|
||||
isActive={activeView === item.value}
|
||||
onNavigate={onNavigate}
|
||||
onRemove={(value) => onRemoveQuickLink?.(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addEnabled ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add quick link"
|
||||
className={navButtonClass(false)}
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<PanelMenuContent title="Add quick link">
|
||||
{unpinnedCandidates.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isCurrent = item.value === activeView;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onSelect={() => onAddQuickLink?.(item.value)}
|
||||
className={cn(
|
||||
'gap-2 font-mono text-[11px] uppercase tracking-[0.14em]',
|
||||
isCurrent && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</PanelMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="inline-flex h-full shrink-0 items-stretch"
|
||||
title={addDisabledReason}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add quick link"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
className={cn(navButtonClass(false), 'pointer-events-none opacity-40')}
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{addDisabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
activeView,
|
||||
navItems,
|
||||
onNavigate,
|
||||
mobileNavOpen,
|
||||
onMobileNavOpenChange,
|
||||
search,
|
||||
themeSwitch,
|
||||
notifications,
|
||||
userMenu,
|
||||
showLabels = true,
|
||||
navAlign = 'left',
|
||||
activeView,
|
||||
navItems,
|
||||
onNavigate,
|
||||
mobileNavOpen,
|
||||
onMobileNavOpenChange,
|
||||
search,
|
||||
themeSwitch,
|
||||
notifications,
|
||||
userMenu,
|
||||
showLabels = true,
|
||||
navAlign = 'left',
|
||||
navMode = 'smart',
|
||||
navModel,
|
||||
quickLinks = [],
|
||||
persistedQuickLinkIds = [],
|
||||
onAddQuickLink,
|
||||
onRemoveQuickLink,
|
||||
onOpenSettings,
|
||||
}: TopBarProps) {
|
||||
// Centering applies only to the icon-only bar; with labels on the nav stays
|
||||
// left so the long labels read from the edge.
|
||||
const centered = !showLabels && navAlign === 'center';
|
||||
return (
|
||||
<div
|
||||
data-sn-chrome="topbar"
|
||||
className={cn(
|
||||
'relative flex h-14 items-center gap-3 px-4',
|
||||
'border-b border-glass-border bg-sidebar backdrop-blur-md',
|
||||
'shadow-chrome-top',
|
||||
)}
|
||||
const stripLabels = navMode !== 'compact' && showLabels;
|
||||
const centered = navMode !== 'compact' && !stripLabels && navAlign === 'center';
|
||||
|
||||
const primaryItems = navModel?.primaryItems ?? navItems;
|
||||
const overflowGroups = navModel?.overflowGroups ?? [];
|
||||
const launcherGroups = navModel?.launcherGroups ?? [];
|
||||
const quickLinkCandidates = navModel?.quickLinkCandidates ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sn-chrome="topbar"
|
||||
data-sn-nav-mode={navMode}
|
||||
className={cn(
|
||||
'relative flex h-14 items-center gap-3 px-4',
|
||||
'border-b border-glass-border bg-sidebar backdrop-blur-md',
|
||||
'shadow-chrome-top',
|
||||
)}
|
||||
>
|
||||
{centered && <div className="flex-1 min-w-0" />}
|
||||
|
||||
<TooltipProvider delayDuration={300} disableHoverableContent>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
'hidden md:flex self-stretch items-stretch',
|
||||
stripLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
|
||||
navMode === 'compact' && 'min-w-0 flex-1 overflow-x-visible',
|
||||
!stripLabels && centered && 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{/* LEFT SPACER: balances the right utilities so the nav centers. */}
|
||||
{centered && <div className="flex-1 min-w-0" />}
|
||||
{navMode === 'classic' && (
|
||||
<ClassicStrip
|
||||
navItems={navItems}
|
||||
activeView={activeView}
|
||||
showLabels={stripLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
{navMode === 'smart' && (
|
||||
<SmartStrip
|
||||
primaryItems={primaryItems}
|
||||
overflowGroups={overflowGroups}
|
||||
activeView={activeView}
|
||||
showLabels={stripLabels}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
{navMode === 'compact' && (
|
||||
<CompactStrip
|
||||
launcherGroups={launcherGroups}
|
||||
quickLinks={quickLinks}
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
persistedQuickLinkIds={persistedQuickLinkIds}
|
||||
activeView={activeView}
|
||||
onNavigate={onNavigate}
|
||||
onAddQuickLink={onAddQuickLink}
|
||||
onRemoveQuickLink={onRemoveQuickLink}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* NAV ZONE: Navigation (hidden on mobile) */}
|
||||
<TooltipProvider delayDuration={300} disableHoverableContent>
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
'hidden md:flex self-stretch items-stretch',
|
||||
showLabels && 'min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]',
|
||||
!showLabels && centered && 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{navItems.map(({ value, label, icon: Icon }) => {
|
||||
const isActive = activeView === value;
|
||||
const button = (
|
||||
<button
|
||||
onClick={() => onNavigate(value)}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative inline-flex h-full shrink-0 items-center gap-2 px-4',
|
||||
'font-mono text-[10px] uppercase tracking-[0.18em] transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" strokeWidth={1.5} />
|
||||
{showLabels && <span className="hidden xl:inline">{label}</span>}
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 -bottom-px h-[2px] bg-brand"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
// Icon-only mode: a tooltip names the destination on hover/focus. With
|
||||
// labels on, the visible text carries it, so the button renders bare.
|
||||
return showLabels ? (
|
||||
<Fragment key={value}>{button}</Fragment>
|
||||
) : (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</TooltipProvider>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-2',
|
||||
centered ? 'flex-1 min-w-0' : 'relative z-10 shrink-0',
|
||||
!centered && !stripLabels && navMode !== 'compact' && 'flex-1 min-w-0',
|
||||
)}
|
||||
>
|
||||
{search}
|
||||
{themeSwitch}
|
||||
{notifications}
|
||||
{userMenu}
|
||||
|
||||
{/* RIGHT ZONE: Utilities + identity pin */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-2',
|
||||
centered ? 'flex-1 min-w-0' : showLabels ? 'relative z-10 shrink-0' : 'flex-1 min-w-0',
|
||||
)}
|
||||
<Sheet open={mobileNavOpen} onOpenChange={onMobileNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open navigation menu"
|
||||
className="h-8 w-8 rounded-lg md:hidden"
|
||||
>
|
||||
{search}
|
||||
{themeSwitch}
|
||||
{notifications}
|
||||
{userMenu}
|
||||
|
||||
{/* Mobile nav trigger */}
|
||||
<Sheet open={mobileNavOpen} onOpenChange={onMobileNavOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open navigation menu"
|
||||
className="h-8 w-8 rounded-lg md:hidden"
|
||||
>
|
||||
<Menu className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-64 p-0">
|
||||
<div className="p-4 border-b">
|
||||
<p className="text-sm font-medium">Navigation</p>
|
||||
</div>
|
||||
<nav className="flex flex-col p-2 gap-1">
|
||||
{navItems.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
onNavigate(value);
|
||||
onMobileNavOpenChange(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
|
||||
activeView === value
|
||||
? 'bg-glass-highlight font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={1.5} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Menu className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-64 p-0">
|
||||
<div className="p-4 border-b">
|
||||
<p className="text-sm font-medium">Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<nav className="flex flex-col p-2 gap-1">
|
||||
{navItems.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(value);
|
||||
onMobileNavOpenChange(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
|
||||
activeView === value
|
||||
? 'bg-glass-highlight font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={1.5} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Home, Radar } from 'lucide-react';
|
||||
import { TopBar, type TopBarNavItem } from '../TopBar';
|
||||
|
||||
@@ -87,3 +88,215 @@ describe('TopBar showLabels', () => {
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' }).previousElementSibling).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar smart and compact modes', () => {
|
||||
const overflowGroups = [
|
||||
{
|
||||
group: 'operations' as const,
|
||||
label: 'Operations',
|
||||
items: [{ value: 'global-observability' as const, label: 'Logs', icon: Home }],
|
||||
},
|
||||
];
|
||||
const launcherGroups = [
|
||||
{
|
||||
group: 'overview' as const,
|
||||
label: 'Overview',
|
||||
items: [{ value: 'dashboard' as const, label: 'Home', icon: Home }],
|
||||
},
|
||||
{
|
||||
group: 'settings' as const,
|
||||
label: 'Settings',
|
||||
items: [{ value: 'settings' as const, label: 'Settings', icon: Radar }],
|
||||
},
|
||||
];
|
||||
const emptyModel = {
|
||||
allPageItems: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
primaryItems: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
overflowGroups: [] as typeof overflowGroups,
|
||||
launcherGroups: [] as typeof launcherGroups,
|
||||
quickLinkCandidates: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
};
|
||||
|
||||
it('marks More with aria-current when the active page is in overflow', () => {
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
activeView: 'global-observability',
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
overflowGroups,
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'More navigation' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'More navigation' })).toHaveTextContent('More');
|
||||
});
|
||||
|
||||
it('opens the More menu with masthead chrome and keeps overflow labels', async () => {
|
||||
const onNavigate = vi.fn();
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
onNavigate,
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
overflowGroups,
|
||||
},
|
||||
});
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
more.focus();
|
||||
fireEvent.keyDown(more, { key: 'Enter' });
|
||||
expect(await screen.findByText('More', { selector: '.font-heading' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('menuitem', { name: /Logs/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Logs/i }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('global-observability');
|
||||
});
|
||||
|
||||
it('renders Compact pins with always-inline labels and a trailing Add control', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
const onAddQuickLink = vi.fn();
|
||||
const onRemoveQuickLink = vi.fn();
|
||||
const onOpenSettings = vi.fn();
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
activeView: 'dashboard',
|
||||
onNavigate,
|
||||
onAddQuickLink,
|
||||
onRemoveQuickLink,
|
||||
onOpenSettings,
|
||||
persistedQuickLinkIds: ['dashboard'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups,
|
||||
quickLinkCandidates: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const home = screen.getByRole('button', { name: 'Home' });
|
||||
expect(home.querySelector('span.inline')).toBeTruthy();
|
||||
expect(home.querySelector('span.hidden')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add quick link' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: /Fleet/i }));
|
||||
expect(onAddQuickLink).toHaveBeenCalledWith('fleet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Home' }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('button', { name: 'Home' }) });
|
||||
await user.click(await screen.findByRole('menuitem', { name: /^Remove$/i }));
|
||||
expect(onRemoveQuickLink).toHaveBeenCalledWith('dashboard');
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
|
||||
expect(await screen.findByText('Navigate', { selector: '.font-heading' })).toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('menuitem', { name: /Settings/i }));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables Add when persisted capacity is full even if fewer pins are visible', () => {
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
persistedQuickLinkIds: ['dashboard', 'fleet', 'resources', 'security', 'networking'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups,
|
||||
quickLinkCandidates: emptyModel.quickLinkCandidates,
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Add quick link' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers Compact launcher context Add for unpinned destinations', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddQuickLink = vi.fn();
|
||||
const compactLauncher = [
|
||||
{
|
||||
group: 'overview' as const,
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
];
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
onAddQuickLink,
|
||||
persistedQuickLinkIds: ['dashboard'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups: compactLauncher,
|
||||
quickLinkCandidates: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
|
||||
fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Fleet/i }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: /Add to quick links/i }));
|
||||
expect(onAddQuickLink).toHaveBeenCalledWith('fleet');
|
||||
});
|
||||
|
||||
it('hides Compact launcher context Add for already-pinned destinations', async () => {
|
||||
const user = userEvent.setup();
|
||||
const compactLauncher = [
|
||||
{
|
||||
group: 'overview' as const,
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{ value: 'dashboard' as const, label: 'Home', icon: Home },
|
||||
{ value: 'fleet' as const, label: 'Fleet', icon: Radar },
|
||||
],
|
||||
},
|
||||
];
|
||||
renderTopBar({
|
||||
navMode: 'compact',
|
||||
onAddQuickLink: vi.fn(),
|
||||
persistedQuickLinkIds: ['dashboard'],
|
||||
quickLinks: [{ value: 'dashboard', label: 'Home', icon: Home }],
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
launcherGroups: compactLauncher,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open navigation launcher' }));
|
||||
fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Home/i }));
|
||||
expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not offer Add to quick links on Smart More', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTopBar({
|
||||
navMode: 'smart',
|
||||
onAddQuickLink: vi.fn(),
|
||||
navModel: {
|
||||
...emptyModel,
|
||||
primaryItems: [{ value: 'dashboard' as const, label: 'Home', icon: Home }],
|
||||
overflowGroups,
|
||||
},
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'More navigation' }));
|
||||
fireEvent.contextMenu(await screen.findByRole('menuitem', { name: /Logs/i }));
|
||||
expect(screen.queryByRole('menuitem', { name: /Add to quick links/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
import type { SectionId } from '@/components/settings';
|
||||
import { SettingsSectionContent } from '@/components/settings/SettingsSectionContent';
|
||||
import { BackChip, Kicker, Masthead } from './mobile-ui';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
interface MobileSettingsProps {
|
||||
headerActions: ReactNode;
|
||||
selectedSection: SectionId | null;
|
||||
onSelectedSectionChange: (section: SectionId | null) => void;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
@@ -27,6 +29,7 @@ export function MobileSettings({
|
||||
headerActions,
|
||||
selectedSection,
|
||||
onSelectedSectionChange,
|
||||
quickLinkCandidates,
|
||||
}: MobileSettingsProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
@@ -60,7 +63,12 @@ export function MobileSettings({
|
||||
<span className="font-heading text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
|
||||
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
|
||||
<SettingsSectionContent
|
||||
sectionId={activeSection}
|
||||
onDirtyChange={NOOP}
|
||||
showDescription
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,11 @@ import type { Density } from '@/hooks/use-density';
|
||||
import { useLogChipColorMode, type LogChipColorMode } from '@/hooks/use-log-chip-color-mode';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTopNavMode, type TopNavMode } from '@/hooks/use-top-nav-mode';
|
||||
import { useTopNavQuickLinks, MAX_QUICK_LINKS } from '@/hooks/use-top-nav-quick-links';
|
||||
import { getAppNavItem } from '@/lib/navigation/appNavRegistry';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import {
|
||||
useTheme, activeVisualStyle, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE,
|
||||
type VisualStyle, type HeadingStyle, type ChartStyle,
|
||||
@@ -37,6 +42,12 @@ const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||||
{ value: 'center', label: 'Center' },
|
||||
];
|
||||
|
||||
const TOP_NAV_MODE_OPTIONS: { value: TopNavMode; label: string }[] = [
|
||||
{ value: 'classic', label: 'Classic bar' },
|
||||
{ value: 'smart', label: 'Smart bar' },
|
||||
{ value: 'compact', label: 'Compact launcher' },
|
||||
];
|
||||
|
||||
const CHART_STYLE_OPTIONS: { value: ChartStyle; label: string }[] = [
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'heat', label: 'Heat' },
|
||||
@@ -134,11 +145,33 @@ function VisualCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function AppearanceSection() {
|
||||
export function AppearanceSection({
|
||||
quickLinkCandidates = [],
|
||||
}: {
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}) {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
|
||||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const [topNavMode, setTopNavMode] = useTopNavMode();
|
||||
const {
|
||||
persistedIds: quickLinkIds,
|
||||
addQuickLink,
|
||||
removeQuickLink,
|
||||
resetQuickLinks,
|
||||
} = useTopNavQuickLinks();
|
||||
const persistedSet = new Set(quickLinkIds);
|
||||
const unpinnedCandidates = quickLinkCandidates.filter((item) => !persistedSet.has(item.value));
|
||||
const atCapacity = quickLinkIds.length >= MAX_QUICK_LINKS;
|
||||
const addEnabled = !atCapacity && unpinnedCandidates.length > 0;
|
||||
const addDisabledReason = atCapacity
|
||||
? 'Remove a pin or reset to free a slot'
|
||||
: 'No more destinations available on this node';
|
||||
const addOptions = unpinnedCandidates.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
headingStyle, chartStyle, reducedEffects, reducedMotion, readability,
|
||||
@@ -413,13 +446,41 @@ export function AppearanceSection() {
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Top navigation labels"
|
||||
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
|
||||
label="Log chip color"
|
||||
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
|
||||
>
|
||||
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
|
||||
<SegmentedControl
|
||||
value={chipColorMode}
|
||||
options={CHIP_COLOR_OPTIONS}
|
||||
onChange={setChipColorMode}
|
||||
ariaLabel="Log chip color mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Navigation" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Navigation style"
|
||||
helper="Smart bar is the recommended default: primary destinations stay visible, and the rest live under More. Classic keeps the full horizontal strip. Compact launcher puts destinations in a menu with optional quick links."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={topNavMode}
|
||||
options={TOP_NAV_MODE_OPTIONS}
|
||||
onChange={setTopNavMode}
|
||||
ariaLabel="Navigation style"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
{!topNavLabels && (
|
||||
{(topNavMode === 'classic' || topNavMode === 'smart') && (
|
||||
<SettingsField
|
||||
label="Top navigation labels"
|
||||
helper="Show text labels beside top navigation icons. Turn off for a more compact navigation bar."
|
||||
>
|
||||
<TogglePill checked={topNavLabels} onChange={setTopNavLabels} />
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
{(topNavMode === 'classic' || topNavMode === 'smart') && !topNavLabels && (
|
||||
<SettingsField
|
||||
label="Top navigation alignment"
|
||||
helper="Place the icon-only navigation against the left edge or centered in the bar."
|
||||
@@ -433,17 +494,64 @@ export function AppearanceSection() {
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
<SettingsField
|
||||
label="Log chip color"
|
||||
helper="Unified uses the accent color for all service chips. Per-service assigns each service a stable label color for faster visual scanning."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={chipColorMode}
|
||||
options={CHIP_COLOR_OPTIONS}
|
||||
onChange={setChipColorMode}
|
||||
ariaLabel="Log chip color mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
{topNavMode === 'compact' && (
|
||||
<SettingsField
|
||||
label="Quick links"
|
||||
helper="Up to five pinned destinations on the top bar. Defaults are a starting set; add reachable destinations here or with the trailing + on the Compact bar."
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{quickLinkIds.length === 0 ? (
|
||||
<p className="font-mono text-[11px] text-stat-subtitle">No quick links pinned.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{quickLinkIds.map((id) => {
|
||||
const item = getAppNavItem(id);
|
||||
const label = item?.label ?? id;
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-glass-border px-2 py-1.5"
|
||||
>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.14em]">
|
||||
{label}
|
||||
</span>
|
||||
<SettingsSecondaryButton
|
||||
type="button"
|
||||
aria-label={`Remove ${label}`}
|
||||
onClick={() => removeQuickLink(id)}
|
||||
>
|
||||
Remove
|
||||
</SettingsSecondaryButton>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Add quick link
|
||||
</span>
|
||||
{addEnabled ? (
|
||||
<Combobox
|
||||
options={addOptions}
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value) addQuickLink(value as ActiveView);
|
||||
}}
|
||||
placeholder="Choose a destination"
|
||||
/>
|
||||
) : (
|
||||
<p className="font-mono text-[11px] text-stat-subtitle">{addDisabledReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<SettingsActions>
|
||||
<SettingsSecondaryButton type="button" onClick={resetQuickLinks}>
|
||||
Reset to defaults
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsActions>
|
||||
</div>
|
||||
</SettingsField>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsSectionContent } from './SettingsSectionContent';
|
||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
interface SettingsPageProps {
|
||||
currentSection: SectionId;
|
||||
@@ -35,6 +36,7 @@ interface SettingsPageProps {
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
export function SettingsPage(props: SettingsPageProps) {
|
||||
@@ -51,6 +53,7 @@ function SettingsPageInner({
|
||||
muteRulePrefill = null,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
}: SettingsPageProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
@@ -210,6 +213,7 @@ function SettingsPageInner({
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
quickLinkCandidates={quickLinkCandidates}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SectionId } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { SectionGate } from './SectionGate';
|
||||
import type { NavDestination } from '@/lib/navigation/appNavRegistry';
|
||||
|
||||
// Paid-tier sections are loaded on demand. SectionGate returns null for
|
||||
// Community / unentitled operators before reaching the JSX that would mount
|
||||
@@ -79,10 +80,11 @@ function renderSection(
|
||||
muteRulePrefill: MuteRuleDraft | null | undefined,
|
||||
onMutePrefillConsumed: (() => void) | undefined,
|
||||
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
|
||||
quickLinkCandidates: NavDestination[] | undefined,
|
||||
) {
|
||||
switch (sectionId) {
|
||||
case 'account': return <AccountSection />;
|
||||
case 'appearance': return <AppearanceSection />;
|
||||
case 'appearance': return <AppearanceSection quickLinkCandidates={quickLinkCandidates} />;
|
||||
case 'license': return <LicenseSection />;
|
||||
case 'users': return <UsersSection />;
|
||||
case 'sso': return <SSOSection />;
|
||||
@@ -125,6 +127,7 @@ interface SettingsSectionContentProps {
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
quickLinkCandidates?: NavDestination[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,11 +143,19 @@ export function SettingsSectionContent({
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
}: SettingsSectionContentProps) {
|
||||
const item = getSettingsItem(sectionId);
|
||||
const element = useMemo(
|
||||
() => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill),
|
||||
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill],
|
||||
() => renderSection(
|
||||
sectionId,
|
||||
onDirtyChange,
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
quickLinkCandidates,
|
||||
),
|
||||
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill, quickLinkCandidates],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -161,4 +161,22 @@ describe('AppearanceSection', () => {
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
expect((screen.getByRole('button', { name: 'Reset to default' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('shows Navigation style and mode-conditional controls', () => {
|
||||
localStorage.clear();
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.getByText('Navigation')).toBeTruthy();
|
||||
expect(screen.getByRole('radiogroup', { name: 'Navigation style' })).toBeTruthy();
|
||||
// Smart default shows label toggle, hides quick links.
|
||||
expect(screen.getByText('Top navigation labels')).toBeTruthy();
|
||||
expect(screen.queryByText('Quick links')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Compact launcher' }));
|
||||
expect(screen.getByText('Quick links')).toBeTruthy();
|
||||
expect(screen.queryByText('Top navigation labels')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Classic bar' }));
|
||||
expect(screen.getByText('Top navigation labels')).toBeTruthy();
|
||||
expect(screen.queryByText('Quick links')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,8 +61,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'appearance',
|
||||
group: 'personal',
|
||||
label: 'Appearance',
|
||||
description: 'Visual style, readability, theme, accent, charts, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects'],
|
||||
description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display', 'calm', 'signature', 'readability', 'heading', 'chart', 'motion', 'effects', 'navigation', 'smart', 'launcher', 'quick links', 'topbar', 'top nav'],
|
||||
tier: null,
|
||||
scope: 'browser',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user