feat: add a compact icon-only top navigation toggle (#1363)

* feat: add a compact icon-only top navigation toggle

Add a browser-local "Top navigation labels" preference under Settings >
Appearance. With it off, the desktop top navigation renders icon-only;
each destination keeps an aria-label, gains a hover/focus tooltip, and
stays reachable from the command palette. The setting defaults on, so
current behavior is preserved, and the mobile navigation always keeps
its labels.

Also left-align the desktop nav (previously centered) and shorten the
longest nav label from "Auto-Update" to "Update" so the bar scans faster.

* feat: let the icon-only top nav be left or centered

Add a "Top navigation alignment" preference under Settings > Appearance
that appears only when top navigation labels are off. It places the
icon-only bar against the left edge (the default) or centered. With
labels on, the nav always stays left so the longer labels read from the
edge. The choice is browser-local and persists per device.
This commit is contained in:
Anso
2026-06-12 10:49:36 -04:00
committed by GitHub
parent 2a4955f56d
commit 1b96f3b980
15 changed files with 391 additions and 38 deletions
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTopNavAlign, TOP_NAV_ALIGN_KEY } from '../use-top-nav-align';
describe('useTopNavAlign (left default)', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults to left when no value is stored', () => {
const { result } = renderHook(() => useTopNavAlign());
expect(result.current[0]).toBe('left');
});
it('reads center only when explicitly set to center', () => {
localStorage.setItem(TOP_NAV_ALIGN_KEY, 'center');
const { result } = renderHook(() => useTopNavAlign());
expect(result.current[0]).toBe('center');
});
it('treats any unknown value as left', () => {
localStorage.setItem(TOP_NAV_ALIGN_KEY, 'something-else');
const { result } = renderHook(() => useTopNavAlign());
expect(result.current[0]).toBe('left');
});
it('setAlign persists and switches', () => {
const { result } = renderHook(() => useTopNavAlign());
act(() => result.current[1]('center'));
expect(result.current[0]).toBe('center');
expect(localStorage.getItem(TOP_NAV_ALIGN_KEY)).toBe('center');
act(() => result.current[1]('left'));
expect(result.current[0]).toBe('left');
});
it('reacts to a storage event from another tab', () => {
const { result } = renderHook(() => useTopNavAlign());
expect(result.current[0]).toBe('left');
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: TOP_NAV_ALIGN_KEY, newValue: 'center' }));
});
expect(result.current[0]).toBe('center');
});
it('syncs a second hook in the same tab via the settings-changed event', () => {
const a = renderHook(() => useTopNavAlign());
const b = renderHook(() => useTopNavAlign());
act(() => a.result.current[1]('center'));
expect(b.result.current[0]).toBe('center');
});
});
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTopNavLabels, TOP_NAV_LABELS_KEY } from '../use-top-nav-labels';
describe('useTopNavLabels (opt-out default)', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults to showing labels when no value is stored', () => {
const { result } = renderHook(() => useTopNavLabels());
expect(result.current[0]).toBe(true);
});
it('hides labels only when explicitly set to false', () => {
localStorage.setItem(TOP_NAV_LABELS_KEY, 'false');
const { result } = renderHook(() => useTopNavLabels());
expect(result.current[0]).toBe(false);
});
it('treats any non-false value as labels-on', () => {
localStorage.setItem(TOP_NAV_LABELS_KEY, 'true');
const { result } = renderHook(() => useTopNavLabels());
expect(result.current[0]).toBe(true);
});
it('restores labels when a storage event clears the key (newValue null)', () => {
localStorage.setItem(TOP_NAV_LABELS_KEY, 'false');
const { result } = renderHook(() => useTopNavLabels());
expect(result.current[0]).toBe(false);
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: TOP_NAV_LABELS_KEY, newValue: null }));
});
expect(result.current[0]).toBe(true);
});
it('setShowLabels(false) persists false and hides labels', () => {
const { result } = renderHook(() => useTopNavLabels());
act(() => result.current[1](false));
expect(result.current[0]).toBe(false);
expect(localStorage.getItem(TOP_NAV_LABELS_KEY)).toBe('false');
});
it('syncs a second hook in the same tab via the settings-changed event', () => {
const a = renderHook(() => useTopNavLabels());
const b = renderHook(() => useTopNavLabels());
act(() => a.result.current[1](false));
expect(b.result.current[0]).toBe(false);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const TOP_NAV_ALIGN_KEY = 'sencho.appearance.topNavAlign';
export type TopNavAlign = 'left' | 'center';
// Horizontal placement of the desktop top nav when it is icon-only (labels off).
// 'left' (the default) keeps the bar tucked against the left edge; 'center'
// centers the icon cluster. Only an explicit 'center' centers it, so a missing
// or unknown value stays left.
function readStored(): TopNavAlign {
if (typeof window === 'undefined') return 'left';
try {
return window.localStorage.getItem(TOP_NAV_ALIGN_KEY) === 'center' ? 'center' : 'left';
} catch {
return 'left';
}
}
export function useTopNavAlign(): [TopNavAlign, (next: TopNavAlign) => void] {
const [align, setAlignState] = useState<TopNavAlign>(readStored);
useEffect(() => {
function onSettingsChanged() {
setAlignState(readStored());
}
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
}, []);
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== TOP_NAV_ALIGN_KEY) return;
setAlignState(event.newValue === 'center' ? 'center' : 'left');
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setAlign = useCallback((next: TopNavAlign) => {
try {
window.localStorage.setItem(TOP_NAV_ALIGN_KEY, next);
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
setAlignState(next);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}, []);
return [align, setAlign];
}
+51
View File
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from 'react';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const TOP_NAV_LABELS_KEY = 'sencho.appearance.topNavLabels';
// Default on (opt-out): the desktop top nav keeps its text labels unless the
// user has explicitly turned them off, so current behavior is preserved. Only a
// stored 'false' switches the bar to icon-only.
function readStored(): boolean {
if (typeof window === 'undefined') return true;
try {
return window.localStorage.getItem(TOP_NAV_LABELS_KEY) !== 'false';
} catch {
return true;
}
}
export function useTopNavLabels(): [boolean, (next: boolean) => void] {
const [showLabels, setShowLabelsState] = useState<boolean>(readStored);
useEffect(() => {
function onSettingsChanged() {
setShowLabelsState(readStored());
}
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
}, []);
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== TOP_NAV_LABELS_KEY) return;
// Opt-out: anything other than an explicit 'false' (including a
// cleared key) means labels are shown.
setShowLabelsState(event.newValue !== 'false');
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setShowLabels = useCallback((next: boolean) => {
try {
window.localStorage.setItem(TOP_NAV_LABELS_KEY, next ? 'true' : 'false');
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
setShowLabelsState(next);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
}, []);
return [showLabels, setShowLabels];
}