Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 517603a

Browse files
SMotaalamiller-gh
authored andcommitted
feat: Enhanced Dark Mode prototype (#352)
* feat: Enhanced Dark Mode Toggle Features This adds a new `utils/DarkModeController` controller wrapped in a new `components/controls` component.
1 parent 19bd84a commit 517603a

7 files changed

Lines changed: 418 additions & 123 deletions

File tree

src/components/controls.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React from 'react';
2+
import { css } from '@emotion/core';
3+
import DarkModeController from '../util/DarkModeController';
4+
5+
const controlsStyles = {
6+
header: css/* scss */ `
7+
position: fixed;
8+
padding: 0.25ch 1ch;
9+
margin-bottom: 1ch;
10+
margin-top: -1ch;
11+
right: 0;
12+
z-index: 999;
13+
box-sizing: border-box;
14+
will-change: transform;
15+
16+
display: grid;
17+
grid-auto-flow: column dense;
18+
grid-gap: 1ch;
19+
align-items: center;
20+
21+
opacity: 0.9;
22+
color: var(--color-text-accent, #999);
23+
background-color: var(--black9, #9993);
24+
border-top-left-radius: 1ch;
25+
border-bottom-left-radius: 1ch;
26+
27+
min-width: max-content;
28+
width: 0;
29+
white-space: normal;
30+
text-size-adjust: 100%;
31+
text-shadow: #333f46 0px 0.875px 0px;
32+
user-select: none;
33+
`,
34+
button: css/* scss */ `
35+
color: inherit;
36+
border: none;
37+
width: max-content;
38+
display: contents;
39+
`,
40+
controls: css/* scss */ `
41+
color: inherit;
42+
`,
43+
};
44+
45+
interface Props {
46+
lightModeIcon?: string;
47+
darkModeIcon?: string;
48+
controller?: DarkModeController;
49+
}
50+
51+
const Controls = ({
52+
lightModeIcon = 'wb_sunny',
53+
darkModeIcon = 'nights_stay',
54+
controller = new DarkModeController(),
55+
}: Props) => (
56+
<header css={controlsStyles.header}>
57+
<div id="controls" css={controlsStyles.controls}>
58+
<span>
59+
<button
60+
type="button"
61+
css={controlsStyles.button}
62+
id="contrast"
63+
title="Dark/Light"
64+
onPointerDown={(): void => {
65+
controller.onPointerDown();
66+
}}
67+
onPointerUp={(): void => {
68+
controller.onPointerUp();
69+
}}
70+
>
71+
<span className="sr-only">Toggle Dark Mode</span>
72+
<i className="material-icons light-mode-only">{darkModeIcon}</i>
73+
<i className="material-icons dark-mode-only">{lightModeIcon}</i>
74+
</button>
75+
</span>
76+
</div>
77+
</header>
78+
);
79+
80+
export default Controls;

src/components/header.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@ import { Link } from 'gatsby';
22
import React from 'react';
33
import logoLight from '../images/logos/nodejs-logo-light-mode.svg';
44
import logoDark from '../images/logos/nodejs-logo-dark-mode.svg';
5+
import DarkModeController from '../util/DarkModeController';
56

67
const activeStyleTab = {
78
fontWeight: 'var(--font-weight-semibold)',
89
color: 'var(--color-text-accent)',
910
borderBottom: 'var(--space-04) inset var(--color-text-accent)',
1011
};
1112

12-
const Header = () => (
13+
interface Props {
14+
darkModeController?: DarkModeController;
15+
}
16+
17+
const Header = ({ darkModeController }: Props) => (
1318
<nav className="nav">
1419
<div className="logo">
1520
<Link to="/">
@@ -51,7 +56,16 @@ const Header = () => (
5156
<button
5257
type="button"
5358
className="dark-mode-toggle"
54-
onClick={() => document.body.classList.toggle('dark-mode')}
59+
onClick={() => {
60+
if (!darkModeController)
61+
document.body.classList.toggle('dark-mode');
62+
}}
63+
onPointerDown={(): void => {
64+
if (darkModeController) darkModeController.onPointerDown();
65+
}}
66+
onPointerUp={(): void => {
67+
if (darkModeController) darkModeController.onPointerUp();
68+
}}
5569
>
5670
<span className="sr-only">Toggle Dark Mode</span>
5771
<i className="material-icons light-mode-only">nights_stay</i>

src/components/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import '../styles/tokens.css';
66
import '../styles/layout.css';
77
import '../styles/mobile.css';
88
import SEO from './seo';
9+
import DarkModeController from '../util/DarkModeController';
910

1011
interface Props {
1112
children: React.ReactNode;
1213
title?: string;
1314
description?: string;
1415
img?: string;
1516
href: string;
17+
darkModeController?: DarkModeController;
1618
}
1719

1820
const Layout = ({
@@ -21,11 +23,12 @@ const Layout = ({
2123
description,
2224
img,
2325
location,
26+
darkModeController = new DarkModeController(),
2427
}: Props): JSX.Element => {
2528
return (
2629
<React.Fragment>
2730
<SEO title={title} description={description} img={img} />
28-
<Header />
31+
<Header darkModeController={darkModeController} />
2932
{children}
3033
</React.Fragment>
3134
);

src/util/DarkModeController.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// @ts-check
2+
3+
/*eslint-disable */
4+
5+
export default class DarkModeController {
6+
static get timeout() {
7+
const value = Symbol.for('dark-mode.toggler.timeout');
8+
Object.defineProperty(this, 'timeout', { value, writable: false });
9+
return value;
10+
}
11+
12+
static get resetting() {
13+
const value = Symbol.for('dark-mode.toggler.resetting');
14+
Object.defineProperty(this, 'resetting', { value, writable: false });
15+
return value;
16+
}
17+
18+
static get prefersLightMode() {
19+
const value =
20+
(typeof matchMedia === 'function' &&
21+
matchMedia('(prefers-color-scheme: light)')) ||
22+
undefined;
23+
Object.defineProperty(this, 'prefersLightMode', { value, writable: false });
24+
return value;
25+
}
26+
27+
static get prefersDarkMode() {
28+
const value =
29+
(typeof matchMedia === 'function' &&
30+
matchMedia('(prefers-color-scheme: dark)')) ||
31+
undefined;
32+
Object.defineProperty(this, 'prefersDarkMode', { value, writable: false });
33+
return value;
34+
}
35+
36+
/** @param {HTMLElement} [target] */
37+
constructor(target) {
38+
Object.defineProperties(this, {
39+
target: {
40+
value:
41+
/** @type {HTMLElement|undefined} */ (target ||
42+
(typeof document === 'object' && document.body) ||
43+
undefined),
44+
writable: false,
45+
},
46+
[DarkModeController.timeout]: {
47+
value: /** @type {number|undefined} */ (undefined),
48+
writable: true,
49+
},
50+
[DarkModeController.resetting]: {
51+
value: /** @type {boolean|undefined} */ (undefined),
52+
writable: true,
53+
},
54+
state: {
55+
value: /** @type {DarkModeState|undefined} */ (undefined),
56+
writable: true,
57+
},
58+
prefers: {
59+
value: /** @type {PrefersColorSchemes|undefined} */ (undefined),
60+
writable: true,
61+
},
62+
enable: { value: this.enable.bind(this), writable: false },
63+
disable: { value: this.disable.bind(this), writable: false },
64+
toggle: { value: this.toggle.bind(this), writable: false },
65+
onPointerDown: { value: this.onPointerDown.bind(this), writable: false },
66+
onPointerUp: { value: this.onPointerUp.bind(this), writable: false },
67+
});
68+
69+
((prefersDarkMode, prefersLightMode, localStorage) => {
70+
if (!localStorage || !prefersDarkMode || !prefersLightMode) return;
71+
localStorage.darkMode === 'enabled'
72+
? ((this.state = 'enabled'), this.enable())
73+
: localStorage.darkMode === 'disabled'
74+
? ((this.state = 'disabled'), this.disable())
75+
: this.toggle(
76+
prefersDarkMode.matches === true ||
77+
prefersLightMode.matches !== true,
78+
!!(localStorage.darkMode = this.state = 'auto')
79+
);
80+
prefersDarkMode.addListener(
81+
({ matches = false }) =>
82+
matches === true && this.toggle(!!matches, true)
83+
);
84+
prefersLightMode.addListener(
85+
({ matches = false }) => matches === true && this.toggle(!matches, true)
86+
);
87+
})(
88+
DarkModeController.prefersDarkMode,
89+
DarkModeController.prefersLightMode,
90+
(typeof localStorage === 'object' && localStorage) || undefined
91+
);
92+
93+
Object.preventExtensions(this);
94+
}
95+
96+
/**
97+
* @param {DarkModeState|boolean} [state]
98+
* @param {boolean} [auto]
99+
*/
100+
async toggle(state, auto) {
101+
const { classList } = this.target;
102+
103+
if (auto === true) {
104+
if (state === true) this.prefers = 'dark';
105+
else if (state === false) this.prefers = 'light';
106+
if (this.state !== 'auto') return;
107+
}
108+
109+
state =
110+
state === 'auto'
111+
? ((auto = true), this.prefers !== 'light')
112+
: state == null
113+
? !classList.contains('dark-mode')
114+
: !!state;
115+
116+
this.state = localStorage.darkMode = auto
117+
? 'auto'
118+
: state
119+
? 'enabled'
120+
: 'disabled';
121+
122+
state
123+
? (classList.add('dark-mode'), classList.remove('light-mode'))
124+
: (classList.add('light-mode'), classList.remove('dark-mode'));
125+
}
126+
127+
/** @param {boolean} [auto] */
128+
enable(auto) {
129+
this.toggle(true, auto);
130+
}
131+
132+
/** @param {boolean} [auto] */
133+
disable(auto) {
134+
this.toggle(false, auto);
135+
}
136+
137+
onPointerDown() {
138+
clearTimeout(this[DarkModeController.timeout]);
139+
this[DarkModeController.timeout] = setTimeout(() => {
140+
this.toggle('auto');
141+
this[DarkModeController.resetting] = true;
142+
// console.log('Reset dark mode!');
143+
}, 2000);
144+
}
145+
146+
onPointerUp() {
147+
this[DarkModeController.timeout] = clearTimeout(
148+
this[DarkModeController.timeout]
149+
);
150+
this[DarkModeController.resetting] === true
151+
? (this[DarkModeController.resetting] = false)
152+
: this.toggle();
153+
}
154+
}
155+
156+
Object.preventExtensions(DarkModeController);
157+
158+
/** @typedef {'auto'|'enabled'|'disabled'} DarkModeState */
159+
/** @typedef {'light'|'dark'} PrefersColorSchemes */
160+
161+
/* eslint-enable */

0 commit comments

Comments
 (0)