91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
import { createContext, useContext, useEffect, useState } from 'react'
|
|
|
|
/**
|
|
* Two named appearances, not a palette picker.
|
|
*
|
|
* The original control was a row of six raw colour dots that changed which hue
|
|
* the confirm/approve buttons used. It read as decoration sitting next to the
|
|
* confidence-band legend — two colour controls side by side, only one of which
|
|
* meant anything. The accent is now fixed to the brand, and this control does
|
|
* the thing a reviewer actually wants: choose the light level for the room.
|
|
*
|
|
* There was a third mode, `eye` — the light palette under a warm sepia filter,
|
|
* for long shifts. It is gone. It was a third appearance to keep in sync
|
|
* forever, it tinted the scanned page a reviewer is trying to read accurately,
|
|
* and both the OS and the monitor already offer the same warmth system-wide.
|
|
*/
|
|
export type Mode = 'light' | 'dark'
|
|
|
|
const MODE_KEY = 'superbill.theme.mode'
|
|
const MODES: { value: Mode; label: string; hint: string }[] = [
|
|
{ value: 'light', label: 'Light', hint: 'Bright rooms' },
|
|
{ value: 'dark', label: 'Dark', hint: 'Low-light rooms' },
|
|
]
|
|
|
|
type ThemeContextValue = {
|
|
mode: Mode
|
|
setMode: (m: Mode) => void
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
|
|
function readStored<T extends string>(key: string, valid: readonly T[], fallback: T): T {
|
|
const v = localStorage.getItem(key)
|
|
return (valid as readonly string[]).includes(v ?? '') ? (v as T) : fallback
|
|
}
|
|
|
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
// A stored `eye` from before that mode was removed is not in the valid list,
|
|
// so readStored falls back to `dark` rather than leaving the app in a mode
|
|
// with no styles behind it.
|
|
const [mode, setMode] = useState<Mode>(() =>
|
|
readStored(MODE_KEY, ['light', 'dark'] as const, 'dark'),
|
|
)
|
|
|
|
useEffect(() => {
|
|
const root = document.documentElement
|
|
root.classList.toggle('dark', mode === 'dark')
|
|
root.setAttribute('data-mode', mode)
|
|
localStorage.setItem(MODE_KEY, mode)
|
|
}, [mode])
|
|
|
|
return <ThemeContext.Provider value={{ mode, setMode }}>{children}</ThemeContext.Provider>
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
const ctx = useContext(ThemeContext)
|
|
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
|
|
return ctx
|
|
}
|
|
|
|
/** Two text pills in a pill, matching the mockup's header control. The icons
|
|
* that used to sit beside each label are gone with the third mode: with only
|
|
* Light and Dark left, the two words are already unambiguous and the glyphs
|
|
* were competing with the Sign out button next to them.
|
|
*
|
|
* Colours here are literal white-on-black alphas rather than tokens, and have
|
|
* to be: this control sits ON the brand gradient, which is the one saturated
|
|
* surface in the product, and a token-coloured control there reads as a hole
|
|
* punched through the band. See `.sb-themes` in index.css. */
|
|
export function ThemeSwitcher() {
|
|
const { mode, setMode } = useTheme()
|
|
|
|
return (
|
|
<div className="sb-themes" role="radiogroup" aria-label="Appearance">
|
|
{MODES.map((m) => (
|
|
<button
|
|
key={m.value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={mode === m.value}
|
|
aria-pressed={mode === m.value}
|
|
title={m.hint}
|
|
onClick={() => setMode(m.value)}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|