-
Notifications
You must be signed in to change notification settings - Fork 556
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
Merged
Merged
feat: theme toggle #8128
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a70b136
feat: theme toggle in the top bar behind dark_mode_nav_toggle
talissoncosta 88a2107
feat: simplify to a one-click toggle, system informs the default
talissoncosta c9684f4
style: theme toggle sits left of Getting Started
talissoncosta a7e78ba
fix: harden dark mode storage handling (blocked storage, cross-tab cl…
talissoncosta 6b3a6dd
fix: clear in-memory theme choice on external storage removal
talissoncosta File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 = {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 0 additions & 32 deletions
32
frontend/web/components/pages/onboarding/ThemeToggle/ThemeToggle.tsx
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,92 @@ | ||
| 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() | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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) { | ||
| // A clear (e.key null) or removal (e.newValue null) means no explicit | ||
| // choice remains; drop the in-memory fallback so we fall back to the OS. | ||
| if (e.key === null || e.newValue === null) { | ||
| inMemoryChoice = null | ||
| } | ||
| apply() | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }) | ||
| } | ||
|
|
||
| apply() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.