mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +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:
@@ -79,15 +79,21 @@ Sencho uses three type contexts. The interface and data faces are yours to chang
|
||||
|
||||
## Display
|
||||
|
||||
The **Display** group holds the remaining per-browser preferences:
|
||||
The **Display** group holds layout and log-chip preferences for this browser:
|
||||
|
||||
- **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards).
|
||||
- **Top navigation labels** shows text labels beside the top navigation icons. Turn it off for an icon-only bar; the destinations stay reachable by hover tooltip, accessible name, and the command palette. On the phone layout the navigation always keeps its labels. With labels off, **Top navigation alignment** chooses whether the icon-only bar sits to the left or centered.
|
||||
- **Log chip color** controls how service chips are colored in log views. **Unified** uses the accent color for all service chips. **Per service** assigns each service a stable label color for faster visual scanning when following multiple services at once.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/settings/appearance-display.png" alt="The Appearance Display section with a Density selector set to Comfortable, a Top navigation labels toggle turned off revealing a Top navigation alignment control with Left selected and Center, and a Log chip color control with Unified selected alongside Per service." />
|
||||
</Frame>
|
||||
## Navigation
|
||||
|
||||
The **Navigation** group chooses how the desktop top bar presents page destinations. Phone navigation is unchanged.
|
||||
|
||||
- **Navigation style**
|
||||
- **Smart bar** (recommended default): keeps a short set of primary destinations visible and moves the rest into a grouped **More** menu.
|
||||
- **Classic bar**: shows the full horizontal destination strip.
|
||||
- **Compact launcher**: puts destinations in a left-side launcher menu and optionally pins up to five **quick links** on the bar.
|
||||
- **Top navigation labels** (Classic and Smart): shows text beside top navigation icons. Turn it off for an icon-only bar; destinations stay reachable by tooltip, accessible name, and the command palette. Phone layout always keeps labels. With labels off, **Top navigation alignment** places the icon-only bar left or centered.
|
||||
- **Quick links** (Compact launcher): labeled pins after the launcher, with a trailing **+** that opens reachable unpinned destinations. Right-click a pin and choose Remove, or manage the full list under Appearance. Up to five pins; recommended defaults start you with four.
|
||||
|
||||
Deploy-progress behavior and the diff-preview-before-save step are stack workflow preferences, so they live in **Settings → Infrastructure → Stacks**, not here.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Global Search
|
||||
description: Jump to any page, node, or stack from anywhere in the app with a single keystroke.
|
||||
---
|
||||
|
||||
The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the destinations the top bar exposes for your tier and role, every configured node, and every stack on every online node in your fleet.
|
||||
The **global search palette** lets you move around Sencho without reaching for the mouse. It covers the reachable page destinations for your tier and role (the same page list Classic top navigation, Smart primary and More, and mobile navigation use), every configured node, and every stack on every online node in your fleet. Compact launcher can also open **Settings** from its menu; Settings is not a palette page row.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/global-search/palette-pages.png" alt="Sencho global search palette open with no query, the Pages group listing Home, Fleet, Resources, Networking, App Store, Logs, and Auto-Update each with a leading icon." />
|
||||
@@ -25,7 +25,7 @@ The palette groups results into three sections.
|
||||
|
||||
| Group | What it contains | What happens when you pick one |
|
||||
|-------|------------------|--------------------------------|
|
||||
| **Pages** | The same set of destinations the top bar shows you. Home, Fleet, Resources, Networking, App Store, and Logs always appear; Update and Schedules appear for admins; Console appears for admins when that limited-availability surface is present; Audit appears on Admiral for roles with the audit permission (the full Audit view, statistics, export, anomalies, and extended retention). Community keeps a rolling 14-day recent-activity audit API window without the Audit view in navigation. | Navigates to that page |
|
||||
| **Pages** | The reachable page destinations for your tier and role (the same set Classic / Smart / mobile navigation use). **Home**, **Resources**, **Networking**, **Security**, and **App Store** appear for signed-in operators; **Fleet** appears when your role holds the `node:read` permission; **Logs**, **Update**, and **Schedules** appear for admins; **Console** is a limited-availability operator surface shown when enabled on an instance; **Audit** appears on Admiral for any role with the `system:audit` permission. See [RBAC & User Management](/features/rbac) for the full permission matrix. | Navigates to that page |
|
||||
| **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline. The currently active node carries a small **ACTIVE** chip on the right. | Switches the active node without leaving the current page |
|
||||
| **Stacks** | Every compose stack on every online node, matched on the compose filename (extension included). | Switches to the stack's node and opens it in the editor |
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ The **Home** view is the default landing page. It is designed for a fast operati
|
||||
- The activity panel shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install.
|
||||
- **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear.
|
||||
|
||||
The top navigation strip starts with **Home**, **Fleet**, **Resources**, **Security**, and **App Store**. Additional operator views (**Logs**, **Update**, **Schedules**, **Console**, and **Audit**) appear based on your role and license tier. Fleet-wide views describe the control instance, so they are hidden while a remote node is active.
|
||||
The top navigation starts with **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. Additional operator views (**Logs**, **Update**, **Schedules**, and **Audit**) appear based on your role and license tier. **Console** is a limited-availability operator surface documented on its own page when enabled on an instance. Fleet-wide views describe the control instance, so they are hidden while a remote node is active. Choose Classic, Smart, or Compact desktop navigation under **Settings → Appearance → Navigation**; phone navigation stays on its own layout.
|
||||
|
||||
## Stack workspace
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ You land on **Home**, the default operational view. The health masthead reports
|
||||
|
||||
Below the stack table, **Configuration Status** summarizes notifications, alerts, automation, security, backups, thresholds, and crash detection. The neighboring activity card shows **Fleet Heartbeat** when remote nodes exist, or **Stack Restarts (7d)** on a local-only install. **Recent Alerts** shows the latest notification feed and includes **Clear All Notifications** when there is anything to clear.
|
||||
|
||||
On the local node, the top navigation includes **Home**, **Fleet**, **Resources**, **Security**, **App Store**, and **Logs**. Depending on license, role, and node context, it can also include **Update**, **Schedules**, **Console**, and **Audit**; hub-only views are hidden when a remote node is active. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**.
|
||||
On the local node, baseline top navigation includes **Home**, **Resources**, **Networking**, **Security**, and **App Store**. **Fleet** appears when your role can read nodes. **Logs**, **Update**, and **Schedules** appear for admins. **Console** and **Audit** depend on license, role, and whether those surfaces are enabled; hub-only views are hidden when a remote node is active. Desktop presentation (Classic bar, Smart bar, or Compact launcher) is chosen under **Settings → Appearance → Navigation**. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**.
|
||||
|
||||
The left sidebar is the stack workspace. Below the Sencho brand, it starts with the node switcher, then **Create Stack**, a bulk-mode toggle, and **Scan stacks folder** for re-indexing compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install with an empty stack list, Sencho scans your mounted compose directory automatically and shows what it found, including compose files that still need to be adopted into their own subfolder.
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Desktop navigation styles: Smart default, Compact quick-link picker,
|
||||
* labeled pins, and persistence.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
async function setTopNavMode(page: import('@playwright/test').Page, mode: 'classic' | 'smart' | 'compact' | null) {
|
||||
await page.evaluate((next) => {
|
||||
if (next === null) {
|
||||
window.localStorage.removeItem('sencho.appearance.topNavMode');
|
||||
window.localStorage.removeItem('sencho.appearance.topNavQuickLinks');
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem('sencho.appearance.topNavMode', next);
|
||||
}, mode);
|
||||
await page.reload();
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
}
|
||||
|
||||
test.describe('Desktop navigation styles', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.removeItem('sencho.appearance.topNavMode');
|
||||
window.localStorage.removeItem('sencho.appearance.topNavQuickLinks');
|
||||
});
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
});
|
||||
|
||||
test('defaults to Smart bar with a More control', async ({ page }) => {
|
||||
const topbar = page.locator('[data-sn-chrome="topbar"]');
|
||||
await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'smart');
|
||||
await expect(page.getByRole('button', { name: 'More navigation' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('persists mode across reload and navigates via Smart More', async ({ page }) => {
|
||||
await setTopNavMode(page, 'classic');
|
||||
await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'classic');
|
||||
|
||||
await setTopNavMode(page, 'smart');
|
||||
await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'smart');
|
||||
await page.getByRole('button', { name: 'More navigation' }).click();
|
||||
await expect(page.locator('.font-heading').filter({ hasText: 'More' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: /Logs/i }).click();
|
||||
await expect(page.locator('body')).toContainText(/Logs|Central|Observability/i);
|
||||
});
|
||||
|
||||
test('Compact launcher opens Settings', async ({ page }) => {
|
||||
await setTopNavMode(page, 'compact');
|
||||
await expect(page.locator('[data-sn-chrome="topbar"]')).toHaveAttribute('data-sn-nav-mode', 'compact');
|
||||
await page.getByRole('button', { name: 'Open navigation launcher' }).click();
|
||||
await page.getByRole('menuitem', { name: /^Settings$/i }).click();
|
||||
await expect(page.getByText('Appearance', { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('Compact trailing + adds a labeled pin that survives reload', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.setItem('sencho.appearance.topNavQuickLinks', '[]');
|
||||
});
|
||||
await setTopNavMode(page, 'compact');
|
||||
|
||||
const topbar = page.locator('[data-sn-chrome="topbar"]');
|
||||
await expect(topbar).toHaveAttribute('data-sn-nav-mode', 'compact');
|
||||
|
||||
await page.getByRole('button', { name: 'Add quick link' }).click();
|
||||
await page.getByRole('menuitem', { name: /Networking/i }).click();
|
||||
|
||||
const pin = topbar.getByRole('button', { name: 'Networking', exact: true });
|
||||
await expect(pin).toBeVisible();
|
||||
await expect(pin.locator('span.inline')).toBeVisible();
|
||||
|
||||
const stored = await page.evaluate(() => window.localStorage.getItem('sencho.appearance.topNavQuickLinks'));
|
||||
expect(stored).toContain('networking');
|
||||
|
||||
await page.reload();
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await expect(page.locator('[data-sn-chrome="topbar"]').getByRole('button', { name: 'Networking', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Add quick link' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTopNavMode, TOP_NAV_MODE_KEY, parseTopNavMode } from '../use-top-nav-mode';
|
||||
|
||||
describe('useTopNavMode', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('defaults to smart when no value is stored', () => {
|
||||
const { result } = renderHook(() => useTopNavMode());
|
||||
expect(result.current[0]).toBe('smart');
|
||||
});
|
||||
|
||||
it('falls back to smart for invalid storage', () => {
|
||||
expect(parseTopNavMode('nope')).toBe('smart');
|
||||
localStorage.setItem(TOP_NAV_MODE_KEY, 'nope');
|
||||
const { result } = renderHook(() => useTopNavMode());
|
||||
expect(result.current[0]).toBe('smart');
|
||||
});
|
||||
|
||||
it('reads each valid stored mode', () => {
|
||||
for (const mode of ['classic', 'smart', 'compact'] as const) {
|
||||
localStorage.setItem(TOP_NAV_MODE_KEY, mode);
|
||||
const { result, unmount } = renderHook(() => useTopNavMode());
|
||||
expect(result.current[0]).toBe(mode);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('persists mode changes and syncs same-tab listeners', () => {
|
||||
const a = renderHook(() => useTopNavMode());
|
||||
const b = renderHook(() => useTopNavMode());
|
||||
act(() => a.result.current[1]('compact'));
|
||||
expect(a.result.current[0]).toBe('compact');
|
||||
expect(b.result.current[0]).toBe('compact');
|
||||
expect(localStorage.getItem(TOP_NAV_MODE_KEY)).toBe('compact');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { recommendedQuickLinkIds } from '@/lib/navigation/appNavRegistry';
|
||||
import {
|
||||
useTopNavQuickLinks,
|
||||
TOP_NAV_QUICK_LINKS_KEY,
|
||||
parseStoredQuickLinks,
|
||||
sanitizeQuickLinkIds,
|
||||
MAX_QUICK_LINKS,
|
||||
} from '../use-top-nav-quick-links';
|
||||
|
||||
describe('useTopNavQuickLinks', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('uses registry recommended defaults when the key is missing', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]);
|
||||
});
|
||||
|
||||
it('uses registry recommended defaults for malformed JSON', () => {
|
||||
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '{not-json');
|
||||
expect(parseStoredQuickLinks(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY))).toEqual([
|
||||
...recommendedQuickLinkIds,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a valid empty array empty', () => {
|
||||
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
expect(result.current.persistedIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('sanitizes unknown, ineligible, and duplicate IDs and caps at five', () => {
|
||||
expect(
|
||||
sanitizeQuickLinkIds([
|
||||
'dashboard',
|
||||
'dashboard',
|
||||
'settings',
|
||||
'not-a-view',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
]),
|
||||
).toEqual(['dashboard', 'fleet', 'security', 'resources', 'networking']);
|
||||
expect(
|
||||
sanitizeQuickLinkIds([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
'templates',
|
||||
]).length,
|
||||
).toBe(MAX_QUICK_LINKS);
|
||||
});
|
||||
|
||||
it('reset writes recommendedQuickLinkIds', () => {
|
||||
localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, '[]');
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => result.current.resetQuickLinks());
|
||||
expect(result.current.persistedIds).toEqual([...recommendedQuickLinkIds]);
|
||||
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([
|
||||
...recommendedQuickLinkIds,
|
||||
]);
|
||||
});
|
||||
|
||||
it('remove can clear all pins without repopulating', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => {
|
||||
for (const id of [...result.current.persistedIds]) {
|
||||
result.current.removeQuickLink(id);
|
||||
}
|
||||
});
|
||||
expect(result.current.persistedIds).toEqual([]);
|
||||
expect(JSON.parse(localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY)!)).toEqual([]);
|
||||
});
|
||||
|
||||
it('add refuses beyond the persisted max of five', () => {
|
||||
const { result } = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => result.current.setPersistedIds([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
]));
|
||||
act(() => result.current.addQuickLink('templates'));
|
||||
expect(result.current.persistedIds).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
'networking',
|
||||
]);
|
||||
});
|
||||
|
||||
it('syncs a second hook in the same tab', () => {
|
||||
const a = renderHook(() => useTopNavQuickLinks());
|
||||
const b = renderHook(() => useTopNavQuickLinks());
|
||||
act(() => a.result.current.setPersistedIds(['networking']));
|
||||
expect(b.result.current.persistedIds).toEqual(['networking']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const TOP_NAV_MODE_KEY = 'sencho.appearance.topNavMode';
|
||||
|
||||
export type TopNavMode = 'classic' | 'smart' | 'compact';
|
||||
|
||||
const VALID: ReadonlySet<string> = new Set(['classic', 'smart', 'compact']);
|
||||
|
||||
/** Missing or invalid storage resolves to Smart (recommended default). */
|
||||
export function parseTopNavMode(raw: string | null): TopNavMode {
|
||||
if (raw && VALID.has(raw)) return raw as TopNavMode;
|
||||
return 'smart';
|
||||
}
|
||||
|
||||
function readStored(): TopNavMode {
|
||||
if (typeof window === 'undefined') return 'smart';
|
||||
try {
|
||||
return parseTopNavMode(window.localStorage.getItem(TOP_NAV_MODE_KEY));
|
||||
} catch {
|
||||
return 'smart';
|
||||
}
|
||||
}
|
||||
|
||||
export function useTopNavMode(): [TopNavMode, (next: TopNavMode) => void] {
|
||||
const [mode, setModeState] = useState<TopNavMode>(readStored);
|
||||
|
||||
useEffect(() => {
|
||||
function onSettingsChanged() {
|
||||
setModeState(readStored());
|
||||
}
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== TOP_NAV_MODE_KEY) return;
|
||||
setModeState(parseTopNavMode(event.newValue));
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((next: TopNavMode) => {
|
||||
try {
|
||||
window.localStorage.setItem(TOP_NAV_MODE_KEY, next);
|
||||
} catch {
|
||||
// ignore; localStorage may be unavailable
|
||||
}
|
||||
setModeState(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
return [mode, setMode];
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
isQuickLinkEligibleId,
|
||||
recommendedQuickLinkIds,
|
||||
} from '@/lib/navigation/appNavRegistry';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const TOP_NAV_QUICK_LINKS_KEY = 'sencho.appearance.topNavQuickLinks';
|
||||
export const MAX_QUICK_LINKS = 5;
|
||||
|
||||
/**
|
||||
* Sanitize a candidate ID list: keep registry-known eligible IDs, dedupe,
|
||||
* and cap at MAX_QUICK_LINKS. Does not expand empty arrays to defaults.
|
||||
*/
|
||||
export function sanitizeQuickLinkIds(ids: unknown): ActiveView[] {
|
||||
if (!Array.isArray(ids)) return [...recommendedQuickLinkIds];
|
||||
const seen = new Set<string>();
|
||||
const out: ActiveView[] = [];
|
||||
for (const raw of ids) {
|
||||
if (typeof raw !== 'string' || !isQuickLinkEligibleId(raw)) continue;
|
||||
if (seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
out.push(raw);
|
||||
if (out.length >= MAX_QUICK_LINKS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse stored JSON. Missing key or malformed JSON → recommended defaults.
|
||||
* Valid JSON array (including []) is sanitized and returned as-is (empty stays empty).
|
||||
*/
|
||||
export function parseStoredQuickLinks(raw: string | null): ActiveView[] {
|
||||
if (raw === null) return [...recommendedQuickLinkIds];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...recommendedQuickLinkIds];
|
||||
return sanitizeQuickLinkIds(parsed);
|
||||
} catch {
|
||||
return [...recommendedQuickLinkIds];
|
||||
}
|
||||
}
|
||||
|
||||
function readStored(): ActiveView[] {
|
||||
if (typeof window === 'undefined') return [...recommendedQuickLinkIds];
|
||||
try {
|
||||
return parseStoredQuickLinks(window.localStorage.getItem(TOP_NAV_QUICK_LINKS_KEY));
|
||||
} catch {
|
||||
return [...recommendedQuickLinkIds];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(ids: ActiveView[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(TOP_NAV_QUICK_LINKS_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export interface TopNavQuickLinksApi {
|
||||
persistedIds: ActiveView[];
|
||||
setPersistedIds: (next: ActiveView[]) => void;
|
||||
addQuickLink: (value: ActiveView) => void;
|
||||
removeQuickLink: (value: ActiveView) => void;
|
||||
resetQuickLinks: () => void;
|
||||
}
|
||||
|
||||
export function useTopNavQuickLinks(): TopNavQuickLinksApi {
|
||||
const [persistedIds, setPersistedState] = useState<ActiveView[]>(readStored);
|
||||
|
||||
useEffect(() => {
|
||||
function onSettingsChanged() {
|
||||
setPersistedState(readStored());
|
||||
}
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== TOP_NAV_QUICK_LINKS_KEY) return;
|
||||
setPersistedState(parseStoredQuickLinks(event.newValue));
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const commit = useCallback((next: ActiveView[]) => {
|
||||
const sanitized = sanitizeQuickLinkIds(next);
|
||||
writeStored(sanitized);
|
||||
setPersistedState(sanitized);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
const setPersistedIds = useCallback((next: ActiveView[]) => {
|
||||
commit(next);
|
||||
}, [commit]);
|
||||
|
||||
const addQuickLink = useCallback((value: ActiveView) => {
|
||||
setPersistedState((prev) => {
|
||||
if (prev.includes(value) || prev.length >= MAX_QUICK_LINKS) return prev;
|
||||
if (!isQuickLinkEligibleId(value)) return prev;
|
||||
const next = [...prev, value];
|
||||
writeStored(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeQuickLink = useCallback((value: ActiveView) => {
|
||||
setPersistedState((prev) => {
|
||||
const next = prev.filter((id) => id !== value);
|
||||
writeStored(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetQuickLinks = useCallback(() => {
|
||||
commit([...recommendedQuickLinkIds]);
|
||||
}, [commit]);
|
||||
|
||||
return {
|
||||
persistedIds,
|
||||
setPersistedIds,
|
||||
addQuickLink,
|
||||
removeQuickLink,
|
||||
resetQuickLinks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
APP_NAV_REGISTRY,
|
||||
recommendedQuickLinkIds,
|
||||
} from './appNavRegistry';
|
||||
|
||||
describe('appNavRegistry', () => {
|
||||
it('has unique ActiveView values', () => {
|
||||
const values = APP_NAV_REGISTRY.map((item) => item.value);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
});
|
||||
|
||||
it('gives every non-Settings destination exactly one Smart placement of primary or overflow', () => {
|
||||
for (const item of APP_NAV_REGISTRY) {
|
||||
if (item.value === 'settings') {
|
||||
expect(item.smart).toBe('launcher-only');
|
||||
continue;
|
||||
}
|
||||
expect(item.smart === 'primary' || item.smart === 'overflow').toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('exports recommendedQuickLinkIds from defaultQuickLink metadata in that order', () => {
|
||||
const flagged = APP_NAV_REGISTRY.filter((item) => item.defaultQuickLink).map((item) => item.value);
|
||||
expect(new Set(recommendedQuickLinkIds)).toEqual(new Set(flagged));
|
||||
expect([...recommendedQuickLinkIds]).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
]);
|
||||
for (const id of recommendedQuickLinkIds) {
|
||||
const item = APP_NAV_REGISTRY.find((entry) => entry.value === id);
|
||||
expect(item?.quickLinkEligible).toBe(true);
|
||||
expect(item?.defaultQuickLink).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Networking after Resources in Classic order metadata', () => {
|
||||
const resources = APP_NAV_REGISTRY.find((item) => item.value === 'resources');
|
||||
const networking = APP_NAV_REGISTRY.find((item) => item.value === 'networking');
|
||||
expect(resources).toBeDefined();
|
||||
expect(networking).toBeDefined();
|
||||
expect(networking!.classicOrder).toBeGreaterThan(resources!.classicOrder);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Terminal, CloudDownload, Home, HardDrive, ScrollText,
|
||||
Activity, Radar, RefreshCw, Clock, ShieldCheck, Network, Settings,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ActiveView } from '@/lib/router/routeTypes';
|
||||
|
||||
export type NavGroup =
|
||||
| 'overview'
|
||||
| 'stack-workspace'
|
||||
| 'fleet'
|
||||
| 'security-review'
|
||||
| 'operations'
|
||||
| 'tools'
|
||||
| 'settings';
|
||||
|
||||
export type SmartPlacement = 'primary' | 'overflow' | 'launcher-only';
|
||||
|
||||
/** Shared destination shape for TopBar, palette, and mobile consumers. */
|
||||
export interface NavDestination {
|
||||
value: ActiveView;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export interface AppNavItem extends NavDestination {
|
||||
group: NavGroup;
|
||||
/** Classic strip order (ascending). Settings uses a high value and is excluded from Classic. */
|
||||
classicOrder: number;
|
||||
smart: SmartPlacement;
|
||||
quickLinkEligible: boolean;
|
||||
defaultQuickLink: boolean;
|
||||
}
|
||||
|
||||
export const NAV_GROUP_META: readonly { id: NavGroup; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'stack-workspace', label: 'Stack workspace' },
|
||||
{ id: 'fleet', label: 'Fleet' },
|
||||
{ id: 'security-review', label: 'Security & review' },
|
||||
{ id: 'operations', label: 'Operations' },
|
||||
{ id: 'tools', label: 'Tools' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
] as const;
|
||||
|
||||
export const APP_NAV_REGISTRY: readonly AppNavItem[] = [
|
||||
{
|
||||
value: 'dashboard',
|
||||
label: 'Home',
|
||||
icon: Home,
|
||||
group: 'overview',
|
||||
classicOrder: 10,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'fleet',
|
||||
label: 'Fleet',
|
||||
icon: Radar,
|
||||
group: 'fleet',
|
||||
classicOrder: 20,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'resources',
|
||||
label: 'Resources',
|
||||
icon: HardDrive,
|
||||
group: 'fleet',
|
||||
classicOrder: 30,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'networking',
|
||||
label: 'Networking',
|
||||
icon: Network,
|
||||
group: 'fleet',
|
||||
classicOrder: 40,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'security',
|
||||
label: 'Security',
|
||||
icon: ShieldCheck,
|
||||
group: 'security-review',
|
||||
classicOrder: 50,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: true,
|
||||
},
|
||||
{
|
||||
value: 'templates',
|
||||
label: 'App Store',
|
||||
icon: CloudDownload,
|
||||
group: 'stack-workspace',
|
||||
classicOrder: 60,
|
||||
smart: 'primary',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'global-observability',
|
||||
label: 'Logs',
|
||||
icon: Activity,
|
||||
group: 'operations',
|
||||
classicOrder: 70,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'auto-updates',
|
||||
label: 'Update',
|
||||
icon: RefreshCw,
|
||||
group: 'operations',
|
||||
classicOrder: 80,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'scheduled-ops',
|
||||
label: 'Schedules',
|
||||
icon: Clock,
|
||||
group: 'operations',
|
||||
classicOrder: 90,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'host-console',
|
||||
label: 'Console',
|
||||
icon: Terminal,
|
||||
group: 'tools',
|
||||
classicOrder: 100,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'audit-log',
|
||||
label: 'Audit',
|
||||
icon: ScrollText,
|
||||
group: 'security-review',
|
||||
classicOrder: 110,
|
||||
smart: 'overflow',
|
||||
quickLinkEligible: true,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
{
|
||||
value: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
group: 'settings',
|
||||
classicOrder: 999,
|
||||
smart: 'launcher-only',
|
||||
quickLinkEligible: false,
|
||||
defaultQuickLink: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Recommended quick-link pins for missing/malformed storage and Reset.
|
||||
* Order is intentional (Home, Fleet, Security, Resources), not Classic strip order.
|
||||
*/
|
||||
export const recommendedQuickLinkIds: readonly ActiveView[] = [
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'security',
|
||||
'resources',
|
||||
] as const;
|
||||
|
||||
const BY_VALUE = new Map(APP_NAV_REGISTRY.map((item) => [item.value, item]));
|
||||
|
||||
export function getAppNavItem(value: ActiveView): AppNavItem | undefined {
|
||||
return BY_VALUE.get(value);
|
||||
}
|
||||
|
||||
export function isQuickLinkEligibleId(value: string): value is ActiveView {
|
||||
const item = BY_VALUE.get(value as ActiveView);
|
||||
return Boolean(item?.quickLinkEligible);
|
||||
}
|
||||
|
||||
export function toNavDestination(item: AppNavItem): NavDestination {
|
||||
return { value: item.value, label: item.label, icon: item.icon };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildNavigationModel } from './buildNavigationModel';
|
||||
import type { ReachabilityContext } from '@/lib/routing/reachability';
|
||||
|
||||
function makeCtx(overrides: Partial<ReachabilityContext> = {}): ReachabilityContext {
|
||||
return {
|
||||
isAdmin: true,
|
||||
isPaid: true,
|
||||
can: () => true,
|
||||
isRemote: false,
|
||||
hasFleetCapability: true,
|
||||
containerLabelsEnabled: true,
|
||||
permissionsStatus: 'ready',
|
||||
licenseStatus: 'ready',
|
||||
experimental: true,
|
||||
experimentalReady: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildNavigationModel', () => {
|
||||
it('returns exact Classic page order including Networking after Resources', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
expect(model.allPageItems.map((item) => item.value)).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'resources',
|
||||
'networking',
|
||||
'security',
|
||||
'templates',
|
||||
'global-observability',
|
||||
'auto-updates',
|
||||
'scheduled-ops',
|
||||
'host-console',
|
||||
'audit-log',
|
||||
]);
|
||||
expect(model.allPageItems.some((item) => item.value === 'settings')).toBe(false);
|
||||
});
|
||||
|
||||
it('partitions Smart primary and overflow disjointly covering all page destinations', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
const primary = model.primaryItems.map((item) => item.value);
|
||||
const overflow = model.overflowGroups.flatMap((g) => g.items.map((i) => i.value));
|
||||
expect(primary).toEqual([
|
||||
'dashboard',
|
||||
'fleet',
|
||||
'resources',
|
||||
'networking',
|
||||
'security',
|
||||
'templates',
|
||||
]);
|
||||
expect(primary.filter((v) => overflow.includes(v))).toEqual([]);
|
||||
expect(new Set([...primary, ...overflow])).toEqual(
|
||||
new Set(model.allPageItems.map((item) => item.value)),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes Settings only in launcher groups', () => {
|
||||
const model = buildNavigationModel(makeCtx());
|
||||
const launcherValues = model.launcherGroups.flatMap((g) => g.items.map((i) => i.value));
|
||||
expect(launcherValues).toContain('settings');
|
||||
expect(model.allPageItems.map((i) => i.value)).not.toContain('settings');
|
||||
expect(model.primaryItems.map((i) => i.value)).not.toContain('settings');
|
||||
});
|
||||
|
||||
it('keeps Networking reachable on a remote node while dropping hub-only pages', () => {
|
||||
const model = buildNavigationModel(makeCtx({ isRemote: true }));
|
||||
const values = model.allPageItems.map((item) => item.value);
|
||||
expect(values).toContain('networking');
|
||||
expect(values).toContain('resources');
|
||||
expect(values).toContain('security');
|
||||
expect(values).toContain('templates');
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('global-observability');
|
||||
expect(values).not.toContain('auto-updates');
|
||||
expect(values).not.toContain('scheduled-ops');
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
it('omits Console until experimental discovery is ready and enabled via reachCtx only', () => {
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: false, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: false }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).not.toContain('host-console');
|
||||
expect(
|
||||
buildNavigationModel(makeCtx({ experimentalReady: true, experimental: true }))
|
||||
.allPageItems.map((i) => i.value),
|
||||
).toContain('host-console');
|
||||
});
|
||||
|
||||
it('excludes hidden views from quick-link candidates', () => {
|
||||
const model = buildNavigationModel(makeCtx({ isRemote: true, isPaid: false }));
|
||||
const values = model.quickLinkCandidates.map((i) => i.value);
|
||||
expect(values).toContain('networking');
|
||||
expect(values).not.toContain('fleet');
|
||||
expect(values).not.toContain('settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
APP_NAV_REGISTRY,
|
||||
NAV_GROUP_META,
|
||||
toNavDestination,
|
||||
type AppNavItem,
|
||||
type NavDestination,
|
||||
type NavGroup,
|
||||
} from '@/lib/navigation/appNavRegistry';
|
||||
import { isViewHidden, type ReachabilityContext } from '@/lib/routing/reachability';
|
||||
|
||||
export interface NavGroupBucket {
|
||||
group: NavGroup;
|
||||
label: string;
|
||||
items: NavDestination[];
|
||||
}
|
||||
|
||||
export interface ReachableNavigationModel {
|
||||
/** Classic / palette / mobile page list (excludes Settings). Exact Classic order. */
|
||||
allPageItems: NavDestination[];
|
||||
primaryItems: NavDestination[];
|
||||
overflowGroups: NavGroupBucket[];
|
||||
launcherGroups: NavGroupBucket[];
|
||||
quickLinkCandidates: NavDestination[];
|
||||
}
|
||||
|
||||
function isVisuallyDiscoverable(item: AppNavItem, reachCtx: ReachabilityContext): boolean {
|
||||
if (item.smart === 'launcher-only') {
|
||||
// Settings is always discoverable in the launcher when the operator can open Settings.
|
||||
return true;
|
||||
}
|
||||
// Console: fail-closed visual discovery until /meta settles and the flag is on.
|
||||
// URL normalization still uses isViewHidden cold-load deferral separately.
|
||||
if (item.value === 'host-console') {
|
||||
if (!reachCtx.experimentalReady || !reachCtx.experimental) return false;
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
return !isViewHidden(item.value, reachCtx);
|
||||
}
|
||||
|
||||
function bucketByGroup(items: AppNavItem[]): NavGroupBucket[] {
|
||||
const byGroup = new Map<NavGroup, NavDestination[]>();
|
||||
for (const item of items) {
|
||||
const list = byGroup.get(item.group) ?? [];
|
||||
list.push(toNavDestination(item));
|
||||
byGroup.set(item.group, list);
|
||||
}
|
||||
return NAV_GROUP_META
|
||||
.map(({ id, label }) => {
|
||||
const groupItems = byGroup.get(id);
|
||||
if (!groupItems?.length) return null;
|
||||
return { group: id, label, items: groupItems };
|
||||
})
|
||||
.filter((bucket): bucket is NavGroupBucket => bucket !== null);
|
||||
}
|
||||
|
||||
/** Derive reachable navigation collections from a single ReachabilityContext. */
|
||||
export function buildNavigationModel(reachCtx: ReachabilityContext): ReachableNavigationModel {
|
||||
const reachable = APP_NAV_REGISTRY.filter((item) => isVisuallyDiscoverable(item, reachCtx));
|
||||
|
||||
const pages = reachable
|
||||
.filter((item) => item.smart !== 'launcher-only')
|
||||
.slice()
|
||||
.sort((a, b) => a.classicOrder - b.classicOrder);
|
||||
|
||||
const allPageItems = pages.map(toNavDestination);
|
||||
const primaryItems = pages
|
||||
.filter((item) => item.smart === 'primary')
|
||||
.map(toNavDestination);
|
||||
const overflowItems = pages.filter((item) => item.smart === 'overflow');
|
||||
const overflowGroups = bucketByGroup(overflowItems);
|
||||
const launcherGroups = bucketByGroup(reachable);
|
||||
const quickLinkCandidates = reachable
|
||||
.filter((item) => item.quickLinkEligible)
|
||||
.map(toNavDestination);
|
||||
|
||||
return {
|
||||
allPageItems,
|
||||
primaryItems,
|
||||
overflowGroups,
|
||||
launcherGroups,
|
||||
quickLinkCandidates,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user