Skip to content

Commit eb7b681

Browse files
authored
Merge pull request #12628 from Turbo87/sv-color-scheme
svelte: Add color scheme picker
2 parents 2997d58 + 1924265 commit eb7b681

File tree

7 files changed

+198
-7
lines changed

7 files changed

+198
-7
lines changed

svelte/.storybook/preview.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { Preview } from '@storybook/sveltekit';
22

33
import '../src/lib/css/global.css';
44

5+
import ColorSchemeDecorator from '../src/lib/storybook/ColorSchemeDecorator.svelte';
6+
57
const preview: Preview = {
8+
decorators: [() => ColorSchemeDecorator],
69
initialGlobals: {
710
backgrounds: { value: 'content' },
811
},
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createContext } from 'svelte';
2+
import { MediaQuery } from 'svelte/reactivity';
3+
4+
import * as storage from '$lib/utils/local-storage';
5+
6+
export type ColorScheme = 'light' | 'dark' | 'system';
7+
export type ResolvedScheme = 'light' | 'dark';
8+
9+
const STORAGE_KEY = 'color-scheme';
10+
const VALID_SCHEMES = new Set<ColorScheme>(['light', 'dark', 'system']);
11+
12+
export class ColorSchemeState {
13+
scheme = $state<ColorScheme>('system');
14+
15+
#mediaQuery = new MediaQuery('prefers-color-scheme: dark', false);
16+
17+
readonly resolvedScheme: ResolvedScheme = $derived(
18+
this.scheme === 'system' ? (this.#mediaQuery.current ? 'dark' : 'light') : this.scheme,
19+
);
20+
21+
readonly isDark: boolean = $derived(this.resolvedScheme === 'dark');
22+
23+
constructor() {
24+
let stored = storage.getItem(STORAGE_KEY);
25+
if (stored && VALID_SCHEMES.has(stored as ColorScheme)) {
26+
this.scheme = stored as ColorScheme;
27+
}
28+
}
29+
30+
setScheme(newScheme: ColorScheme): void {
31+
if (!VALID_SCHEMES.has(newScheme)) return;
32+
33+
this.scheme = newScheme;
34+
storage.setItem(STORAGE_KEY, newScheme);
35+
}
36+
}
37+
38+
export interface ColorSchemeContext {
39+
readonly scheme: ColorScheme;
40+
readonly resolvedScheme: ResolvedScheme;
41+
readonly isDark: boolean;
42+
setScheme: (scheme: ColorScheme) => void;
43+
}
44+
45+
export const [getColorScheme, setColorScheme] = createContext<ColorSchemeContext>();
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<script lang="ts">
2+
import type { ColorScheme } from '$lib/color-scheme.svelte';
3+
import type { Component } from 'svelte';
4+
import type { HTMLAttributes } from 'svelte/elements';
5+
6+
import ColorModeIcon from '$lib/assets/color-mode.svg?component';
7+
import MoonIcon from '$lib/assets/moon.svg?component';
8+
import SunIcon from '$lib/assets/sun.svg?component';
9+
import { getColorScheme } from '$lib/color-scheme.svelte';
10+
import * as Dropdown from './dropdown';
11+
12+
type Props = HTMLAttributes<HTMLDivElement>;
13+
14+
let { class: className, ...restProps }: Props = $props();
15+
16+
interface SchemeOption {
17+
mode: ColorScheme;
18+
Icon: Component;
19+
}
20+
21+
const COLOR_SCHEMES: SchemeOption[] = [
22+
{ mode: 'light', Icon: SunIcon },
23+
{ mode: 'dark', Icon: MoonIcon },
24+
{ mode: 'system', Icon: ColorModeIcon },
25+
];
26+
27+
let colorScheme = getColorScheme();
28+
29+
let CurrentIcon: Component = $derived(COLOR_SCHEMES.find(({ mode }) => mode === colorScheme.scheme)?.Icon ?? SunIcon);
30+
</script>
31+
32+
<div class={['color-scheme-menu', className]} {...restProps}>
33+
<Dropdown.Root class="dropdown">
34+
<Dropdown.Trigger hideArrow class="trigger">
35+
<CurrentIcon class="icon" />
36+
<span class="sr-only">Change color scheme</span>
37+
</Dropdown.Trigger>
38+
39+
<Dropdown.Menu class="menu">
40+
{#each COLOR_SCHEMES as { mode, Icon } (mode)}
41+
<Dropdown.Item>
42+
<button
43+
class="menu-button button-reset"
44+
class:selected={mode === colorScheme.scheme}
45+
type="button"
46+
onclick={() => colorScheme.setScheme(mode)}
47+
>
48+
<Icon class="icon" />
49+
{mode}
50+
</button>
51+
</Dropdown.Item>
52+
{/each}
53+
</Dropdown.Menu>
54+
</Dropdown.Root>
55+
</div>
56+
57+
<style>
58+
.color-scheme-menu {
59+
& :global(.dropdown) {
60+
line-height: 1rem;
61+
}
62+
63+
& :global(.icon) {
64+
width: 1.4em;
65+
height: auto;
66+
}
67+
68+
& :global(.trigger) {
69+
background: none;
70+
border: 0;
71+
padding: 0;
72+
}
73+
74+
& :global(.menu) {
75+
right: 0;
76+
min-width: max-content;
77+
}
78+
79+
.menu-button {
80+
align-items: center;
81+
gap: var(--space-2xs);
82+
cursor: pointer;
83+
text-transform: capitalize;
84+
}
85+
86+
.selected {
87+
background: light-dark(#e6e6e6, #404040);
88+
}
89+
}
90+
</style>

svelte/src/lib/components/Header.svelte

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
44
import logo from '$lib/assets/cargo.png';
55
import LockIcon from '$lib/assets/lock.svg?component';
6+
import ColorSchemeMenu from './ColorSchemeMenu.svelte';
67
import SearchForm from './SearchForm.svelte';
78
8-
// TODO: import ColorSchemeMenu from './ColorSchemeMenu.svelte';
99
// TODO: import Dropdown from './Dropdown.svelte';
1010
// TODO: import LoadingSpinner from './LoadingSpinner.svelte';
1111
// TODO: import UserAvatar from './UserAvatar.svelte';
@@ -44,7 +44,7 @@
4444
</div>
4545

4646
<nav class="nav">
47-
<!-- TODO: <ColorSchemeMenu class="color-scheme-menu" /> -->
47+
<ColorSchemeMenu class="color-scheme-menu" />
4848

4949
<a href={resolve('/crates')} data-test-all-crates-link> Browse All Crates </a>
5050
<span class="sep">|</span>
@@ -61,7 +61,7 @@
6161
</nav>
6262

6363
<div class="menu">
64-
<!-- TODO: <ColorSchemeMenu class="color-scheme-menu" /> -->
64+
<ColorSchemeMenu class="color-scheme-menu" />
6565

6666
<!-- TODO: implement mobile menu dropdown -->
6767
<!-- <Dropdown>
@@ -214,11 +214,9 @@
214214
}
215215
*/
216216
217-
/* TODO: uncomment when color scheme menu is added
218-
.color-scheme-menu {
217+
.header :global(.color-scheme-menu) {
219218
margin-right: var(--space-xs);
220219
}
221-
*/
222220
223221
.login-button {
224222
display: inline-flex;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script lang="ts">
2+
import type { Snippet } from 'svelte';
3+
4+
import { ColorSchemeState, setColorScheme } from '$lib/color-scheme.svelte';
5+
6+
let { children }: { children: Snippet } = $props();
7+
8+
let colorScheme = new ColorSchemeState();
9+
setColorScheme(colorScheme);
10+
11+
$effect(() => {
12+
document.documentElement.dataset.colorScheme = colorScheme.resolvedScheme;
13+
});
14+
</script>
15+
16+
{@render children()}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Wrappers around localStorage that silently handle errors.
3+
*
4+
* localStorage can throw in certain situations:
5+
* - Safari private browsing mode
6+
* - Storage quota exceeded
7+
* - Sandboxed iframes with restricted storage access
8+
*/
9+
10+
export function getItem(key: string): string | null {
11+
try {
12+
return localStorage.getItem(key);
13+
} catch {
14+
return null;
15+
}
16+
}
17+
18+
export function setItem(key: string, value: string): void {
19+
try {
20+
localStorage.setItem(key, value);
21+
} catch {
22+
// ignored
23+
}
24+
}
25+
26+
export function removeItem(key: string): void {
27+
try {
28+
localStorage.removeItem(key);
29+
} catch {
30+
// ignored
31+
}
32+
}

svelte/src/routes/+layout.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { page } from '$app/state';
33
4+
import { ColorSchemeState, setColorScheme } from '$lib/color-scheme.svelte';
45
import Footer from '$lib/components/Footer.svelte';
56
import Header from '$lib/components/Header.svelte';
67
@@ -12,7 +13,13 @@
1213
1314
let isIndex = $derived(page.route.id === '/');
1415
15-
// TODO: implement color scheme support
16+
let colorScheme = new ColorSchemeState();
17+
setColorScheme(colorScheme);
18+
19+
$effect(() => {
20+
document.documentElement.dataset.colorScheme = colorScheme.resolvedScheme;
21+
});
22+
1623
// TODO: implement notification container
1724
</script>
1825

0 commit comments

Comments
 (0)