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
@@ -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