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
+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} />
|
||||
|
||||
Reference in new issue
Block a user