-
Notifications
You must be signed in to change notification settings - Fork 555
feat: theme toggle #8128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: theme toggle #8128
Changes from all commits
a70b136
88a2107
c9684f4
a7e78ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { Meta, StoryObj } from 'storybook' | ||
|
|
||
| import ThemeToggle from 'components/ThemeToggle' | ||
|
|
||
| const meta: Meta<typeof ThemeToggle> = { | ||
| component: ThemeToggle, | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| component: | ||
| 'One-click light/dark toggle (the icon shows what you switch to). With no stored choice the theme follows the OS; the first click pins an explicit preference. Clicking flips this Storybook canvas live.', | ||
| }, | ||
| }, | ||
| layout: 'centered', | ||
| }, | ||
| title: 'Components/ThemeToggle', | ||
| } | ||
| export default meta | ||
|
|
||
| type Story = StoryObj<typeof ThemeToggle> | ||
|
|
||
| export const Default: Story = {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import React from 'react' | ||
| import Button from 'components/base/forms/Button' | ||
| import Icon from 'components/icons/Icon' | ||
| import { useTheme } from 'project/darkMode' | ||
|
|
||
| // One-click light/dark flip (the icon shows what you switch to). With no | ||
| // stored choice the theme follows the OS; the first click pins an explicit | ||
| // preference. State is shared via useTheme, so the account-settings switch | ||
| // and other tabs stay in sync. | ||
| const ThemeToggle = () => { | ||
| const { isDark, setDarkMode } = useTheme() | ||
|
|
||
| return ( | ||
| <Button | ||
| theme='icon' | ||
| onClick={() => setDarkMode(!isDark)} | ||
| aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} | ||
| > | ||
| <Icon name={isDark ? 'sun' : 'moon'} width={18} /> | ||
| </Button> | ||
| ) | ||
| } | ||
|
|
||
| export default ThemeToggle | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,20 +1,87 @@ | ||||||||||||||||||
| import { useSyncExternalStore } from 'react' | ||||||||||||||||||
| import { storageGet, storageSet } from 'common/safeLocalStorage' | ||||||||||||||||||
|
|
||||||||||||||||||
| export const getDarkMode = () => { | ||||||||||||||||||
| return storageGet('dark_mode') === 'true' | ||||||||||||||||||
| type ThemeChoice = 'light' | 'dark' | ||||||||||||||||||
|
|
||||||||||||||||||
| // A ThemeChoice once the user has explicitly chosen. | ||||||||||||||||||
| const THEME_KEY = 'theme' | ||||||||||||||||||
| // Pre-theme storage: 'true' | 'false' under 'dark_mode'. | ||||||||||||||||||
| const LEGACY_KEY = 'dark_mode' | ||||||||||||||||||
|
|
||||||||||||||||||
| // Fallback when localStorage is unavailable (private mode, quota, blocked): | ||||||||||||||||||
| // hold the explicit choice in memory so the toggle still works this session. | ||||||||||||||||||
| let inMemoryChoice: ThemeChoice | null = null | ||||||||||||||||||
|
|
||||||||||||||||||
| const media = | ||||||||||||||||||
| typeof window !== 'undefined' && window.matchMedia | ||||||||||||||||||
| ? window.matchMedia('(prefers-color-scheme: dark)') | ||||||||||||||||||
| : null | ||||||||||||||||||
|
|
||||||||||||||||||
| const getStoredChoice = (): ThemeChoice | null => { | ||||||||||||||||||
| const stored = storageGet(THEME_KEY) | ||||||||||||||||||
| if (stored === 'light' || stored === 'dark') return stored | ||||||||||||||||||
| const legacy = storageGet(LEGACY_KEY) | ||||||||||||||||||
| if (legacy === 'true') return 'dark' | ||||||||||||||||||
| if (legacy === 'false') return 'light' | ||||||||||||||||||
| // Storage wins when present so cross-tab changes propagate; fall back to the | ||||||||||||||||||
| // in-memory choice only when storage has nothing (e.g. writes are blocked). | ||||||||||||||||||
| return inMemoryChoice | ||||||||||||||||||
| } | ||||||||||||||||||
| export const setDarkMode = (enabled: boolean) => { | ||||||||||||||||||
| if (enabled) { | ||||||||||||||||||
| storageSet('dark_mode', 'true') | ||||||||||||||||||
| document.body.classList.add('dark') | ||||||||||||||||||
|
|
||||||||||||||||||
| // An explicit choice wins; without one the OS preference decides. | ||||||||||||||||||
| export const getDarkMode = (): boolean => { | ||||||||||||||||||
| const choice = getStoredChoice() | ||||||||||||||||||
| return choice ? choice === 'dark' : !!media?.matches | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const listeners = new Set<() => void>() | ||||||||||||||||||
|
|
||||||||||||||||||
| const apply = () => { | ||||||||||||||||||
| const dark = getDarkMode() | ||||||||||||||||||
| document.body.classList.toggle('dark', dark) | ||||||||||||||||||
| if (dark) { | ||||||||||||||||||
| document.documentElement.setAttribute('data-bs-theme', 'dark') | ||||||||||||||||||
| } else { | ||||||||||||||||||
| storageSet('dark_mode', 'false') | ||||||||||||||||||
| document.body.classList.remove('dark') | ||||||||||||||||||
| document.documentElement.removeAttribute('data-bs-theme') | ||||||||||||||||||
| } | ||||||||||||||||||
| listeners.forEach((listener) => listener()) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| export const setDarkMode = (enabled: boolean) => { | ||||||||||||||||||
| inMemoryChoice = enabled ? 'dark' : 'light' | ||||||||||||||||||
| storageSet(THEME_KEY, inMemoryChoice) | ||||||||||||||||||
| apply() | ||||||||||||||||||
|
Comment on lines
+50
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Keep the selected theme in memory when storage is unavailable.
Based on the supplied safe-storage contract, |
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const subscribe = (listener: () => void) => { | ||||||||||||||||||
| listeners.add(listener) | ||||||||||||||||||
| return () => { | ||||||||||||||||||
| listeners.delete(listener) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (storageGet('dark_mode')) { | ||||||||||||||||||
| setDarkMode(getDarkMode()) | ||||||||||||||||||
| // Reactive theme state; all theme UI (nav toggle, settings switch) shares it | ||||||||||||||||||
| // so no control goes stale when another one changes the theme. | ||||||||||||||||||
| export const useTheme = () => { | ||||||||||||||||||
| const isDark = useSyncExternalStore(subscribe, getDarkMode) | ||||||||||||||||||
| return { isDark, setDarkMode } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Follow OS appearance changes until the user makes an explicit choice. | ||||||||||||||||||
| media?.addEventListener?.('change', () => { | ||||||||||||||||||
| if (!getStoredChoice()) { | ||||||||||||||||||
| apply() | ||||||||||||||||||
| } | ||||||||||||||||||
| }) | ||||||||||||||||||
|
|
||||||||||||||||||
| // Reflect theme changes made in other browser tabs immediately. | ||||||||||||||||||
| if (typeof window !== 'undefined') { | ||||||||||||||||||
| window.addEventListener('storage', (e) => { | ||||||||||||||||||
| // e.key is null on localStorage.clear() — revert to OS theme then too. | ||||||||||||||||||
| if (e.key === null || e.key === THEME_KEY || e.key === LEGACY_KEY) { | ||||||||||||||||||
| apply() | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+79
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Handle cross-tab A - if (e.key === THEME_KEY || e.key === LEGACY_KEY) {
+ if (e.key === null || e.key === THEME_KEY || e.key === LEGACY_KEY) {
apply()
}Based on the PR objective, theme changes must synchronise across open browser tabs. 📝 Committable suggestion
Suggested change
|
||||||||||||||||||
| }) | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| apply() | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use the required component-folder layout.
Move this to
ThemeToggle/ThemeToggle.tsx, add co-locatedThemeToggle/ThemeToggle.scss, and re-export it from the existing barrel.As per coding guidelines, each new component requires a barrel,
ComponentName/ComponentName.tsx, and co-located SCSS.Source: Coding guidelines