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:
Anso
2026-07-16 21:48:03 -04:00
committed by GitHub
parent d8e4ede94f
commit 25586fc8ab
24 changed files with 1871 additions and 216 deletions
@@ -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']);
});
});