UNPKG

dark-mode-toggle

Version:
494 lines (459 loc) 19.7 kB
/** * 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 doc = document; let store = {}; try { store = localStorage; } catch (err) { // Do nothing. The user probably blocks cookies. } const PREFERS_COLOR_SCHEME = 'prefers-color-scheme'; const MEDIA = 'media'; const LIGHT = 'light'; const DARK = 'dark'; const SYSTEM = 'system'; const MQ_DARK = `(${PREFERS_COLOR_SCHEME}:${DARK})`; const MQ_LIGHT = `(${PREFERS_COLOR_SCHEME}:${LIGHT})`; const LINK_REL_STYLESHEET = 'link[rel=stylesheet]'; const STYLE = 'style'; const REMEMBER = 'remember'; const LEGEND = 'legend'; const TOGGLE = 'toggle'; const SWITCH = 'switch'; const THREE_WAY = 'three-way'; 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'); // ⚠️ Note: this is a minified version of `src/template-contents.tpl`. // Compress the CSS with https://cssminifier.com/, then paste it here. template.innerHTML = `<style>*,::after,::before{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:.25rem;padding-inline:.25rem}legend{font:var(--${NAME}-legend-font,inherit);padding:0}input,label{cursor:pointer}label{white-space:nowrap}input{opacity:0;position:absolute;pointer-events:none}input:focus-visible+label{outline:#e59700 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}label:not(:empty)::before{margin-inline-end:.5rem}[part=lightLabel]::before,[part=lightThreeWayLabel]::before{background-image:var(--${NAME}-light-icon, url("${DEFAULT_URL}sun.png"))}[part=darkLabel]::before,[part=darkThreeWayLabel]::before{filter:var(--${NAME}-icon-filter, none);background-image:var(--${NAME}-dark-icon, url("${DEFAULT_URL}moon.png"))}[part=systemThreeWayLabel]::before{background-image:var(--${NAME}-system-icon, url("${DEFAULT_URL}system.png"))}[part=toggleLabel]::before{background-image:var(--${NAME}-checkbox-icon,none)}[part=permanentLabel]::before{background-image:var(--${NAME}-remember-icon-unchecked, url("${DEFAULT_URL}unchecked.svg"))}[part$=ThreeWayLabel],[part=darkLabel],[part=lightLabel],[part=toggleLabel]{font:var(--${NAME}-label-font,inherit)}[part$=ThreeWayLabel]:empty,[part=darkLabel]:empty,[part=lightLabel]:empty,[part=toggleLabel]:empty{font-size:0;padding:0}[part=permanentLabel]{font:var(--${NAME}-remember-font,inherit)}input:checked+[part=permanentLabel]::before{background-image:var(--${NAME}-remember-icon-checked, url("${DEFAULT_URL}checked.svg"))}input:checked+[part$=ThreeWayLabel],input:checked+[part=darkLabel],input:checked+[part=lightLabel]{background-color:var(--${NAME}-active-mode-background-color,transparent)}input:checked+[part$=ThreeWayLabel]::before,input:checked+[part=darkLabel]::before,input:checked+[part=lightLabel]::before{background-color:var(--${NAME}-active-mode-background-color,transparent)}input:checked+[part=toggleLabel]::before,input[part=toggleCheckbox]:checked~[part=threeWayRadioWrapper] [part$=ThreeWayLabel]::before{filter:var(--${NAME}-icon-filter, none)}input:checked+[part=toggleLabel]~aside [part=permanentLabel]::before{filter:var(--${NAME}-remember-filter, invert(100%))}aside{visibility:hidden;margin-block-start:.15rem}[part=darkLabel]:focus-visible~aside,[part=lightLabel]:focus-visible~aside,[part=toggleLabel]:focus-visible~aside{visibility:visible;transition:visibility 0s}aside [part=permanentLabel]:empty{display:none}@media (hover:hover){aside{transition:visibility 3s}aside:hover{visibility:visible}[part=darkLabel]:hover~aside,[part=lightLabel]:hover~aside,[part=toggleLabel]:hover~aside{visibility:visible;transition:visibility 0s}}</style><form part=form><fieldset part=fieldset><legend part=legend></legend><input id=l part=lightRadio type=radio name=mode> <label for=l part=lightLabel></label> <input id=d part=darkRadio type=radio name=mode> <label for=d part=darkLabel></label> <input id=t part=toggleCheckbox type=checkbox> <label for=t part=toggleLabel></label> <span part=threeWayRadioWrapper><input id=3l part=lightThreeWayRadio type=radio name=three-way-mode> <label for=3l part=lightThreeWayLabel></label> <input id=3s part=systemThreeWayRadio type=radio name=three-way-mode> <label for=3s part=systemThreeWayLabel></label> <input id=3d part=darkThreeWayRadio type=radio name=three-way-mode> <label for=3d part=darkThreeWayLabel></label></span><aside part=aside><input id=p part=permanentCheckbox type=checkbox> <label for=p part=permanentLabel></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, SYSTEM); installStringReflection(this, REMEMBER); installBoolReflection(this, PERMANENT); this._darkCSS = null; this._lightCSS = null; doc.addEventListener(COLOR_SCHEME_CHANGE, (event) => { this.mode = event.detail.colorScheme; this._updateRadios(); this._updateCheckbox(); this._updateThreeWayRadios(); }); doc.addEventListener(PERMANENT_COLOR_SCHEME, (event) => { this.permanent = event.detail.permanent; this._permanentCheckbox.checked = this.permanent; this._updateThreeWayRadios(); }); this._initializeDOM(); } _initializeDOM() { const shadowRoot = this.attachShadow({ mode: 'open' }); shadowRoot.append(template.content.cloneNode(true)); // We need to support `media="(prefers-color-scheme: dark)"` (with space) // and `media="(prefers-color-scheme:dark)"` (without space) this._darkCSS = doc.querySelectorAll( `${LINK_REL_STYLESHEET}[${MEDIA}*=${PREFERS_COLOR_SCHEME}][${MEDIA}*="${DARK}"], ${STYLE}[${MEDIA}*=${PREFERS_COLOR_SCHEME}][${MEDIA}*="${DARK}"]`, ); this._lightCSS = doc.querySelectorAll( `${LINK_REL_STYLESHEET}[${MEDIA}*=${PREFERS_COLOR_SCHEME}][${MEDIA}*="${LIGHT}"], ${STYLE}[${MEDIA}*=${PREFERS_COLOR_SCHEME}][${MEDIA}*="${LIGHT}"]`, ); // Get DOM references. this._lightRadio = shadowRoot.querySelector('[part=lightRadio]'); this._lightLabel = shadowRoot.querySelector('[part=lightLabel]'); this._darkRadio = shadowRoot.querySelector('[part=darkRadio]'); this._darkLabel = shadowRoot.querySelector('[part=darkLabel]'); this._darkCheckbox = shadowRoot.querySelector('[part=toggleCheckbox]'); this._checkboxLabel = shadowRoot.querySelector('[part=toggleLabel]'); this._lightThreeWayRadio = shadowRoot.querySelector( '[part=lightThreeWayRadio]', ); this._lightThreeWayLabel = shadowRoot.querySelector( '[part=lightThreeWayLabel]', ); this._systemThreeWayRadio = shadowRoot.querySelector( '[part=systemThreeWayRadio]', ); this._systemThreeWayLabel = shadowRoot.querySelector( '[part=systemThreeWayLabel]', ); this._darkThreeWayRadio = shadowRoot.querySelector( '[part=darkThreeWayRadio]', ); this._darkThreeWayLabel = shadowRoot.querySelector( '[part=darkThreeWayLabel]', ); this._legendLabel = shadowRoot.querySelector('legend'); this._permanentAside = shadowRoot.querySelector('aside'); this._permanentCheckbox = shadowRoot.querySelector( '[part=permanentCheckbox]', ); this._permanentLabel = shadowRoot.querySelector('[part=permanentLabel]'); } connectedCallback() { // Does the browser support native `prefers-color-scheme`? const hasNativePrefersColorScheme = matchMedia(MQ_DARK).media !== NOT_ALL; // Listen to `prefers-color-scheme` changes. if (hasNativePrefersColorScheme) { matchMedia(MQ_DARK).addListener(({ matches }) => { if (this.permanent) { return; } 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. let rememberedValue = false; try { rememberedValue = store.getItem(NAME); } catch (err) { // Do nothing. The user probably blocks cookies. } if (rememberedValue && [DARK, LIGHT].includes(rememberedValue)) { this.mode = rememberedValue; this._permanentCheckbox.checked = true; this.permanent = true; } else if (hasNativePrefersColorScheme) { this.mode = matchMedia(MQ_LIGHT).matches ? LIGHT : DARK; } if (!this.mode) { this.mode = LIGHT; } if (this.permanent && !rememberedValue) { try { store.setItem(NAME, this.mode); } catch (err) { // Do nothing. The user probably blocks cookies. } } // Default to toggle appearance. if (!this.appearance) { this.appearance = TOGGLE; } // Update the appearance to toggle, switch or three-way. this._updateAppearance(); // Update the radios this._updateRadios(); // Make the checkbox reflect the state of the radios this._updateCheckbox(); // Make the 3 way radio reflect the state of the radios this._updateThreeWayRadios(); // 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._updateThreeWayRadios(); this._dispatchEvent(COLOR_SCHEME_CHANGE, { colorScheme: this.mode }); }); }); this._darkCheckbox.addEventListener('change', () => { this.mode = this._darkCheckbox.checked ? DARK : LIGHT; this._updateRadios(); this._updateThreeWayRadios(); this._dispatchEvent(COLOR_SCHEME_CHANGE, { colorScheme: this.mode }); }); this._lightThreeWayRadio.addEventListener('change', () => { this.mode = LIGHT; this.permanent = true; this._updateCheckbox(); this._updateRadios(); this._updateThreeWayRadios(); this._dispatchEvent(COLOR_SCHEME_CHANGE, { colorScheme: this.mode }); this._dispatchEvent(PERMANENT_COLOR_SCHEME, { permanent: this.permanent, }); }); this._darkThreeWayRadio.addEventListener('change', () => { this.mode = DARK; this.permanent = true; this._updateCheckbox(); this._updateRadios(); this._updateThreeWayRadios(); this._dispatchEvent(COLOR_SCHEME_CHANGE, { colorScheme: this.mode }); this._dispatchEvent(PERMANENT_COLOR_SCHEME, { permanent: this.permanent, }); }); this._systemThreeWayRadio.addEventListener('change', () => { this.mode = this._getPrefersColorScheme(); this.permanent = false; this._updateCheckbox(); this._updateRadios(); this._updateThreeWayRadios(); this._dispatchEvent(COLOR_SCHEME_CHANGE, { colorScheme: this.mode }); this._dispatchEvent(PERMANENT_COLOR_SCHEME, { permanent: this.permanent, }); }); // Make remembering the last mode optional this._permanentCheckbox.addEventListener('change', () => { this.permanent = this._permanentCheckbox.checked; this._updateThreeWayRadios(); 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) { const allAttributes = [LIGHT, SYSTEM, DARK]; if (!allAttributes.includes(newValue)) { throw new RangeError( `Allowed values are: "${allAttributes.join(`", "`)}".`, ); } // Only show the dialog programmatically on devices not capable of hover // and only if there is a label if (matchMedia('(hover:none)').matches && this.remember) { this._showPermanentAside(); } if (this.permanent) { try { store.setItem(NAME, this.mode); } catch (err) { // Do nothing. The user probably blocks cookies. } } this._updateRadios(); this._updateCheckbox(); this._updateThreeWayRadios(); this._updateMode(); } else if (name === APPEARANCE) { const allAppearanceOptions = [TOGGLE, SWITCH, THREE_WAY]; if (!allAppearanceOptions.includes(newValue)) { throw new RangeError( `Allowed values are: "${allAppearanceOptions.join(`", "`)}".`, ); } this._updateAppearance(); } else if (name === PERMANENT) { if (this.permanent) { if (this.mode) { try { store.setItem(NAME, this.mode); } catch (err) { // Do nothing. The user probably blocks cookies. } } } else { try { store.removeItem(NAME); } catch (err) { // Do nothing. The user probably blocks cookies. } } 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; } } } _getPrefersColorScheme() { return matchMedia(MQ_LIGHT).matches ? LIGHT : DARK; } _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" , "toggle" or "three-way". this._lightRadio.hidden = this._lightLabel.hidden = this._darkRadio.hidden = this._darkLabel.hidden = this._darkCheckbox.hidden = this._checkboxLabel.hidden = this._lightThreeWayRadio.hidden = this._lightThreeWayLabel.hidden = this._systemThreeWayRadio.hidden = this._systemThreeWayLabel.hidden = this._darkThreeWayRadio.hidden = this._darkThreeWayLabel.hidden = true; switch (this.appearance) { case SWITCH: this._lightRadio.hidden = this._lightLabel.hidden = this._darkRadio.hidden = this._darkLabel.hidden = false; break; case THREE_WAY: this._lightThreeWayRadio.hidden = this._lightThreeWayLabel.hidden = this._systemThreeWayRadio.hidden = this._systemThreeWayLabel.hidden = this._darkThreeWayRadio.hidden = this._darkThreeWayLabel.hidden = false; break; case TOGGLE: default: this._darkCheckbox.hidden = this._checkboxLabel.hidden = false; break; } } _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; if (!this.light) { this._checkboxLabel.ariaLabel = DARK; } 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; if (!this.dark) { this._checkboxLabel.ariaLabel = LIGHT; } this._darkCheckbox.checked = true; } } _updateThreeWayRadios() { this._lightThreeWayLabel.ariaLabel = LIGHT; this._systemThreeWayLabel.ariaLabel = SYSTEM; this._lightThreeWayLabel.ariaLabel = DARK; this._lightThreeWayLabel.textContent = this.light; this._systemThreeWayLabel.textContent = this.system; this._darkThreeWayLabel.textContent = this.dark; if (this.permanent) { if (this.mode === LIGHT) { this._lightThreeWayRadio.checked = true; } else { this._darkThreeWayRadio.checked = true; } } else { this._systemThreeWayRadio.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'; setTimeout(() => { this._permanentAside.style.visibility = 'hidden'; }, 3000); } } customElements.define(NAME, DarkModeToggle);