mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 13:38:33 +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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user