mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
feat(settings): sidebar layout preference
Add a Sidebar layout section to Settings > Appearance with a Fixed/Resizable mode control, a width slider (224 to 440 px, step 4), and a targeted reset that restores the defaults without touching other appearance values. The sidebar layout hook now broadcasts the settings-changed event when one of its setter writes actually changes localStorage, so every mounted instance (the shell separator, the Settings slider, other tabs) converges on the commit instead of holding a stale draft. Settings search keywords gain sidebar/resize/layout aliases, and the preference sync tests cover the targeted reset path: local defaults apply immediately, one bus op carries both dirty fields, and the write degrades to a migrate POST when no preference row exists yet.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Check, Info, RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
@@ -6,6 +7,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useSidebarLayout, SIDEBAR_WIDTH, type SidebarMode } from '@/hooks/use-sidebar-layout';
|
||||
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';
|
||||
@@ -22,6 +24,7 @@ import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||
import { ThemePreview } from '@/components/theme/ThemePreview';
|
||||
import { TypeChips } from '@/components/theme/TypeChips';
|
||||
import { UI_FONT_OPTIONS, MONO_FONT_OPTIONS } from '@/components/theme/typeOptions';
|
||||
import { resetSidebarLayout } from '@/lib/preferences/resetPreferences';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
import { SettingsActions, SettingsSecondaryButton } from './SettingsActions';
|
||||
@@ -63,6 +66,14 @@ const CHIP_COLOR_OPTIONS: { value: LogChipColorMode; label: string }[] = [
|
||||
{ value: 'per-service', label: 'Per service' },
|
||||
];
|
||||
|
||||
const SIDEBAR_MODE_OPTIONS: { value: SidebarMode; label: string }[] = [
|
||||
{ value: 'fixed', label: 'Fixed' },
|
||||
{ value: 'resizable', label: 'Resizable' },
|
||||
];
|
||||
|
||||
/** Slider granularity in px; every reachable width is a valid stored integer. */
|
||||
const SIDEBAR_WIDTH_STEP = 4;
|
||||
|
||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
// Preview swatches for the Visual style cards. Calm uses the muted ramp; Signature
|
||||
@@ -157,6 +168,16 @@ export function AppearanceSection({
|
||||
}) {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [chipColorMode, setChipColorMode] = useLogChipColorMode();
|
||||
const { sidebarMode, sidebarWidth, setSidebarMode, setSidebarWidth } = useSidebarLayout();
|
||||
// The width slider holds a local draft only while the user drags. An effect
|
||||
// re-syncs the draft from the shared preference whenever it changes through
|
||||
// another surface (targeted reset, hydration, another tab), without issuing
|
||||
// a write.
|
||||
const [widthDraft, setWidthDraft] = useState(sidebarWidth);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- re-sync the draft from the shared preference (reset, hydration, another tab)
|
||||
setWidthDraft(sidebarWidth);
|
||||
}, [sidebarWidth]);
|
||||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const [topNavMode, setTopNavMode] = useTopNavMode();
|
||||
@@ -464,6 +485,53 @@ export function AppearanceSection({
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Sidebar layout" kicker="your account">
|
||||
<SettingsField
|
||||
label="Sidebar mode"
|
||||
helper="Fixed keeps the stacks sidebar at a set width. Resizable makes the divider between the sidebar and the workspace draggable, on desktop, between 224 and 440 px. Reset sidebar layout restores Fixed and the default width."
|
||||
align="start"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={sidebarMode}
|
||||
options={SIDEBAR_MODE_OPTIONS}
|
||||
onChange={setSidebarMode}
|
||||
ariaLabel="Sidebar mode"
|
||||
/>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Sidebar width"
|
||||
helper="Preferred stacks-sidebar width while Resizable is active."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[widthDraft]}
|
||||
min={SIDEBAR_WIDTH.min}
|
||||
max={SIDEBAR_WIDTH.max}
|
||||
step={SIDEBAR_WIDTH_STEP}
|
||||
disabled={sidebarMode !== 'resizable'}
|
||||
onValueChange={([v]) => setWidthDraft(v)}
|
||||
onValueCommit={([v]) => setSidebarWidth(v)}
|
||||
aria-label="Sidebar width"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-stat-subtitle">
|
||||
{widthDraft} px
|
||||
</span>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Reset sidebar layout"
|
||||
helper="Restore the Fixed mode and default width; other appearance preferences are untouched."
|
||||
align="start"
|
||||
>
|
||||
<SettingsSecondaryButton type="button" onClick={resetSidebarLayout} aria-label="Reset sidebar layout">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset
|
||||
</SettingsSecondaryButton>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Navigation" kicker="your account">
|
||||
<SettingsField
|
||||
label="Navigation style"
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act, renderHook } from '@testing-library/react';
|
||||
import { AppearanceSection } from '../AppearanceSection';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { SETTINGS_ITEMS } from '../registry';
|
||||
import { SIDEBAR_WIDTH, SIDEBAR_MODE_KEY, SIDEBAR_WIDTH_KEY } from '@/hooks/use-sidebar-layout';
|
||||
import { subscribeToPreferenceWrites } from '@/lib/preferences/preferenceEvents';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
// AppearanceSection drives the shared theme store. Reset it to a known Signature
|
||||
// baseline (readability off, effects full) before each test so the disabled-state
|
||||
@@ -18,7 +22,10 @@ function resetTheme() {
|
||||
}
|
||||
|
||||
describe('AppearanceSection', () => {
|
||||
beforeEach(() => resetTheme());
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetTheme();
|
||||
});
|
||||
|
||||
it('renders the four refresh sections above Theme', () => {
|
||||
render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
@@ -93,8 +100,12 @@ describe('AppearanceSection', () => {
|
||||
|
||||
it('readability locks the header + chart controls and disables the glow slider', () => {
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
// Baseline: nothing reduced, so no slider is disabled.
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBe(0);
|
||||
// Baseline: only the sidebar width slider is disabled (Fixed mode leaves
|
||||
// it unapplied); the readability-gated controls are all active.
|
||||
const glowLocked = () => !!container.querySelector('[aria-label="Ambient glow"][data-disabled]');
|
||||
const sidebarLocked = () => !!container.querySelector('[aria-label="Sidebar width"][data-disabled]');
|
||||
expect(glowLocked()).toBe(false);
|
||||
expect(sidebarLocked()).toBe(true);
|
||||
expect(screen.getByRole('radiogroup', { name: 'Header style' }).getAttribute('aria-disabled')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
|
||||
@@ -104,7 +115,7 @@ describe('AppearanceSection', () => {
|
||||
expect((screen.getByRole('switch', { name: 'Reduced effects' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
// Effective reduced (readability || reducedEffects) disables the glow slider
|
||||
// even though reducedEffects itself is still off.
|
||||
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
|
||||
expect(glowLocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('reduced motion is independent of readability and toggles data-motion on <html>', () => {
|
||||
@@ -231,3 +242,146 @@ describe('AppearanceSection', () => {
|
||||
expect(screen.queryByText(/this browser/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppearanceSection sidebar layout', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetTheme();
|
||||
});
|
||||
|
||||
// Radix puts the passed aria-label on the slider ROOT span (the element
|
||||
// that also carries data-disabled and the keyboard handler); the visible
|
||||
// thumb is a descendant with role="slider" and the value attributes.
|
||||
const sliderRoot = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>('[aria-label="Sidebar width"]');
|
||||
const sliderThumb = (container: HTMLElement) =>
|
||||
sliderRoot(container)?.querySelector<HTMLElement>('[role="slider"]');
|
||||
|
||||
it('renders the sidebar layout rows with Fixed as the default and the width slider locked', () => {
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
expect(screen.getByText('Sidebar layout')).toBeTruthy();
|
||||
expect(screen.getByRole('radio', { name: 'Fixed' }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(screen.getByRole('radio', { name: 'Resizable' }).getAttribute('aria-checked')).toBe('false');
|
||||
// Fixed mode leaves the width preference unapplied, so the slider locks.
|
||||
expect(sliderRoot(container)?.getAttribute('data-disabled')).not.toBeNull();
|
||||
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('256');
|
||||
});
|
||||
|
||||
it('switching to Resizable unlocks the width slider without writing the width field', () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '340');
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
const notify = vi.fn();
|
||||
const unsub = subscribeToPreferenceWrites(notify);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Resizable' }));
|
||||
expect(sliderRoot(container)?.getAttribute('data-disabled')).toBeNull();
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith('appearance', ['sidebarMode']);
|
||||
// The mode write never changes the width field: the stored 340 survives
|
||||
// and the slider keeps showing it.
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('340');
|
||||
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('340');
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('the width slider drafts locally and writes once on commit', () => {
|
||||
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
const notify = vi.fn();
|
||||
const unsub = subscribeToPreferenceWrites(notify);
|
||||
expect(sliderRoot(container)?.getAttribute('data-disabled')).toBeNull();
|
||||
|
||||
// Radix commits keyboard steps immediately (drag commits on release);
|
||||
// onValueCommit is the single write path for both.
|
||||
fireEvent.keyDown(sliderRoot(container)!, { key: 'End' });
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith('appearance', ['sidebarWidth']);
|
||||
expect(Number(sliderThumb(container)?.getAttribute('aria-valuenow'))).toBe(SIDEBAR_WIDTH.max);
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe(String(SIDEBAR_WIDTH.max));
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('a targeted sidebar reset restores Fixed and the default width, leaving other fields untouched', () => {
|
||||
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '400');
|
||||
localStorage.setItem('sencho.appearance.density', 'compact');
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset sidebar layout' }));
|
||||
expect(localStorage.getItem(SIDEBAR_MODE_KEY)).toBe('fixed');
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe(String(SIDEBAR_WIDTH.default));
|
||||
// Unrelated appearance fields survive the targeted reset.
|
||||
expect(localStorage.getItem('sencho.appearance.density')).toBe('compact');
|
||||
// The slider re-locks and the thumb lands on the default width.
|
||||
expect(sliderRoot(container)?.getAttribute('data-disabled')).not.toBeNull();
|
||||
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe(String(SIDEBAR_WIDTH.default));
|
||||
});
|
||||
|
||||
it('the width draft re-syncs from the shared preference without issuing a write', () => {
|
||||
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
const notify = vi.fn();
|
||||
const unsub = subscribeToPreferenceWrites(notify);
|
||||
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('300');
|
||||
|
||||
// An apply-path change (targeted reset, hydration, another tab) lands
|
||||
// via the settings-changed event; the slider follows it with no write.
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '352');
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
});
|
||||
expect(sliderThumb(container)?.getAttribute('aria-valuenow')).toBe('352');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('registers the sidebar search keywords on the appearance entry', () => {
|
||||
const appearance = SETTINGS_ITEMS.find((item) => item.id === 'appearance');
|
||||
expect(appearance).toBeTruthy();
|
||||
for (const term of ['sidebar', 'resize', 'resizable', 'pane', 'width', 'layout']) {
|
||||
expect(appearance?.keywords, `keyword ${term}`).toContain(term);
|
||||
}
|
||||
});
|
||||
|
||||
it('dragging the slider drafts locally and writes only on pointer release', () => {
|
||||
localStorage.setItem(SIDEBAR_MODE_KEY, 'resizable');
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
|
||||
const { container } = render(<AppearanceSection onResetAppearance={() => {}} onResetNavigation={() => {}} />);
|
||||
const notify = vi.fn();
|
||||
const unsub = subscribeToPreferenceWrites(notify);
|
||||
const root = sliderRoot(container)!;
|
||||
expect(root.getAttribute('data-disabled')).toBeNull();
|
||||
|
||||
// Radix's pointer handlers consult the captured pointer position
|
||||
// against the slider rect; pin both so the drag math is deterministic.
|
||||
const rect: DOMRect = { width: 216, height: 20, top: 0, left: 0, bottom: 20, right: 216, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (root.contains(this)) return rect;
|
||||
return { width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'hasPointerCapture').mockReturnValue(true);
|
||||
vi.spyOn(HTMLElement.prototype, 'setPointerCapture').mockImplementation(() => {});
|
||||
vi.spyOn(HTMLElement.prototype, 'releasePointerCapture').mockImplementation(() => {});
|
||||
// Radix maps pointer x onto [min, max] over the track rect, then snaps
|
||||
// to the 4px step. Compute the expected value the same way.
|
||||
const valueAt = (x: number) => Math.min(SIDEBAR_WIDTH.max, Math.max(SIDEBAR_WIDTH.min,
|
||||
Math.round((SIDEBAR_WIDTH.min + (x / 216) * (SIDEBAR_WIDTH.max - SIDEBAR_WIDTH.min)) / 4) * 4));
|
||||
|
||||
fireEvent.pointerDown(root, { pointerId: 1, clientX: 108, button: 0 });
|
||||
const mid = valueAt(140);
|
||||
fireEvent.pointerMove(root, { pointerId: 1, clientX: 140 });
|
||||
// Mid-drag: the draft follows the pointer but nothing is queued.
|
||||
expect(Number(sliderThumb(container)?.getAttribute('aria-valuenow'))).toBe(mid);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('300');
|
||||
|
||||
fireEvent.pointerUp(root, { pointerId: 1, clientX: 140 });
|
||||
// Release commits exactly once: one bus notify and one localStorage write.
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith('appearance', ['sidebarWidth']);
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe(String(mid));
|
||||
unsub();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
group: 'personal',
|
||||
label: 'Appearance',
|
||||
description: 'Visual style, readability, theme, accent, charts, display, and navigation preferences saved to your account.',
|
||||
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', 'sync', 'account'],
|
||||
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', 'sync', 'account', 'sidebar', 'resize', 'resizable', 'pane', 'width', 'layout'],
|
||||
tier: null,
|
||||
scope: 'account',
|
||||
},
|
||||
|
||||
@@ -94,12 +94,27 @@ describe('apply* write path', () => {
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('skips the identical-value localStorage write but still broadcasts', () => {
|
||||
it('skips the identical-value localStorage write and its broadcast', () => {
|
||||
applySidebarModeValue('fixed');
|
||||
let changed = 0;
|
||||
const listener = () => { changed += 1; };
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, listener);
|
||||
applySidebarModeValue('fixed');
|
||||
expect(changed).toBe(0);
|
||||
window.removeEventListener(SENCHO_SETTINGS_CHANGED, listener);
|
||||
});
|
||||
|
||||
it('broadcasts once when the setter path changes storage (shell separator commit)', () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
|
||||
const { result } = renderHook(() => useSidebarLayout());
|
||||
let changed = 0;
|
||||
const listener = () => { changed += 1; };
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, listener);
|
||||
|
||||
act(() => result.current.setSidebarWidth(352));
|
||||
// The writing instance keeps its own state; other mounted instances
|
||||
// re-read storage on this event and converge on the new width.
|
||||
expect(result.current.sidebarWidth).toBe(352);
|
||||
expect(changed).toBe(1);
|
||||
window.removeEventListener(SENCHO_SETTINGS_CHANGED, listener);
|
||||
});
|
||||
|
||||
@@ -73,14 +73,17 @@ export function currentSidebarWidth(): number {
|
||||
|
||||
/** Guarded localStorage write shared by every path in this module: skips the
|
||||
* identical-value write and tolerates an unavailable store (private mode,
|
||||
* quota). */
|
||||
function writeStoredValue(key: string, value: string): void {
|
||||
* quota). Reports whether a new value actually landed. */
|
||||
function writeStoredValue(key: string, value: string): boolean {
|
||||
try {
|
||||
if (window.localStorage.getItem(key) !== value) {
|
||||
window.localStorage.setItem(key, value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
// ignore; localStorage may be unavailable (private mode, quota)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +91,14 @@ function writeStoredValue(key: string, value: string): void {
|
||||
* instances arrives via the settings-changed event; localStorage is written
|
||||
* here). Hydration-side writes do not notify the sync bus. */
|
||||
export function applySidebarModeValue(next: SidebarMode): void {
|
||||
writeStoredValue(SIDEBAR_MODE_KEY, next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
const changed = writeStoredValue(SIDEBAR_MODE_KEY, next);
|
||||
if (changed) window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}
|
||||
|
||||
/** Apply a preferred width (already sanitized) without notifying the bus. */
|
||||
export function applySidebarWidthValue(next: number): void {
|
||||
writeStoredValue(SIDEBAR_WIDTH_KEY, String(next));
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
const changed = writeStoredValue(SIDEBAR_WIDTH_KEY, String(next));
|
||||
if (changed) window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}
|
||||
|
||||
interface SidebarLayoutState {
|
||||
@@ -112,13 +115,20 @@ export function useSidebarLayout(): SidebarLayoutState {
|
||||
// Persist-on-change, matching the use-density contract: the hook's own state
|
||||
// is the write source, so a setter state change lands in localStorage even
|
||||
// when no apply* path was involved. Guarded, so a re-read (same value) is a
|
||||
// no-op write.
|
||||
// no-op write. A setter write that actually changes storage also broadcasts
|
||||
// the settings-changed event so other mounted instances (e.g. the Settings
|
||||
// page while the shell separator commits) re-read; this is the setter-side
|
||||
// counterpart of the apply* broadcasts above.
|
||||
useEffect(() => {
|
||||
writeStoredValue(SIDEBAR_MODE_KEY, sidebarMode);
|
||||
if (writeStoredValue(SIDEBAR_MODE_KEY, sidebarMode)) {
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}
|
||||
}, [sidebarMode]);
|
||||
|
||||
useEffect(() => {
|
||||
writeStoredValue(SIDEBAR_WIDTH_KEY, String(sidebarWidth));
|
||||
if (writeStoredValue(SIDEBAR_WIDTH_KEY, String(sidebarWidth))) {
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
hydrateAppearanceDocument,
|
||||
hydrateNavigationDocument,
|
||||
} from '../preferencesDocuments';
|
||||
import { resetSidebarLayout } from '../resetPreferences';
|
||||
|
||||
interface MockResponse {
|
||||
ok: boolean;
|
||||
@@ -475,4 +476,56 @@ describe('preference sync layer', () => {
|
||||
expect(recordedCalls().filter((c) => c.method === 'PUT')).toHaveLength(0);
|
||||
expect(inspectQueue('appearance').kind).toBeNull();
|
||||
});
|
||||
|
||||
it('the targeted sidebar reset writes defaults locally and enqueues one write with both fields', async () => {
|
||||
// No prior row is known, so the PUT resolves its baseline first, finds the
|
||||
// row absent, and converts to a create-if-absent migrate carrying the
|
||||
// reset values (document rebuilt at send time).
|
||||
apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
|
||||
if (opts?.method === 'POST') {
|
||||
return jsonResponse(201, { domain: 'appearance', revision: 1, row: { domain: 'appearance', schemaVersion: 1, revision: 1, updatedAt: 1 } });
|
||||
}
|
||||
return jsonResponse(200, { preferences: {} });
|
||||
});
|
||||
localStorage.setItem('sencho.appearance.sidebarMode', 'resizable');
|
||||
localStorage.setItem('sencho.appearance.sidebarWidth', '400');
|
||||
localStorage.setItem('sencho.appearance.theme', 'dim');
|
||||
|
||||
resetSidebarLayout();
|
||||
|
||||
// Local defaults land immediately, unrelated appearance fields untouched.
|
||||
expect(localStorage.getItem('sencho.appearance.sidebarMode')).toBe('fixed');
|
||||
expect(localStorage.getItem('sencho.appearance.sidebarWidth')).toBe('256');
|
||||
expect(localStorage.getItem('sencho.appearance.theme')).toBe('dim');
|
||||
await flushPendingWrites();
|
||||
await vi.waitFor(() => expect(recordedCalls().some((c) => c.method === 'POST')).toBe(true));
|
||||
const mutations = recordedCalls().filter((c) => c.method === 'PUT' || c.method === 'POST' || c.method === 'DELETE');
|
||||
expect(mutations).toHaveLength(1);
|
||||
expect(mutations[0].path).toBe('/user-preferences/appearance/migrate');
|
||||
expect(mutations[0].body).toMatchObject({ sidebarMode: 'fixed', sidebarWidth: 256 });
|
||||
expect(inspectQueue('appearance').settling).toBe(false);
|
||||
});
|
||||
|
||||
it('the targeted sidebar reset after a known revision PUTs the defaults conditionally', async () => {
|
||||
apiFetch.mockImplementation(async (_path: string, opts?: RequestInit) => {
|
||||
if (opts?.method === 'PUT') {
|
||||
return jsonResponse(200, { domain: 'appearance', revision: 5, updatedAt: 1 });
|
||||
}
|
||||
return jsonResponse(200, { preferences: {} });
|
||||
});
|
||||
adoptKnownRevision('appearance', 4);
|
||||
localStorage.setItem('sencho.appearance.sidebarMode', 'resizable');
|
||||
localStorage.setItem('sencho.appearance.sidebarWidth', '400');
|
||||
|
||||
resetSidebarLayout();
|
||||
|
||||
await flushPendingWrites();
|
||||
await vi.waitFor(() => expect(recordedCalls().some((c) => c.method === 'PUT')).toBe(true));
|
||||
const puts = recordedCalls().filter((c) => c.method === 'PUT');
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0].path).toBe('/user-preferences/appearance');
|
||||
expect(puts[0].body).toMatchObject({ expectedRevision: 4, sidebarMode: 'fixed', sidebarWidth: 256 });
|
||||
expect(recordedCalls().filter((c) => c.method === 'DELETE')).toHaveLength(0);
|
||||
expect(inspectQueue('appearance').settling).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user