dark-mode-toggle
Version:
Web Component that toggles dark mode 🌒
513 lines (448 loc) • 14.3 kB
JavaScript
/**
* Copyright 2019 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @license © 2019 Google LLC. Licensed under the Apache License, Version 2.0.
const win = window;
const doc = document;
const store = win.localStorage;
const MQ_DARK = '(prefers-color-scheme: dark)';
const MQ_LIGHT = [
'(prefers-color-scheme: light)',
'(prefers-color-scheme: no-preference)',
];
const LIGHT = 'light';
const DARK = 'dark';
const REMEMBER = 'remember';
const LEGEND = 'legend';
const TOGGLE = 'toggle';
const SWITCH = 'switch';
const APPEARANCE = 'appearance';
const PERMANENT = 'permanent';
const MODE = 'mode';
const COLOR_SCHEME_CHANGE = 'colorschemechange';
const PERMANENT_COLOR_SCHEME = 'permanentcolorscheme';
const ALL = 'all';
const NOT_ALL = 'not all';
const NAME = 'dark-mode-toggle';
const DEFAULT_URL = 'https://googlechromelabs.github.io/dark-mode-toggle/demo/';
// See https://html.spec.whatwg.org/multipage/common-dom-interfaces.html ↵
// #reflecting-content-attributes-in-idl-attributes.
const installStringReflection = (obj, attrName, propName = attrName) => {
Object.defineProperty(obj, propName, {
enumerable: true,
get() {
const value = this.getAttribute(attrName);
return value === null ? '' : value;
},
set(v) {
this.setAttribute(attrName, v);
},
});
};
const installBoolReflection = (obj, attrName, propName = attrName) => {
Object.defineProperty(obj, propName, {
enumerable: true,
get() {
return this.hasAttribute(attrName);
},
set(v) {
if (v) {
this.setAttribute(attrName, '');
} else {
this.removeAttribute(attrName);
}
},
});
};
const template = doc.createElement('template');
// eslint-disable-next-line max-len
template.innerHTML = `
<style>
*,
::before,
::after {
box-sizing: border-box;
}
:host {
contain: content;
display: block;
}
:host([hidden]) {
display: none;
}
form {
background-color: var(--${NAME}-background-color, transparent);
color: var(--${NAME}-color, inherit);
padding: 0;
}
fieldset {
border: none;
margin: 0;
padding-block-start: 0.25rem;
padding-block-end: 0.25rem;
padding-inline-start: 0.25rem;
padding-inline-end: 0.25rem;
}
legend {
font: var(--${NAME}-legend-font, inherit);
padding: 0;
}
input,
label {
cursor: pointer;
}
label {
padding: 0.15rem;
white-space: nowrap;
}
input {
opacity: 0;
position: absolute;
pointer-events: none;
}
input:focus + label {
outline: rgb(229, 151, 0) auto 2px;
outline: -webkit-focus-ring-color auto 5px;
}
label::before {
content: "";
display: inline-block;
background-size: var(--${NAME}-icon-size, 1rem);
background-repeat: no-repeat;
height: var(--${NAME}-icon-size, 1rem);
width: var(--${NAME}-icon-size, 1rem);
vertical-align: middle;
margin: 0 0.5rem 0 0;
}
label[dir="rtl"]::before {
margin: 0 0 0 0.5rem;
}
#lightLabel::before {
background-image: var(--${NAME}-light-icon, url("${DEFAULT_URL}sun.png"));
}
#darkLabel::before {
filter: var(--${NAME}-icon-filter, none);
background-image: var(--${NAME}-dark-icon, url("${DEFAULT_URL}moon.png"));
}
#checkboxLabel::before {
background-image: var(--${NAME}-checkbox-icon, none);
}
#permanentLabel::before {
background-image: var(--${NAME}-remember-icon-unchecked, url("${DEFAULT_URL}unchecked.svg"));
}
#lightLabel,
#darkLabel,
#checkboxLabel {
font: var(--${NAME}-label-font, inherit);
}
#lightLabel:empty,
#darkLabel:empty,
#checkboxLabel:empty {
font-size: 0;
padding: 0;
}
#permanentLabel {
font: var(--${NAME}-remember-font, inherit);
}
input:checked + #permanentLabel::before {
background-image: var(--${NAME}-remember-icon-checked, url("${DEFAULT_URL}checked.svg"));
}
input:checked + #darkLabel,
input:checked + #lightLabel {
background-color: var(--${NAME}-active-mode-background-color, transparent);
}
input:checked + #darkLabel::before,
input:checked + #lightLabel::before {
background-color: var(--${NAME}-active-mode-background-color, transparent);
}
input:checked + #checkboxLabel::before {
filter: var(--${NAME}-icon-filter, none);
}
input:checked + #checkboxLabel + aside #permanentLabel::before {
filter: var(--${NAME}-remember-filter, invert(100%));
}
aside {
visibility: hidden;
margin-top: 0.15rem;
}
#lightLabel:focus-visible ~ aside,
#darkLabel:focus-visible ~ aside,
#checkboxLabel:focus-visible ~ aside {
visibility: visible;
transition: visibility 0s;
}
(hover: hover) {
aside {
transition: visibility 3s;
}
aside:hover {
visibility: visible;
}
#lightLabel:hover ~ aside,
#darkLabel:hover ~ aside,
#checkboxLabel:hover ~ aside {
visibility: visible;
transition: visibility 0s;
}
aside #permanentLabel:empty {
display: none;
}
}
</style>
<form>
<fieldset>
<legend></legend>
<input id="lightRadio" name="mode" type="radio">
<label id="lightLabel" for="lightRadio"></label>
<input id="darkRadio" name="mode" type="radio">
<label id="darkLabel" for="darkRadio"></label>
<input id="darkCheckbox" type="checkbox">
<label id="checkboxLabel" for="darkCheckbox"></label>
<aside>
<input id="permanentCheckbox" type="checkbox">
<label id="permanentLabel" for="permanentCheckbox"></label>
</aside>
</fieldset>
</form>`;
export class DarkModeToggle extends HTMLElement {
static get observedAttributes() {
return [MODE, APPEARANCE, PERMANENT, LEGEND, LIGHT, DARK, REMEMBER];
}
constructor() {
super();
installStringReflection(this, MODE);
installStringReflection(this, APPEARANCE);
installStringReflection(this, LEGEND);
installStringReflection(this, LIGHT);
installStringReflection(this, DARK);
installStringReflection(this, REMEMBER);
installBoolReflection(this, PERMANENT);
this._darkCSS = null;
this._lightCSS = null;
doc.addEventListener(COLOR_SCHEME_CHANGE, (e) => {
this.mode = e.detail.colorScheme;
this._updateRadios();
this._updateCheckbox();
});
doc.addEventListener(PERMANENT_COLOR_SCHEME, (e) => {
this.permanent = e.detail.permanent;
this.permanentCheckbox.checked = this.permanent;
});
this._initializeDOM();
}
_initializeDOM() {
const shadowRoot = this.attachShadow({mode: 'closed'});
shadowRoot.appendChild(template.content.cloneNode(true));
// Store original `media` attribute value.
// Note: we treat `prefers-color-scheme: light` and
// `prefers-color-scheme: no-preference` the same.
this._darkCSS =
doc.querySelectorAll(`link[rel="stylesheet"][media="${MQ_DARK}"]`);
this._lightCSS = document.querySelectorAll(MQ_LIGHT.map((mqLight) => {
return `link[rel="stylesheet"][media*="${mqLight}"]`;
}).join(', '));
// Get DOM references.
this.lightRadio = shadowRoot.querySelector('#lightRadio');
this.lightLabel = shadowRoot.querySelector('#lightLabel');
this.darkRadio = shadowRoot.querySelector('#darkRadio');
this.darkLabel = shadowRoot.querySelector('#darkLabel');
this.darkCheckbox = shadowRoot.querySelector('#darkCheckbox');
this.checkboxLabel = shadowRoot.querySelector('#checkboxLabel');
this.legendLabel = shadowRoot.querySelector('legend');
this.permanentAside = shadowRoot.querySelector('aside');
this.permanentCheckbox = shadowRoot.querySelector('#permanentCheckbox');
this.permanentLabel = shadowRoot.querySelector('#permanentLabel');
// Does the browser support native `prefers-color-scheme`?
const hasNativePrefersColorScheme =
win.matchMedia('(prefers-color-scheme)').media !== NOT_ALL;
// Listen to `prefers-color-scheme` changes, unless `permanent` is true.
if (hasNativePrefersColorScheme) {
win.matchMedia(MQ_DARK).addListener(({matches}) => {
if (!this.permanent) {
this.mode = matches ? DARK : LIGHT;
this._dispatchEvent(COLOR_SCHEME_CHANGE, {colorScheme: this.mode});
}
});
}
// Set initial state, giving preference to a remembered value, then the
// native value (if supported), and eventually defaulting to a light
// experience.
const rememberedValue = store.getItem(NAME);
if (rememberedValue && [DARK, LIGHT].includes(rememberedValue)) {
this.mode = rememberedValue;
this.permanentCheckbox.checked = true;
this.permanent = true;
} else if (hasNativePrefersColorScheme) {
if ((win.matchMedia(MQ_LIGHT[0]).matches) ||
(win.matchMedia(MQ_LIGHT[1]).matches)) {
this.mode = LIGHT;
} else if (win.matchMedia(MQ_DARK).matches) {
this.mode = DARK;
}
}
if (!this.mode) {
this.mode = LIGHT;
}
if (this.permanent && !rememberedValue) {
store.setItem(NAME, this.mode);
}
// Default to toggle appearance.
if (!this.appearance) {
this.appearance = TOGGLE;
}
// Update the appearance to either of toggle or switch.
this._updateAppearance();
// Update the radios
this._updateRadios();
// Make the checkbox reflect the state of the radios
this._updateCheckbox();
// Synchronize the behavior of the radio and the checkbox.
[this.lightRadio, this.darkRadio].forEach((input) => {
input.addEventListener('change', () => {
this.mode = this.lightRadio.checked ? LIGHT : DARK;
this._updateCheckbox();
this._dispatchEvent(COLOR_SCHEME_CHANGE, {colorScheme: this.mode});
});
});
this.darkCheckbox.addEventListener('change', () => {
this.mode = this.darkCheckbox.checked ? DARK : LIGHT;
this._updateRadios();
this._dispatchEvent(COLOR_SCHEME_CHANGE, {colorScheme: this.mode});
});
// Make remembering the last mode optional
this.permanentCheckbox.addEventListener('change', () => {
this.permanent = this.permanentCheckbox.checked;
this._dispatchEvent(PERMANENT_COLOR_SCHEME, {
permanent: this.permanent,
});
});
// Finally update the mode and let the world know what's going on
this._updateMode();
this._dispatchEvent(COLOR_SCHEME_CHANGE, {colorScheme: this.mode});
this._dispatchEvent(PERMANENT_COLOR_SCHEME, {
permanent: this.permanent,
});
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === MODE) {
if (![LIGHT, DARK].includes(newValue)) {
throw new RangeError(`Allowed values: "${LIGHT}" and "${DARK}".`);
}
// Only show the dialog programmatically on devices not capable of hover
// and only if there is a label
if (win.matchMedia('(hover: none)').matches && this.remember) {
this._showPermanentAside();
}
if (this.permanent) {
store.setItem(NAME, this.mode);
}
this._updateRadios();
this._updateCheckbox();
this._updateMode();
} else if (name === APPEARANCE) {
if (![TOGGLE, SWITCH].includes(newValue)) {
throw new RangeError('Allowed values: "${TOGGLE}" and "${SWITCH}".');
}
this._updateAppearance();
} else if (name === PERMANENT) {
if (this.permanent) {
store.setItem(NAME, this.mode);
} else {
store.removeItem(NAME);
}
this.permanentCheckbox.checked = this.permanent;
} else if (name === LEGEND) {
this.legendLabel.textContent = newValue;
} else if (name === REMEMBER) {
this.permanentLabel.textContent = newValue;
} else if (name === LIGHT) {
this.lightLabel.textContent = newValue;
if (this.mode === LIGHT) {
this.checkboxLabel.textContent = newValue;
}
} else if (name === DARK) {
this.darkLabel.textContent = newValue;
if (this.mode === DARK) {
this.checkboxLabel.textContent = newValue;
}
}
}
_dispatchEvent(type, value) {
this.dispatchEvent(new CustomEvent(type, {
bubbles: true,
composed: true,
detail: value,
}));
}
_updateAppearance() {
// Hide or show the light-related affordances dependent on the appearance,
// which can be "switch" or "toggle".
const appearAsToggle = this.appearance === TOGGLE;
this.lightRadio.hidden = appearAsToggle;
this.lightLabel.hidden = appearAsToggle;
this.darkRadio.hidden = appearAsToggle;
this.darkLabel.hidden = appearAsToggle;
this.darkCheckbox.hidden = !appearAsToggle;
this.checkboxLabel.hidden = !appearAsToggle;
}
_updateRadios() {
if (this.mode === LIGHT) {
this.lightRadio.checked = true;
} else {
this.darkRadio.checked = true;
}
}
_updateCheckbox() {
if (this.mode === LIGHT) {
this.checkboxLabel.style.setProperty(`--${NAME}-checkbox-icon`,
`var(--${NAME}-light-icon, url("${DEFAULT_URL}moon.png"))`);
this.checkboxLabel.textContent = this.light;
this.darkCheckbox.checked = false;
} else {
this.checkboxLabel.style.setProperty(`--${NAME}-checkbox-icon`,
`var(--${NAME}-dark-icon, url("${DEFAULT_URL}sun.png"))`);
this.checkboxLabel.textContent = this.dark;
this.darkCheckbox.checked = true;
}
}
_updateMode() {
if (this.mode === LIGHT) {
this._lightCSS.forEach((link) => {
link.media = ALL;
link.disabled = false;
});
this._darkCSS.forEach((link) => {
link.media = NOT_ALL;
link.disabled = true;
});
} else {
this._darkCSS.forEach((link) => {
link.media = ALL;
link.disabled = false;
});
this._lightCSS.forEach((link) => {
link.media = NOT_ALL;
link.disabled = true;
});
}
}
_showPermanentAside() {
this.permanentAside.style.visibility = 'visible';
win.setTimeout(() => {
this.permanentAside.style.visibility = 'hidden';
}, 3000);
}
}
win.customElements.define(NAME, DarkModeToggle);