Template
feat(app): add AppStateProvider for shared global settings
Central context for the settings a template app shares instead of prop drilling: theme, viewMode, sidebarCollapsed and the search palette, with the ⌘K/Ctrl+K shortcut moved out of App. - settings persist on user action only (an effect-written theme would freeze the OS preference for users who never toggled) - theme still boots from the <html data-theme> attribute the pre-paint script set; viewMode mirrors to data-view-mode as the audience-preset hook and deliberately gates nothing yet - Topbar: viewMode segmented control, theme toggle and search now read the context; useAppState throws outside the provider - tests/appState.test.tsx: defaults, persistence, rehydration, garbage storage values, ⌘K shortcut
This commit is contained in:
1 parent
72ff61f94d
commit
d6c7464170
11 files changed
+414
-72
No files matched your search
@@ -35,6 +35,7 @@ Browser tooling here has no console access. Runtime errors therefore render into
|
||||
|
||||
## Smart Utils & AI Workflow
|
||||
|
||||
- **Global app state**: `src/app/` — `AppStateProvider` (mounted in `main.tsx`) owns the few settings the whole app shares: `theme`, `viewMode`, `sidebarCollapsed`, `searchOpen`, plus the ⌘K/Ctrl+K shortcut. Read them anywhere with `useAppState()` (import from `./app` or `../app`); it throws outside the provider on purpose. Add a setting to `appState.ts` (types) + `AppStateProvider.tsx` (state, DOM mirror, `STORAGE_KEYS` persistence) — never thread a new prop chain. Section-local state (active tab, selected pattern) stays in its section. Persisted settings are written **only on user action**, so an untouched app keeps following `prefers-color-scheme`. `theme` always boots from the `<html data-theme>` attribute the pre-paint script set (storage key `ns-theme` is shared with `index.html`); `viewMode` mirrors to `<html data-view-mode>` and currently gates nothing — it is the audience preset hook for per-persona views.
|
||||
- **Class helper**: `src/utils/cn.ts` — zero-dependency classnames merging utility for conditional component styles.
|
||||
- **Timer helper**: `src/utils/useTimeout.ts` — `schedule(fn, ms)` / `clear()`; auto-cancels pending timers on unmount. Never fire-and-forget a raw `setTimeout` in a component.
|
||||
- **Smart CSS**: `src/utils.css` — ready-to-use helpers (`.dot-canvas`, `.surface-card`, `.surface-sidebar`, `.badge-*`, `.hover-lift`, `.kbd-tag`, `.skeleton-pulse`, `.progress-*`, `.ui-table-*`, `.dialog-*`, `.sheet-*`, `.alert-*`, `.ui-tabs-*`, `.switch-*`).
|
||||
|
||||
@@ -46,6 +46,7 @@ src/
|
||||
index.css ← fonts, token import, base resets
|
||||
utils.css ← token-compliant utility classes
|
||||
App.css ← application showcase styles
|
||||
app/ ← AppStateProvider + useAppState(): theme, viewMode, sidebar, ⌘K search
|
||||
components/
|
||||
ui/ ← composable primitives (Overlay, Dialog, Sheet, Tabs, Alert, …)
|
||||
icon-registry.ts← the one icon name → lucide component map
|
||||
|
||||
+15
-1
@@ -158,6 +158,20 @@
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* viewMode switcher — the shared .segmented look, one size down and quiet
|
||||
enough to sit between the header links. */
|
||||
.top-view-mode {
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.top-view-mode button {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.top-links a {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
@@ -1168,7 +1182,7 @@ footer {
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
@media (max-width: 1020px) {
|
||||
.topbar { padding: 0 var(--space-5); gap: var(--space-4); }
|
||||
.top-links a { display: none; }
|
||||
.top-links a, .top-view-mode { display: none; }
|
||||
.body-layout { grid-template-columns: 200px minmax(0, 1fr); }
|
||||
.hero-section, .content-grid { padding-left: var(--space-7); padding-right: var(--space-7); }
|
||||
.content-grid { gap: var(--space-7); grid-template-columns: minmax(0, 1fr) 190px; }
|
||||
|
||||
+7
-21
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import './App.css'
|
||||
import { useAppState } from './app'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { Hero } from './components/Hero'
|
||||
@@ -22,24 +23,13 @@ import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections
|
||||
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
|
||||
|
||||
export default function App() {
|
||||
/* App-wide settings live in AppStateProvider; section state stays local. */
|
||||
const { searchOpen, closeSearch, sidebarCollapsed, toggleSidebar } = useAppState()
|
||||
const [active, setActive] = useState('Buttons')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [buttonTab, setButtonTab] = useState('Preview')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [pattern, setPattern] = useState<PatternId>('metrics')
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
|
||||
setActive(item.label)
|
||||
@@ -65,12 +55,8 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Topbar onOpenSearch={() => setSearchOpen(true)} onMenuToggle={() => setMenu(!menu)} />
|
||||
<SearchPalette
|
||||
isOpen={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onSelect={handleSearchSelect}
|
||||
/>
|
||||
<Topbar onMenuToggle={() => setMenu(!menu)} />
|
||||
<SearchPalette isOpen={searchOpen} onClose={closeSearch} onSelect={handleSearchSelect} />
|
||||
|
||||
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
<Sidebar
|
||||
@@ -78,7 +64,7 @@ export default function App() {
|
||||
groups={navigationGroups}
|
||||
onSelect={handleNavigate}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
|
||||
onToggleCollapse={toggleSidebar}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
AppStateContext,
|
||||
VIEW_MODES,
|
||||
type AppStateContextValue,
|
||||
type Theme,
|
||||
type ViewMode,
|
||||
} from './appState'
|
||||
import { STORAGE_KEYS, readFlag, readSetting, writeFlag, writeSetting } from './persist'
|
||||
|
||||
/** Theme is a data attribute + class on `<html>`; tokens.css defines both
|
||||
palettes. Kept in sync with the pre-paint script in index.html. */
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = theme
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
root.classList.toggle('light', theme === 'light')
|
||||
}
|
||||
|
||||
/** View mode rides on `<html>` as well, so styling and agent tooling can hook
|
||||
it without a prop. */
|
||||
function applyViewMode(viewMode: ViewMode) {
|
||||
document.documentElement.dataset.viewMode = viewMode
|
||||
}
|
||||
|
||||
/** Read the theme the pre-paint script already applied, so first paint stays
|
||||
flash-free and honors both the saved preference and `prefers-color-scheme`
|
||||
(tokens.css defaults to dark). Never writes the DOM during state init. */
|
||||
function initialTheme(): Theme {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
function initialViewMode(): ViewMode {
|
||||
return readSetting(STORAGE_KEYS.viewMode, VIEW_MODES) ?? 'default'
|
||||
}
|
||||
|
||||
function initialSidebarCollapsed(): boolean {
|
||||
return readFlag(STORAGE_KEYS.sidebarCollapsed) ?? false
|
||||
}
|
||||
|
||||
type AppStateProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the app-wide settings: theme, view mode, sidebar collapse and the
|
||||
* search palette, plus the ⌘K / Ctrl+K shortcut that opens it from anywhere.
|
||||
* Mount it once, above everything that calls `useAppState()`.
|
||||
*/
|
||||
export function AppStateProvider({ children }: AppStateProviderProps) {
|
||||
const [theme, setThemeState] = useState<Theme>(initialTheme)
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(initialViewMode)
|
||||
const [sidebarCollapsed, setSidebarCollapsedState] = useState<boolean>(initialSidebarCollapsed)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
applyViewMode(viewMode)
|
||||
}, [viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
/* Settings persist on explicit user action only. An effect would also write
|
||||
the OS-derived theme during the first mount, freezing the preference and
|
||||
killing `prefers-color-scheme` for anyone who never toggled. */
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next)
|
||||
writeSetting(STORAGE_KEYS.theme, next)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
}, [theme, setTheme])
|
||||
|
||||
const setViewMode = useCallback((next: ViewMode) => {
|
||||
setViewModeState(next)
|
||||
writeSetting(STORAGE_KEYS.viewMode, next)
|
||||
}, [])
|
||||
|
||||
const setSidebarCollapsed = useCallback((next: boolean) => {
|
||||
setSidebarCollapsedState(next)
|
||||
writeFlag(STORAGE_KEYS.sidebarCollapsed, next)
|
||||
}, [])
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed(!sidebarCollapsed)
|
||||
}, [sidebarCollapsed, setSidebarCollapsed])
|
||||
|
||||
const openSearch = useCallback(() => setSearchOpen(true), [])
|
||||
const closeSearch = useCallback(() => setSearchOpen(false), [])
|
||||
const toggleSearch = useCallback(() => setSearchOpen(prev => !prev), [])
|
||||
|
||||
const value = useMemo<AppStateContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
viewMode,
|
||||
sidebarCollapsed,
|
||||
searchOpen,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setViewMode,
|
||||
setSidebarCollapsed,
|
||||
toggleSidebar,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
toggleSearch,
|
||||
}),
|
||||
[
|
||||
theme,
|
||||
viewMode,
|
||||
sidebarCollapsed,
|
||||
searchOpen,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setViewMode,
|
||||
setSidebarCollapsed,
|
||||
toggleSidebar,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
toggleSearch,
|
||||
],
|
||||
)
|
||||
|
||||
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/* ── Global application state ────────────────────────────────────────────────
|
||||
One provider (AppStateProvider) owns every setting the whole app shares:
|
||||
theme, audience preset, shell layout and the search palette. Pages and
|
||||
sections read it with `useAppState()` instead of threading props through
|
||||
the tree — and a template user gets one obvious place to add their own.
|
||||
|
||||
Keep this list short. State belongs here only when two unrelated parts of
|
||||
the app need it; section-local state (active tab, open accordion, selected
|
||||
pattern) stays in the section that owns it.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
/** Audience preset for the template. Nothing gates on it yet — the value is
|
||||
carried in context and mirrored to `<html data-view-mode>` so per-persona
|
||||
views can be layered on later without touching a single consumer. */
|
||||
export type ViewMode = 'default' | 'compact'
|
||||
|
||||
/** Runtime allow-list, also used to validate whatever `localStorage` hands back. */
|
||||
export const VIEW_MODES: readonly ViewMode[] = ['default', 'compact']
|
||||
|
||||
/** Settings the user owns — these are persisted between visits. */
|
||||
export type AppSettings = {
|
||||
theme: Theme
|
||||
viewMode: ViewMode
|
||||
sidebarCollapsed: boolean
|
||||
}
|
||||
|
||||
/** Settings plus the transient shell flags derived from them. */
|
||||
export type AppState = AppSettings & {
|
||||
searchOpen: boolean
|
||||
}
|
||||
|
||||
export type AppStateContextValue = AppState & {
|
||||
setTheme: (theme: Theme) => void
|
||||
toggleTheme: () => void
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
toggleSidebar: () => void
|
||||
openSearch: () => void
|
||||
closeSearch: () => void
|
||||
toggleSearch: () => void
|
||||
}
|
||||
|
||||
export const AppStateContext = createContext<AppStateContextValue | null>(null)
|
||||
|
||||
/** Read the shared app state. Throws outside the provider: a missing provider
|
||||
is a loud dev error, never a silently-uncontrolled component. */
|
||||
export function useAppState(): AppStateContextValue {
|
||||
const context = useContext(AppStateContext)
|
||||
if (!context) throw new Error('useAppState must be used inside <AppStateProvider>')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Public surface of the app-state layer — import from './app' or '../app'. */
|
||||
export { AppStateProvider } from './AppStateProvider'
|
||||
export {
|
||||
AppStateContext,
|
||||
VIEW_MODES,
|
||||
useAppState,
|
||||
type AppSettings,
|
||||
type AppState,
|
||||
type AppStateContextValue,
|
||||
type Theme,
|
||||
type ViewMode,
|
||||
} from './appState'
|
||||
export { STORAGE_KEYS } from './persist'
|
||||
@@ -0,0 +1,41 @@
|
||||
/* localStorage access for app settings. Private mode, disabled storage and
|
||||
non-browser environments all throw on access, so every call is guarded and
|
||||
degrades to "the setting just doesn't persist".
|
||||
|
||||
`ns-theme` is a shared contract: the pre-paint script in index.html reads
|
||||
it before first paint, so changing the key means changing index.html too. */
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
theme: 'ns-theme',
|
||||
viewMode: 'ns-view-mode',
|
||||
sidebarCollapsed: 'ns-sidebar-collapsed',
|
||||
} as const
|
||||
|
||||
/** Stored string, or `undefined` when absent or outside the allowed set. */
|
||||
export function readSetting<T extends string>(key: string, allowed: readonly T[]): T | undefined {
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored === null) return undefined
|
||||
return (allowed as readonly string[]).includes(stored) ? (stored as T) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSetting(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored boolean, or `undefined` when absent or unparseable. */
|
||||
export function readFlag(key: string): boolean | undefined {
|
||||
const stored = readSetting(key, ['true', 'false'] as const)
|
||||
return stored === undefined ? undefined : stored === 'true'
|
||||
}
|
||||
|
||||
export function writeFlag(key: string, value: boolean): void {
|
||||
writeSetting(key, value ? 'true' : 'false')
|
||||
}
|
||||
+27
-49
@@ -1,58 +1,18 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { VIEW_MODES, useAppState, type ViewMode } from '../app'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
/**
|
||||
* Read the theme the pre-paint script in index.html already applied to
|
||||
* <html> — that keeps first paint flash-free and honors both the saved
|
||||
* preference and the OS `prefers-color-scheme` when nothing is saved.
|
||||
* This hook never touches the DOM during render/state-initialization.
|
||||
*/
|
||||
function getInitialTheme(): Theme {
|
||||
const attr = document.documentElement.getAttribute('data-theme')
|
||||
return attr === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
/** App theme is a data attribute + class on <html>; tokens.css defines both palettes. */
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = theme
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
root.classList.toggle('light', theme === 'light')
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeState(next)
|
||||
persistTheme(next)
|
||||
}, [theme])
|
||||
|
||||
return { theme, toggle }
|
||||
}
|
||||
|
||||
function persistTheme(theme: Theme) {
|
||||
try {
|
||||
localStorage.setItem('ns-theme', theme)
|
||||
} catch {
|
||||
/* private mode — theme just won't persist */
|
||||
}
|
||||
/** `viewMode` is the template's audience preset (see src/app/appState.ts). */
|
||||
const VIEW_MODE_LABELS: Record<ViewMode, string> = {
|
||||
default: 'Default',
|
||||
compact: 'Compact',
|
||||
}
|
||||
|
||||
type TopbarProps = {
|
||||
onOpenSearch: () => void
|
||||
onMenuToggle: () => void
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
export function Topbar({ onMenuToggle }: TopbarProps) {
|
||||
const { theme, toggleTheme, viewMode, setViewMode, openSearch } = useAppState()
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
@@ -67,7 +27,7 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="top-search"
|
||||
onClick={onOpenSearch}
|
||||
onClick={openSearch}
|
||||
aria-label="Search components, tokens, docs (Press ⌘K)"
|
||||
>
|
||||
<Icon name="search" size={16} />
|
||||
@@ -78,10 +38,28 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
<nav className="top-links">
|
||||
<a href="#docs">Docs</a>
|
||||
<a href="#github">GitHub ↗</a>
|
||||
|
||||
{/* Audience preset held by the app state. Nothing gates on it yet — it
|
||||
is wired here so the value is reachable and persisted from the UI. */}
|
||||
<div className="segmented top-view-mode" role="group" aria-label="View mode">
|
||||
{VIEW_MODES.map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={mode === viewMode ? 'selected' : undefined}
|
||||
aria-pressed={mode === viewMode}
|
||||
title={`Switch to ${VIEW_MODE_LABELS[mode].toLowerCase()} view`}
|
||||
onClick={() => setViewMode(mode)}
|
||||
>
|
||||
{VIEW_MODE_LABELS[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={toggle}
|
||||
onClick={toggleTheme}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { AppStateProvider } from './app'
|
||||
import { ErrorBoundary } from './dev/ErrorBoundary.tsx'
|
||||
import { installDevErrorSink } from './dev/errorSink.ts'
|
||||
|
||||
@@ -12,7 +13,9 @@ installDevErrorSink()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
<AppStateProvider>
|
||||
<App />
|
||||
</AppStateProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppStateProvider, useAppState } from '../src/app'
|
||||
|
||||
function Probe() {
|
||||
const { theme, viewMode, sidebarCollapsed, searchOpen, toggleTheme, setViewMode, toggleSidebar } = useAppState()
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="state">{`${theme}|${viewMode}|${sidebarCollapsed}|${searchOpen}`}</span>
|
||||
<button onClick={toggleTheme}>theme</button>
|
||||
<button onClick={() => setViewMode('compact')}>compact</button>
|
||||
<button onClick={toggleSidebar}>sidebar</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderApp() {
|
||||
return render(
|
||||
<AppStateProvider>
|
||||
<Probe />
|
||||
</AppStateProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const state = () => screen.getByTestId('state').textContent
|
||||
|
||||
describe('AppStateProvider', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('dark', 'light')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.removeAttribute('data-view-mode')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('throws when useAppState is called outside the provider', () => {
|
||||
// React logs the render error too — keep the output readable.
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<Probe />)).toThrow(/AppStateProvider/)
|
||||
})
|
||||
|
||||
it('starts from the defaults and mirrors them onto <html>', () => {
|
||||
renderApp()
|
||||
|
||||
expect(state()).toBe('dark|default|false|false')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
expect(document.documentElement.dataset.viewMode).toBe('default')
|
||||
})
|
||||
|
||||
it('persists settings on user action only, so prefers-color-scheme keeps working', () => {
|
||||
renderApp()
|
||||
|
||||
// Nothing was stored by the initial mount — an effect-driven write would
|
||||
// freeze the OS-derived theme for users who never toggled anything.
|
||||
expect(localStorage.getItem('ns-theme')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'theme' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'compact' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'sidebar' }))
|
||||
|
||||
expect(localStorage.getItem('ns-theme')).toBe('light')
|
||||
expect(localStorage.getItem('ns-view-mode')).toBe('compact')
|
||||
expect(localStorage.getItem('ns-sidebar-collapsed')).toBe('true')
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(document.documentElement.dataset.viewMode).toBe('compact')
|
||||
expect(state()).toBe('light|compact|true|false')
|
||||
})
|
||||
|
||||
it('rehydrates stored settings on the next mount', () => {
|
||||
localStorage.setItem('ns-theme', 'light')
|
||||
localStorage.setItem('ns-view-mode', 'compact')
|
||||
localStorage.setItem('ns-sidebar-collapsed', 'true')
|
||||
// Theme boots from the attribute index.html already painted (it resolved
|
||||
// storage / prefers-color-scheme), so state can never contradict the DOM.
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
renderApp()
|
||||
|
||||
expect(state()).toBe('light|compact|true|false')
|
||||
})
|
||||
|
||||
it('ignores stored values outside the allowed set', () => {
|
||||
localStorage.setItem('ns-view-mode', 'space')
|
||||
localStorage.setItem('ns-sidebar-collapsed', 'yes')
|
||||
renderApp()
|
||||
|
||||
expect(state()).toBe('dark|default|false|false')
|
||||
})
|
||||
|
||||
it('toggles the search palette with ⌘K / Ctrl+K from anywhere', () => {
|
||||
function SearchProbe() {
|
||||
const { searchOpen } = useAppState()
|
||||
return <span data-testid="search">{String(searchOpen)}</span>
|
||||
}
|
||||
render(
|
||||
<AppStateProvider>
|
||||
<SearchProbe />
|
||||
</AppStateProvider>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'k', metaKey: true })
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'k', ctrlKey: true })
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('false')
|
||||
|
||||
// A plain "k" must not hijack typing.
|
||||
fireEvent.keyDown(window, { key: 'k' })
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('false')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user