Skip to content

Commit 7d9121e

Browse files
authored
Merge pull request #7 from joyofrails/feat/add-system-mode-to-darkmode-switch
Add system mode to darkmode switch
2 parents 082591c + 2694d47 commit 7d9121e

File tree

7 files changed

+162
-73
lines changed

7 files changed

+162
-73
lines changed
Lines changed: 16 additions & 0 deletions
Loading

app/assets/images/darkmode/moon.svg

Lines changed: 3 additions & 0 deletions
Loading

app/assets/images/darkmode/sun.svg

Lines changed: 6 additions & 0 deletions
Loading

app/javascript/controllers/darkmode.js

Lines changed: 84 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,64 @@ const log = debug('app:javascript:controllers:darkmode');
55

66
const controllers = new Set();
77

8+
const DARK = 'dark';
9+
const LIGHT = 'light';
10+
const SYSTEM = 'system';
11+
12+
const modes = [DARK, LIGHT, SYSTEM];
13+
let mode = SYSTEM;
14+
815
const broadcastDark = () => {
9-
controllers.forEach((controller) => controller.handleDark());
16+
mode = DARK;
17+
document.documentElement.classList.add(DARK);
18+
document.documentElement.classList.remove(LIGHT);
19+
20+
controllers.forEach((controller) => controller.setDark());
1021
};
1122

1223
const broadcastLight = () => {
13-
controllers.forEach((controller) => controller.handleLight());
24+
mode = LIGHT;
25+
document.documentElement.classList.remove(DARK);
26+
document.documentElement.classList.add(LIGHT);
27+
28+
controllers.forEach((controller) => controller.setLight());
1429
};
30+
31+
const broadcastSystem = (color) => {
32+
mode = SYSTEM;
33+
document.documentElement.classList.add(color);
34+
document.documentElement.classList.remove(color === DARK ? LIGHT : DARK);
35+
36+
controllers.forEach((controller) => controller.setSystem(color));
37+
};
38+
39+
const prefersColorTheme = (theme) => window.matchMedia(`(prefers-color-scheme: ${theme})`).matches;
40+
41+
const storedTheme = () => localStorage.getItem('color-theme');
42+
const storeTheme = (theme) => localStorage.setItem('color-theme', theme);
43+
44+
const removeTheme = () => localStorage.removeItem('color-theme');
45+
46+
window.matchMedia(`(prefers-color-scheme: dark)`).addEventListener('change', (e) => {
47+
if (mode !== SYSTEM) return;
48+
49+
if (e.matches) {
50+
broadcastSystem(DARK);
51+
} else {
52+
broadcastSystem(LIGHT);
53+
}
54+
});
55+
1556
export default class extends Controller {
16-
static targets = ['description', 'darkIcon', 'lightIcon'];
57+
static targets = ['description', 'darkIcon', 'lightIcon', 'systemIcon'];
1758

1859
connect() {
1960
controllers.add(this);
20-
log('Darkmode Controller connect');
2161

22-
if (this.hasStoredTheme('dark') || this.prefersColorTheme('dark')) {
23-
this.setDark();
62+
if (storedTheme()) {
63+
this.setMode(storedTheme());
2464
} else {
25-
this.setLight();
65+
this.setMode(SYSTEM);
2666
}
2767
}
2868

@@ -31,59 +71,64 @@ export default class extends Controller {
3171
log('Darkmode Controller disconnect');
3272
}
3373

34-
toggle() {
35-
log('Darkmode Controller toggle');
36-
37-
if (this.hasStoredTheme('dark') || this.hasRenderedTheme('dark')) {
38-
broadcastLight();
39-
} else {
40-
broadcastDark();
74+
setMode(mode) {
75+
switch (mode) {
76+
case DARK:
77+
broadcastDark();
78+
storeTheme(DARK);
79+
break;
80+
case LIGHT:
81+
broadcastLight();
82+
storeTheme(LIGHT);
83+
break;
84+
case SYSTEM:
85+
if (prefersColorTheme(DARK)) {
86+
broadcastSystem(DARK);
87+
} else {
88+
broadcastSystem(LIGHT);
89+
}
90+
removeTheme();
91+
break;
92+
default:
93+
throw new Error(`Unknown mode ${mode}`);
4194
}
4295
}
4396

44-
handleLight() {
45-
this.setLight();
46-
this.storeTheme('light');
47-
}
48-
49-
handleDark() {
50-
this.setDark();
51-
this.storeTheme('dark');
52-
}
53-
54-
hasStoredTheme(theme) {
55-
return localStorage.getItem('color-theme') === theme;
56-
}
97+
cycle() {
98+
const index = modes.indexOf(mode);
99+
if (index === -1) {
100+
throw new Error(`Unknown mode ${mode}`);
101+
}
57102

58-
hasRenderedTheme(theme) {
59-
return document.documentElement.classList.contains(theme);
60-
}
103+
const nextIndex = index >= modes.length - 1 ? 0 : index + 1;
61104

62-
prefersColorTheme(theme) {
63-
return window.matchMedia(`(prefers-color-scheme: ${theme})`).matches;
105+
this.setMode(modes[nextIndex]);
64106
}
65107

66108
setDark() {
67109
log('Set Dark');
68110
this.darkIconTarget.classList.remove('hidden');
69111
this.lightIconTarget.classList.add('hidden');
70-
document.documentElement.classList.add('dark');
112+
this.systemIconTarget.classList.add('hidden');
71113
this.setDescription('Dark Mode');
72114
}
73115

74-
storeTheme(theme) {
75-
localStorage.setItem('color-theme', theme);
76-
}
77-
78116
setLight() {
79117
log('Set Light');
80118
this.darkIconTarget.classList.add('hidden');
81119
this.lightIconTarget.classList.remove('hidden');
82-
document.documentElement.classList.remove('dark');
83-
localStorage.setItem('color-theme', 'light');
120+
this.systemIconTarget.classList.add('hidden');
84121
this.setDescription('Light Mode');
85122
}
86123

124+
setSystem() {
125+
log('Set System');
126+
this.darkIconTarget.classList.add('hidden');
127+
this.lightIconTarget.classList.add('hidden');
128+
this.systemIconTarget.classList.remove('hidden');
129+
this.setDescription('System Mode');
130+
}
131+
87132
setDescription(text) {
88133
const node = document.createTextNode(text);
89134
this.descriptionTarget.replaceChildren(node);

app/javascript/controllers/darkmode.spec.js

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ const user = userEvent.setup();
55

66
const html = `
77
<div data-controller="darkmode" class="flex items-center justify-between">
8-
<h2 data-darkmode-target="description" data-action="click->darkmode#toggle" class="hidden mr-3 sm:block cursor-pointer">Light Mode</h2>
9-
<button data-action="click->darkmode#toggle" id="theme-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-label="Toggle Dark Mode" role="button">
8+
<h2 data-darkmode-target="description" data-action="click->darkmode#cycle" class="hidden mr-3 sm:block cursor-pointer">Light Mode</h2>
9+
<button data-action="click->darkmode#cycle" id="theme-cycle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-label="Toggle Dark Mode" role="button">
1010
<svg aria-role="graphics-symbol" data-darkmode-target="darkIcon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
1111
<svg aria-role="graphics-symbol" data-darkmode-target="lightIcon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
12+
<svg aria-role="graphics-symbol" data-darkmode-target="systemIcon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"> <g> <path d="m675 1012.5c-9.9453 0-19.484 3.9492-26.516 10.984-7.0352 7.0312-10.984 16.57-10.984 26.516v37.5c0 13.398 7.1484 25.777 18.75 32.477 11.602 6.6992 25.898 6.6992 37.5 0 11.602-6.6992 18.75-19.078 18.75-32.477v-37.5c0-9.9453-3.9492-19.484-10.984-26.516-7.0312-7.0352-16.57-10.984-26.516-10.984z" /> <path d="m330.38 891.75-26.625 26.625c-7.9375 6.7969-12.676 16.594-13.078 27.035-0.40625 10.441 3.5664 20.578 10.953 27.965s17.523 11.359 27.965 10.953c10.441-0.40234 20.238-5.1406 27.035-13.078l26.625-26.625c8.2656-9.6523 11.082-22.84 7.4766-35.027-3.6016-12.188-13.137-21.723-25.324-25.324-12.188-3.6055-25.375-0.78906-35.027 7.4766z" /> <path d="m225 562.5h-37.5c-13.398 0-25.777 7.1484-32.477 18.75-6.6992 11.602-6.6992 25.898 0 37.5 6.6992 11.602 19.078 18.75 32.477 18.75h37.5c13.398 0 25.777-7.1484 32.477-18.75 6.6992-11.602 6.6992-25.898 0-37.5-6.6992-11.602-19.078-18.75-32.477-18.75z" /> <path d="m330.38 308.25c9.6523 8.2656 22.84 11.082 35.027 7.4766 12.188-3.6016 21.723-13.137 25.324-25.324 3.6055-12.188 0.78906-25.375-7.4766-35.027l-26.625-26.625c-9.6523-8.2656-22.84-11.082-35.027-7.4766-12.188 3.6016-21.723 13.137-25.324 25.324-3.6055 12.188-0.78906 25.375 7.4766 35.027z" /> <path d="m675 187.5c9.9453 0 19.484-3.9492 26.516-10.984 7.0352-7.0312 10.984-16.57 10.984-26.516v-37.5c0-13.398-7.1484-25.777-18.75-32.477-11.602-6.6992-25.898-6.6992-37.5 0-11.602 6.6992-18.75 19.078-18.75 32.477v37.5c0 9.9453 3.9492 19.484 10.984 26.516 7.0312 7.0352 16.57 10.984 26.516 10.984z" /> <path d="m856.88 315.75c-63.141-40.391-137.93-58.617-212.57-51.797-74.645 6.8164-144.9 38.289-199.68 89.453s-90.965 119.11-102.85 193.11c-11.891 74.004 1.1953 149.86 37.191 215.61s92.852 117.64 161.61 147.5c68.754 29.855 145.49 35.973 218.11 17.391 72.613-18.582 136.98-60.812 182.94-120.02 45.957-59.211 70.898-132.04 70.887-206.99 0.19922-56.672-13.969-112.47-41.188-162.18-27.215-49.707-66.586-91.707-114.44-122.07zm-444.38 284.25c-0.015625-63.133 22.723-124.16 64.047-171.89 41.324-47.73 98.469-78.969 160.95-87.988v519.75c-62.484-9.0195-119.63-40.258-160.95-87.988-41.324-47.727-64.062-108.75-64.047-171.89z" /> </g> </svg>
1213
</button>
1314
</div>
1415
`;
@@ -24,13 +25,14 @@ describe('Darkmode', () => {
2425
localStorage.removeItem('color-theme');
2526
});
2627

27-
it('should initialize in light mode', async () => {
28+
it('should initialize in system mode', async () => {
2829
await render(html);
2930

3031
const heading = await screen.findByRole('heading');
3132

32-
expect(heading).toHaveTextContent('Light Mode');
33+
expect(heading).toHaveTextContent('System Mode');
3334
expect(document.documentElement).not.toHaveClass('dark');
35+
expect(document.documentElement).toHaveClass('light');
3436
});
3537

3638
it('should initialize in dark mode', async () => {
@@ -44,6 +46,17 @@ describe('Darkmode', () => {
4446
expect(document.documentElement).toHaveClass('dark');
4547
});
4648

49+
it('should initialize in light mode', async () => {
50+
localStorage.setItem('color-theme', 'light');
51+
52+
await render(html);
53+
54+
const heading = await screen.findByRole('heading');
55+
56+
expect(heading).toHaveTextContent('Light Mode');
57+
expect(document.documentElement).toHaveClass('light');
58+
});
59+
4760
it('should set to dark mode', async () => {
4861
await render(html);
4962

@@ -52,6 +65,7 @@ describe('Darkmode', () => {
5265

5366
await screen.findByText('Dark Mode');
5467
expect(document.documentElement).toHaveClass('dark');
68+
expect(document.documentElement).not.toHaveClass('light');
5569
});
5670

5771
it('should set to light mode', async () => {
@@ -62,9 +76,32 @@ describe('Darkmode', () => {
6276

6377
await screen.findByText('Dark Mode');
6478
expect(document.documentElement).toHaveClass('dark');
79+
expect(document.documentElement).not.toHaveClass('light');
6580

6681
await user.click(button);
6782
await screen.findByText('Light Mode');
83+
expect(document.documentElement).toHaveClass('light');
84+
expect(document.documentElement).not.toHaveClass('dark');
85+
});
86+
87+
it('should cycle back to system mode', async () => {
88+
await render(html);
89+
90+
const button = screen.getByRole('button');
91+
await user.click(button);
92+
93+
await screen.findByText('Dark Mode');
94+
expect(document.documentElement).toHaveClass('dark');
95+
expect(document.documentElement).not.toHaveClass('light');
96+
97+
await user.click(button);
98+
await screen.findByText('Light Mode');
99+
expect(document.documentElement).not.toHaveClass('dark');
100+
expect(document.documentElement).toHaveClass('light');
101+
102+
await user.click(button);
103+
await screen.findByText('System Mode');
68104
expect(document.documentElement).not.toHaveClass('dark');
105+
expect(document.documentElement).toHaveClass('light');
69106
});
70107
});

app/views/darkmode/_setup.html.erb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script type="text/javascript">
2-
if (localStorage.getItem('color-theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
2+
const colorTheme = localStorage.getItem('color-theme');
3+
if (colorTheme) {
4+
document.documentElement.classList.add(colorTheme)
5+
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
36
document.documentElement.classList.add('dark')
4-
} else {
5-
document.documentElement.classList.remove('dark')
67
}
78
</script>

app/views/darkmode/_switch.html.erb

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,24 @@
11
<div data-controller="darkmode" class="flex items-center justify-between">
22
<span
3-
aria-label="Toggle Dark Mode"
3+
aria-label="Change Color Scheme"
44
data-darkmode-target="description"
5-
data-action="click->darkmode#toggle"
5+
data-action="click->darkmode#cycle"
66
class="hidden mr-3 cursor-pointer"
77
>Light Mode</span>
88
<button
9-
data-action="click->darkmode#toggle"
10-
id="theme-toggle"
9+
data-action="click->darkmode#cycle"
10+
id="theme-cycle"
1111
type="button"
1212
class="
1313
text-gray-500 dark:text-gray-400 hover:bg-theme-bg-hover focus:ring-gray-200
1414
dark:focus:ring-gray-700 focus:ring-2 focus:outline-none rounded-lg text-sm
1515
p-2.5
1616
"
17-
aria-label="Toggle Dark Mode"
17+
aria-label="Change Color Scheme"
1818
role="button"
1919
>
20-
<svg
21-
data-darkmode-target="darkIcon"
22-
class="hidden w-5 h-5"
23-
fill="currentColor"
24-
viewBox="0 0 20 20"
25-
xmlns="http://www.w3.org/2000/svg"
26-
>
27-
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
28-
</svg>
29-
<svg
30-
data-darkmode-target="lightIcon"
31-
class="hidden w-5 h-5"
32-
fill="currentColor"
33-
viewBox="0 0 20 20"
34-
xmlns="http://www.w3.org/2000/svg"
35-
>
36-
<path
37-
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
38-
fill-rule="evenodd"
39-
clip-rule="evenodd"
40-
></path>
41-
</svg>
20+
<%= inline_svg_tag "darkmode/moon", data: { "darkmode-target" => "darkIcon" }, class: "hidden w-5 h-5", fill: "currentColor" %>
21+
<%= inline_svg_tag "darkmode/sun", data: { "darkmode-target" => "lightIcon" }, class: "hidden w-5 h-5", fill: "currentColor" %>
22+
<%= inline_svg_tag "darkmode/eclipse", data: { "darkmode-target" => "systemIcon" }, class: "hidden w-5 h-5", fill: "currentColor" %>
4223
</button>
4324
</div>

0 commit comments

Comments
 (0)