forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy paththeme-toggle.tsx
More file actions
149 lines (136 loc) · 4.85 KB
/
Copy paththeme-toggle.tsx
File metadata and controls
149 lines (136 loc) · 4.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
'use client'
import { useId, useSyncExternalStore } from 'react'
import { cn } from '@/lib/cn'
type Choice = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'ot-theme'
const CHOICES: Choice[] = ['light', 'system', 'dark']
/**
* Three states, not two. "System" is a real choice and the default — a two-way
* toggle silently opts everyone out of their OS setting the first time they
* touch it.
*
* The attribute is what the tokens key off: [data-theme] wins, and its absence
* means follow prefers-color-scheme.
*/
function apply(choice: Choice): void {
const root = document.documentElement
if (choice === 'system') delete root.dataset.theme
else root.dataset.theme = choice
}
function readChoice(): Choice {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v === 'light' || v === 'dark' ? v : 'system'
} catch {
return 'system'
}
}
/**
* localStorage is the source of truth rather than mirrored into React state,
* which avoids a setState-in-effect cascade.
*/
let listeners: (() => void)[] = []
function subscribe(onChange: () => void): () => void {
// A storage event means another tab changed the choice. Apply it to the DOM
// here — telling React to rerender only updates the control, and the page
// would keep the old theme while the buttons claimed otherwise.
const onStorage = (e: StorageEvent) => {
if (e.key !== null && e.key !== STORAGE_KEY) return
apply(readChoice())
onChange()
}
listeners.push(onChange)
window.addEventListener('storage', onStorage)
return () => {
listeners = listeners.filter((l) => l !== onChange)
window.removeEventListener('storage', onStorage)
}
}
/** The server cannot know the stored choice, and guessing would flash. */
const serverChoice = (): Choice => 'system'
function pick(next: Choice): void {
apply(next)
try {
if (next === 'system') localStorage.removeItem(STORAGE_KEY)
else localStorage.setItem(STORAGE_KEY, next)
} catch {
// Private browsing can refuse storage; the choice still applies for now.
}
listeners.forEach((l) => l())
}
export type ThemeToggleTone = 'default' | 'reversed'
/**
* The reversed tone is for the navy brand bar, where the default's white pill
* would read as a hole punched in the header. Cream on navy at 70% still clears
* AA at 12px, and the active pill keeps navy-on-coral — white fails on coral.
*/
const TONES: Record<ThemeToggleTone, { frame: string; idle: string }> = {
default: {
frame: 'border-[var(--ot-border)] bg-[var(--ot-card)]',
idle: 'text-[var(--ot-text-2)] hover:text-[var(--ot-text)]',
},
reversed: {
frame: 'border-[rgba(255,240,220,0.35)] bg-transparent',
idle: 'text-[var(--ot-cream)]/75 hover:text-[var(--ot-cream)]',
},
}
/**
* Native radios rather than role="radio" on buttons. A hand-rolled radiogroup
* has to implement roving focus and arrow keys to match what the role promises;
* real inputs give that, plus form semantics, for free. The inputs are visually
* hidden, and the label is the control.
*/
export function ThemeToggle({
tone = 'default',
className,
}: {
tone?: ThemeToggleTone
className?: string
}) {
const choice = useSyncExternalStore(subscribe, readChoice, serverChoice)
const name = useId()
return (
<fieldset
className={cn(
'inline-flex gap-1 rounded-[var(--ot-radius-pill)] border p-1',
TONES[tone].frame,
className,
)}
>
<legend className="sr-only">Colour theme</legend>
{CHOICES.map((c) => (
<label
key={c}
className={cn(
'cursor-pointer rounded-[var(--ot-radius-pill)] px-3 py-1 text-[12px] font-medium capitalize',
// The label is the control, so the label is the tap target: 36px
// below sm, which with the fieldset's own padding puts the row at
// 44. A 26px segment is a thumb-width of three wrong answers.
'flex items-center justify-center max-sm:min-h-9',
'transition-colors duration-[var(--ot-dur-fast)]',
'has-[:focus-visible]:outline has-[:focus-visible]:outline-2',
'has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ot-plan)]',
choice === c
? 'bg-[var(--ot-coral)] text-[var(--ot-on-state)]'
: TONES[tone].idle,
)}
>
<input
type="radio"
name={name}
value={c}
checked={choice === c}
onChange={() => pick(c)}
className="sr-only"
/>
{c}
</label>
))}
</fieldset>
)
}
/**
* Runs before first paint, so a stored dark choice does not flash light first.
* Inline and synchronous on purpose — anything deferred is too late.
*/
export const themeScript = `try{var t=localStorage.getItem('${STORAGE_KEY}');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t}catch(e){}`