diff --git a/dist/theme-switch.js b/dist/theme-switch.js index 89fb456..d02ae76 100644 --- a/dist/theme-switch.js +++ b/dist/theme-switch.js @@ -76,6 +76,15 @@ // TODO: Make the component customizable by adding custom attributes: // See https://medium.com/technofunnel/creating-passing-data-to-html-custom-elements-using-attributes-bfd9aa759fd4 +// FIXME: If the switch is toggled too quickly, the switches in other open tabs may not update consistently. +// Steps to reproduce the bug: +// - Open two tabs +// - Set the theme of both tabs to "auto" +// - In one of the pages, click the switch three times in rapid succession so its new state is "auto" again +// The other tab should also show auto icon but it shows light icon. +// To resolve this, instead of listening for localstorage events, maybe we can pull the +// localstorage state for changes every 100 ms and update the switch if necessary. + /* * NOTE: To avoid name collisions if another script declares variables or functions with the same name * as ours (i.e. defining them in the global scope) and browsers complaining about identifiers diff --git a/dist/theme-switch.min.js.map b/dist/theme-switch.min.js.map index c29284b..759c469 100644 --- a/dist/theme-switch.min.js.map +++ b/dist/theme-switch.min.js.map @@ -1 +1 @@ -{"version":3,"file":"theme-switch.min.js","sources":["../src/main.js"],"sourcesContent":["/*\r\n* NOTE: Do not use this script as an ES6 module.\r\n* ES6 modules are deferred and we don't want that because\r\n* we want the user previous theme selection to be applied\r\n* as soon as possible (before the page is rendered).\r\n* */\r\n\r\n/*\r\n* There are two types of modules mostly used in JavaScript.\r\n* One is created by Node.js and is used inside the Node environment\r\n* and has been available for a long time. It is called CommonJS.\r\n* Another is the standard native JavaScript modules introduced in ES6.\r\n*\r\n* The Node variant (CommonJS) uses `module.exports` (or simply `exports`) and\r\n* `require()` to export and import scripts, functions, variables, etc.\r\n* Browsers do not know about `exports` or `require` functions and throw error\r\n* because they are objects and functions created just in Node environment and set globally.\r\n* If you want to use this type of module in browsers, you should bundle the files\r\n* (merge all of them into a single JS file which eliminates the need for exports and require)\r\n* with tools like babel, webpack, rollup, etc.\r\n*\r\n* ES6 modules use `export` and `import` keywords for the same purpose.\r\n*\r\n* Example Node modules:\r\n*\r\n* my-calculator.js\r\n* const PI = 3.14;\r\n* function calculate() {}\r\n* modules.exports.calculate = calculate;\r\n* modules.exports.PI = PI;\r\n*\r\n* main.js\r\n* const calculator = require(\"my-calculator\");\r\n* const perimeter = 2 * calculator.PI;\r\n* const result = calculator.calculate();\r\n*\r\n* Example ES6 modules:\r\n*\r\n* my-calculator.js\r\n* export const PI = 3.14;\r\n* export function calculate() {}\r\n*\r\n* main.js\r\n* import { calculate, PI } from \"my-calculator.js\";\r\n* const perimeter = 2 * PI;\r\n* const result = calculate();\r\n*\r\n* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules\r\n* and https://stackoverflow.com/a/9901097/8583692\r\n* */\r\n\r\n/*\r\n* Minify the script either through command line with babel:\r\n* - babel main.js --source-maps --out-file result.min.js\r\n* or with babel-minify (also has an alias called minify) which is useful if\r\n* you don't already use babel (as a preset) or want to run minification standalone.\r\n* Note that it does not take into account babel.config.json settings.\r\n* - babel-minify (or minify) main.js --mangle --no-comments --out-file result.min.js`\r\n* Or automate it with IntelliJ file watcher\r\n* - babel\r\n* + program: $ProjectFileDir$\\node_modules\\.bin\\babel\r\n* + arguments: $FilePathRelativeToProjectRoot$ --out-file $FileNameWithoutExtension$.min.js --source-maps\r\n* Note that setting \"sourceMaps\": \"true\" in babel.config.json does not work because of\r\n* this bug: https://github.com/babel/babel/issues/5261 (\"sourceMaps\": \"inline\" works, however)\r\n* - UglifyJS\r\n* - YUI compressor (seems to be deprecated and removed in newer versions of IntelliJ)\r\n*\r\n* Babel preset-env inserts a semicolon at the start of the minified file.\r\n* See why: https://stackoverflow.com/q/1873983/8583692\r\n* */\r\n\r\n// TODO: extract Jest configuration to a jest.config.js file\r\n// TODO: Add an attribute so the user can define key name stored in localstorage\r\n// TODO: Make the component customizable by adding custom attributes:\r\n// See https://medium.com/technofunnel/creating-passing-data-to-html-custom-elements-using-attributes-bfd9aa759fd4\r\n\r\n/*\r\n* NOTE: To avoid name collisions if another script declares variables or functions with the same name\r\n* as ours (i.e. defining them in the global scope) and browsers complaining about identifiers\r\n* being redeclared, we wrap all our code in a closure or IIFE (sort of creating a namespace for it).\r\n* ES6 now supports block scope as well (simply wrapping the whole code in {}).\r\n* I am now using the babel-plugin-iife-wrap plugin to wrap the whole result (minified)\r\n* code in an IIFE.\r\n* For examples, see these libraries:\r\n* - https://github.com/highlightjs/highlight.js/blob/main/src/highlight.js\r\n* - https://github.com/jashkenas/underscore/blob/master/underscore.js\r\n* See\r\n* - https://www.w3schools.com/js/js_scope.asp\r\n* - https://github.com/jhnns/rewire/issues/136#issuecomment-380829197\r\n* - https://stackoverflow.com/a/32750216/8583692\r\n* - https://stackoverflow.com/q/8228281/8583692\r\n* - https://stackoverflow.com/q/881515/8583692\r\n* - https://stackoverflow.com/q/39388777/8583692\r\n* - https://stackoverflow.com/a/47207686/8583692\r\n* We could also do something like these libraries:\r\n* - https://github.com/juliangarnier/anime/blob/master/build.js\r\n* - https://github.com/mrdoob/three.js/\r\n* - https://github.com/moment/moment\r\n* - https://github.com/floating-ui/floating-ui\r\n* */\r\n\r\nconst ELEMENT_NAME = \"theme-switch\";\r\nconst ICON_SIZE = 24 /* px */;\r\nconst ICON_COLOR = \"#000\";\r\nconst THEME_KEY = \"theme\";\r\nconst THEME_AUTO = \"auto\";\r\nconst THEME_DARK = \"dark\";\r\nconst THEME_LIGHT = \"light\";\r\nconst THEME_VALUES = [THEME_AUTO, THEME_DARK, THEME_LIGHT];\r\nconst THEME_DEFAULT = THEME_LIGHT;\r\nconst THEME_ATTRIBUTE = \"data-theme\";\r\nconst COLOR_SCHEME_DARK = \"(prefers-color-scheme: dark)\";\r\nconst CUSTOM_EVENT_NAME = \"themeToggle\";\r\n// circleRadius, raysOpacity, eclipseCenterX, letterOffset\r\nconst ICON_INITIAL_STATE_FOR_AUTO = [10, 0, 33, 0];\r\nconst ICON_INITIAL_STATE_FOR_DARK = [10, 0, 20, 1];\r\nconst ICON_INITIAL_STATE_FOR_LIGHT = [5, 1, 33, 1];\r\n\r\nclass ThemeSwitchElement extends HTMLElement {\r\n shadowRoot;\r\n static counter = 0; // See https://stackoverflow.com/a/43116254/8583692\r\n identifier = ThemeSwitchElement.counter++;\r\n\r\n constructor() {\r\n super();\r\n // See https://stackoverflow.com/q/2305654/8583692\r\n this.shadowRoot = this.attachShadow({ mode: \"open\" });\r\n this.shadowRoot.innerHTML = generateIcon(...getInitialStateForIcon());\r\n // Add the click listener to the top-most parent (the custom element itself)\r\n // so the padding etc. on the element be also clickable\r\n this.shadowRoot.host.addEventListener(\"click\", this.onClick);\r\n // If another theme switch in page toggled, update my icon too\r\n document.addEventListener(CUSTOM_EVENT_NAME, event => {\r\n if (event.detail.originId !== this.identifier) {\r\n this.adaptToTheme();\r\n }\r\n });\r\n // If a theme switch in another page toggled, update my state too\r\n // See https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event\r\n window.addEventListener(\"storage\", event => {\r\n if (event.key === THEME_KEY) {\r\n this.adaptToTheme();\r\n updateTheme();\r\n }\r\n });\r\n // Create some CSS to apply to the shadow DOM\r\n // See https://css-tricks.com/styling-a-web-component/\r\n const style = document.createElement(\"style\");\r\n style.textContent = generateStyle();\r\n this.shadowRoot.append(style);\r\n }\r\n\r\n onClick() {\r\n const oldTheme = getUserThemeSelection();\r\n this.toggleTheme(oldTheme);\r\n const newTheme = getUserThemeSelection();\r\n const event = this.createEvent(oldTheme, newTheme);\r\n this.dispatchEvent(event);\r\n }\r\n\r\n // See https://stackoverflow.com/a/53804106/8583692\r\n createEvent(oldTheme, newTheme) {\r\n return new CustomEvent(CUSTOM_EVENT_NAME, {\r\n detail: {\r\n originId: this.identifier,\r\n oldState: oldTheme,\r\n newState: newTheme\r\n },\r\n bubbles: true,\r\n composed: true,\r\n cancelable: false\r\n });\r\n }\r\n\r\n // See https://stackoverflow.com/q/48316611\r\n toggleTheme(currentTheme) {\r\n if (currentTheme === THEME_AUTO) {\r\n localStorage.setItem(THEME_KEY, THEME_LIGHT);\r\n this.animateThemeButtonIconToLight();\r\n } else if (currentTheme === THEME_DARK) {\r\n localStorage.setItem(THEME_KEY, THEME_AUTO);\r\n this.animateThemeButtonIconToAuto();\r\n } else /* if (theme === THEME_LIGHT) */ {\r\n localStorage.setItem(THEME_KEY, THEME_DARK);\r\n this.animateThemeButtonIconToDark();\r\n }\r\n updateTheme();\r\n }\r\n\r\n adaptToTheme() {\r\n const theme = getUserThemeSelection();\r\n if (theme === THEME_AUTO) {\r\n this.animateThemeButtonIconToAuto();\r\n } else if (theme === THEME_DARK) {\r\n this.animateThemeButtonIconToDark();\r\n } else /* if (theme === THEME_LIGHT) */ {\r\n this.animateThemeButtonIconToLight();\r\n }\r\n }\r\n\r\n animateThemeButtonIconToLight() {\r\n this.shadowRoot.getElementById(\"letter-anim-hide\").beginElement();\r\n this.shadowRoot.getElementById(\"core-anim-shrink\").beginElement();\r\n this.shadowRoot.getElementById(\"rays-anim-rotate\").beginElement();\r\n this.shadowRoot.getElementById(\"rays-anim-show\").beginElement();\r\n }\r\n\r\n animateThemeButtonIconToAuto() {\r\n this.shadowRoot.getElementById(\"eclipse-anim-go\").beginElement();\r\n this.shadowRoot.getElementById(\"letter-anim-show\").beginElement();\r\n }\r\n\r\n animateThemeButtonIconToDark() {\r\n this.shadowRoot.getElementById(\"rays-anim-hide\").beginElement();\r\n this.shadowRoot.getElementById(\"core-anim-enlarge\").beginElement();\r\n this.shadowRoot.getElementById(\"eclipse-anim-come\").beginElement();\r\n }\r\n}\r\n\r\nfunction generateIcon(circleRadius, raysOpacity, eclipseCenterX, letterOffset) {\r\n return `ICON_TEMPLATE`;\r\n}\r\n\r\nfunction generateStyle() {\r\n return `STYLES_TEMPLATE`;\r\n}\r\n\r\nupdateTheme();\r\nwindow.customElements.define(ELEMENT_NAME, ThemeSwitchElement);\r\nwindow\r\n .matchMedia(COLOR_SCHEME_DARK)\r\n .addEventListener(\"change\", updateTheme);\r\n\r\nfunction updateTheme() {\r\n let theme = getUserThemeSelection();\r\n if (theme === THEME_AUTO) theme = getSystemTheme();\r\n document.documentElement.setAttribute(THEME_ATTRIBUTE, theme);\r\n}\r\n\r\nfunction getUserThemeSelection() {\r\n const userSelection = localStorage.getItem(THEME_KEY);\r\n return THEME_VALUES.includes(userSelection) ? userSelection : THEME_DEFAULT;\r\n}\r\n\r\nfunction getSystemTheme() {\r\n const isDark = window.matchMedia(COLOR_SCHEME_DARK).matches;\r\n return isDark ? THEME_DARK : THEME_LIGHT;\r\n}\r\n\r\nfunction getInitialStateForIcon() {\r\n const theme = getUserThemeSelection();\r\n if (theme === THEME_AUTO) {\r\n return ICON_INITIAL_STATE_FOR_AUTO;\r\n } else if (theme === THEME_DARK) {\r\n return ICON_INITIAL_STATE_FOR_DARK;\r\n } else /* if (theme === THEME_LIGHT) */ {\r\n return ICON_INITIAL_STATE_FOR_LIGHT;\r\n }\r\n}\r\n\r\n// Export for tests run by npm (no longer needed; kept for future reference)\r\n// See https://stackoverflow.com/q/63752210/8583692\r\n// and https://stackoverflow.com/a/54680602/8583692\r\n// and https://stackoverflow.com/q/43042889/8583692\r\n// and https://stackoverflow.com/a/1984728/8583692\r\n// if (typeof module !== \"undefined\") {\r\n// module.exports = {\r\n// updateTheme,\r\n// toggleTheme,\r\n// getSystemTheme,\r\n// getInitialStateForIcon,\r\n// animateThemeButtonIconToAuto,\r\n// animateThemeButtonIconToDark,\r\n// animateThemeButtonIconToLight\r\n// };\r\n// }\r\n"],"names":["THEME_KEY","THEME_AUTO","THEME_DARK","THEME_LIGHT","THEME_VALUES","COLOR_SCHEME_DARK","CUSTOM_EVENT_NAME","ICON_INITIAL_STATE_FOR_AUTO","ICON_INITIAL_STATE_FOR_DARK","ICON_INITIAL_STATE_FOR_LIGHT","ThemeSwitchElement","HTMLElement","shadowRoot","static","identifier","counter","constructor","super","this","attachShadow","mode","innerHTML","circleRadius","raysOpacity","eclipseCenterX","letterOffset","generateIcon","theme","getUserThemeSelection","getInitialStateForIcon","host","addEventListener","onClick","document","event","detail","originId","adaptToTheme","window","key","updateTheme","style","createElement","textContent","append","oldTheme","toggleTheme","newTheme","createEvent","dispatchEvent","CustomEvent","oldState","newState","bubbles","composed","cancelable","currentTheme","localStorage","setItem","animateThemeButtonIconToLight","animateThemeButtonIconToAuto","animateThemeButtonIconToDark","getElementById","beginElement","matchMedia","matches","documentElement","setAttribute","userSelection","getItem","includes","customElements","define"],"mappings":"yBAqGA,MAGMA,EAAY,QACZC,EAAa,OACbC,EAAa,OACbC,EAAc,QACdC,EAAe,CAACH,EAAYC,EAAYC,GAGxCE,EAAoB,+BACpBC,EAAoB,cAEpBC,EAA8B,CAAC,GAAI,EAAG,GAAI,GAC1CC,EAA8B,CAAC,GAAI,EAAG,GAAI,GAC1CC,EAA+B,CAAC,EAAG,EAAG,GAAI,GAEhD,MAAMC,UAA2BC,YAC7BC,WACAC,eAAiB,EACjBC,WAAaJ,EAAmBK,UAEhCC,cACIC,QAEAC,KAAKN,WAAaM,KAAKC,aAAa,CAAEC,KAAM,SAC5CF,KAAKN,WAAWS,UA4FxB,SAAsBC,EAAcC,EAAaC,EAAgBC,GAC7D,MAAO,8NAAcD,6fAAAC,oiBAAAF,44BAAAD,uYA7FWI,IA0HpC,WACI,MAAMC,EAAQC,IACd,OAAID,IAAU1B,EACHM,EACAoB,IAAUzB,EACVM,EAEAC,EAjIqCoB,IAG5CX,KAAKN,WAAWkB,KAAKC,iBAAiB,QAASb,KAAKc,SAEpDC,SAASF,iBAAiBzB,GAAmB4B,IACrCA,EAAMC,OAAOC,WAAalB,KAAKJ,YAC/BI,KAAKmB,kBAKbC,OAAOP,iBAAiB,WAAWG,IAC3BA,EAAMK,MAAQvC,IACdkB,KAAKmB,eACLG,QAKR,MAAMC,EAAQR,SAASS,cAAc,SACrCD,EAAME,YA4EH,8RA3EHzB,KAAKN,WAAWgC,OAAOH,GAG3BT,UACI,MAAMa,EAAWjB,IACjBV,KAAK4B,YAAYD,GACjB,MAAME,EAAWnB,IACXM,EAAQhB,KAAK8B,YAAYH,EAAUE,GACzC7B,KAAK+B,cAAcf,GAIvBc,YAAYH,EAAUE,GAClB,OAAO,IAAIG,YAAY5C,EAAmB,CACtC6B,OAAQ,CACJC,SAAUlB,KAAKJ,WACfqC,SAAUN,EACVO,SAAUL,GAEdM,SAAS,EACTC,UAAU,EACVC,YAAY,IAKpBT,YAAYU,GACJA,IAAiBvD,GACjBwD,aAAaC,QAAQ1D,EAAWG,GAChCe,KAAKyC,iCACEH,IAAiBtD,GACxBuD,aAAaC,QAAQ1D,EAAWC,GAChCiB,KAAK0C,iCAELH,aAAaC,QAAQ1D,EAAWE,GAChCgB,KAAK2C,gCAETrB,IAGJH,eACI,MAAMV,EAAQC,IACVD,IAAU1B,EACViB,KAAK0C,+BACEjC,IAAUzB,EACjBgB,KAAK2C,+BAEL3C,KAAKyC,gCAIbA,gCACIzC,KAAKN,WAAWkD,eAAe,oBAAoBC,eACnD7C,KAAKN,WAAWkD,eAAe,oBAAoBC,eACnD7C,KAAKN,WAAWkD,eAAe,oBAAoBC,eACnD7C,KAAKN,WAAWkD,eAAe,kBAAkBC,eAGrDH,+BACI1C,KAAKN,WAAWkD,eAAe,mBAAmBC,eAClD7C,KAAKN,WAAWkD,eAAe,oBAAoBC,eAGvDF,+BACI3C,KAAKN,WAAWkD,eAAe,kBAAkBC,eACjD7C,KAAKN,WAAWkD,eAAe,qBAAqBC,eACpD7C,KAAKN,WAAWkD,eAAe,qBAAqBC,gBAkB5D,SAASvB,IACL,IAAIb,EAAQC,IACRD,IAAU1B,IAAY0B,EAUXW,OAAO0B,WAAW3D,GAAmB4D,QACpC/D,EAAaC,GAV7B8B,SAASiC,gBAAgBC,aA9HL,aA8HmCxC,GAG3D,SAASC,IACL,MAAMwC,EAAgBX,aAAaY,QAAQrE,GAC3C,OAAOI,EAAakE,SAASF,GAAiBA,EApI5BjE,QAsHtBqC,IACAF,OAAOiC,eAAeC,OA/HD,eA+HsB9D,GAC3C4B,OACK0B,WAAW3D,GACX0B,iBAAiB,SAAUS"} \ No newline at end of file +{"version":3,"file":"theme-switch.min.js","sources":["../src/main.js"],"sourcesContent":["/*\r\n* NOTE: Do not use this script as an ES6 module.\r\n* ES6 modules are deferred and we don't want that because\r\n* we want the user previous theme selection to be applied\r\n* as soon as possible (before the page is rendered).\r\n* */\r\n\r\n/*\r\n* There are two types of modules mostly used in JavaScript.\r\n* One is created by Node.js and is used inside the Node environment\r\n* and has been available for a long time. It is called CommonJS.\r\n* Another is the standard native JavaScript modules introduced in ES6.\r\n*\r\n* The Node variant (CommonJS) uses `module.exports` (or simply `exports`) and\r\n* `require()` to export and import scripts, functions, variables, etc.\r\n* Browsers do not know about `exports` or `require` functions and throw error\r\n* because they are objects and functions created just in Node environment and set globally.\r\n* If you want to use this type of module in browsers, you should bundle the files\r\n* (merge all of them into a single JS file which eliminates the need for exports and require)\r\n* with tools like babel, webpack, rollup, etc.\r\n*\r\n* ES6 modules use `export` and `import` keywords for the same purpose.\r\n*\r\n* Example Node modules:\r\n*\r\n* my-calculator.js\r\n* const PI = 3.14;\r\n* function calculate() {}\r\n* modules.exports.calculate = calculate;\r\n* modules.exports.PI = PI;\r\n*\r\n* main.js\r\n* const calculator = require(\"my-calculator\");\r\n* const perimeter = 2 * calculator.PI;\r\n* const result = calculator.calculate();\r\n*\r\n* Example ES6 modules:\r\n*\r\n* my-calculator.js\r\n* export const PI = 3.14;\r\n* export function calculate() {}\r\n*\r\n* main.js\r\n* import { calculate, PI } from \"my-calculator.js\";\r\n* const perimeter = 2 * PI;\r\n* const result = calculate();\r\n*\r\n* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules\r\n* and https://stackoverflow.com/a/9901097/8583692\r\n* */\r\n\r\n/*\r\n* Minify the script either through command line with babel:\r\n* - babel main.js --source-maps --out-file result.min.js\r\n* or with babel-minify (also has an alias called minify) which is useful if\r\n* you don't already use babel (as a preset) or want to run minification standalone.\r\n* Note that it does not take into account babel.config.json settings.\r\n* - babel-minify (or minify) main.js --mangle --no-comments --out-file result.min.js`\r\n* Or automate it with IntelliJ file watcher\r\n* - babel\r\n* + program: $ProjectFileDir$\\node_modules\\.bin\\babel\r\n* + arguments: $FilePathRelativeToProjectRoot$ --out-file $FileNameWithoutExtension$.min.js --source-maps\r\n* Note that setting \"sourceMaps\": \"true\" in babel.config.json does not work because of\r\n* this bug: https://github.com/babel/babel/issues/5261 (\"sourceMaps\": \"inline\" works, however)\r\n* - UglifyJS\r\n* - YUI compressor (seems to be deprecated and removed in newer versions of IntelliJ)\r\n*\r\n* Babel preset-env inserts a semicolon at the start of the minified file.\r\n* See why: https://stackoverflow.com/q/1873983/8583692\r\n* */\r\n\r\n// TODO: extract Jest configuration to a jest.config.js file\r\n// TODO: Add an attribute so the user can define key name stored in localstorage\r\n// TODO: Make the component customizable by adding custom attributes:\r\n// See https://medium.com/technofunnel/creating-passing-data-to-html-custom-elements-using-attributes-bfd9aa759fd4\r\n\r\n// FIXME: If the switch is toggled too quickly, the switches in other open tabs may not update consistently.\r\n// Steps to reproduce the bug:\r\n// - Open two tabs\r\n// - Set the theme of both tabs to \"auto\"\r\n// - In one of the pages, click the switch three times in rapid succession so its new state is \"auto\" again\r\n// The other tab should also show auto icon but it shows light icon.\r\n// To resolve this, instead of listening for localstorage events, maybe we can pull the\r\n// localstorage state for changes every 100 ms and update the switch if necessary.\r\n\r\n/*\r\n* NOTE: To avoid name collisions if another script declares variables or functions with the same name\r\n* as ours (i.e. defining them in the global scope) and browsers complaining about identifiers\r\n* being redeclared, we wrap all our code in a closure or IIFE (sort of creating a namespace for it).\r\n* ES6 now supports block scope as well (simply wrapping the whole code in {}).\r\n* I am now using the babel-plugin-iife-wrap plugin to wrap the whole result (minified)\r\n* code in an IIFE.\r\n* For examples, see these libraries:\r\n* - https://github.com/highlightjs/highlight.js/blob/main/src/highlight.js\r\n* - https://github.com/jashkenas/underscore/blob/master/underscore.js\r\n* See\r\n* - https://www.w3schools.com/js/js_scope.asp\r\n* - https://github.com/jhnns/rewire/issues/136#issuecomment-380829197\r\n* - https://stackoverflow.com/a/32750216/8583692\r\n* - https://stackoverflow.com/q/8228281/8583692\r\n* - https://stackoverflow.com/q/881515/8583692\r\n* - https://stackoverflow.com/q/39388777/8583692\r\n* - https://stackoverflow.com/a/47207686/8583692\r\n* We could also do something like these libraries:\r\n* - https://github.com/juliangarnier/anime/blob/master/build.js\r\n* - https://github.com/mrdoob/three.js/\r\n* - https://github.com/moment/moment\r\n* - https://github.com/floating-ui/floating-ui\r\n* */\r\n\r\nconst ELEMENT_NAME = \"theme-switch\";\r\nconst ICON_SIZE = 24 /* px */;\r\nconst ICON_COLOR = \"#000\";\r\nconst THEME_KEY = \"theme\";\r\nconst THEME_AUTO = \"auto\";\r\nconst THEME_DARK = \"dark\";\r\nconst THEME_LIGHT = \"light\";\r\nconst THEME_VALUES = [THEME_AUTO, THEME_DARK, THEME_LIGHT];\r\nconst THEME_DEFAULT = THEME_LIGHT;\r\nconst THEME_ATTRIBUTE = \"data-theme\";\r\nconst COLOR_SCHEME_DARK = \"(prefers-color-scheme: dark)\";\r\nconst CUSTOM_EVENT_NAME = \"themeToggle\";\r\n// circleRadius, raysOpacity, eclipseCenterX, letterOffset\r\nconst ICON_INITIAL_STATE_FOR_AUTO = [10, 0, 33, 0];\r\nconst ICON_INITIAL_STATE_FOR_DARK = [10, 0, 20, 1];\r\nconst ICON_INITIAL_STATE_FOR_LIGHT = [5, 1, 33, 1];\r\n\r\nclass ThemeSwitchElement extends HTMLElement {\r\n shadowRoot;\r\n static counter = 0; // See https://stackoverflow.com/a/43116254/8583692\r\n identifier = ThemeSwitchElement.counter++;\r\n\r\n constructor() {\r\n super();\r\n // See https://stackoverflow.com/q/2305654/8583692\r\n this.shadowRoot = this.attachShadow({ mode: \"open\" });\r\n this.shadowRoot.innerHTML = generateIcon(...getInitialStateForIcon());\r\n // Add the click listener to the top-most parent (the custom element itself)\r\n // so the padding etc. on the element be also clickable\r\n this.shadowRoot.host.addEventListener(\"click\", this.onClick);\r\n // If another theme switch in page toggled, update my icon too\r\n document.addEventListener(CUSTOM_EVENT_NAME, event => {\r\n if (event.detail.originId !== this.identifier) {\r\n this.adaptToTheme();\r\n }\r\n });\r\n // If a theme switch in another page toggled, update my state too\r\n // See https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event\r\n window.addEventListener(\"storage\", event => {\r\n if (event.key === THEME_KEY) {\r\n this.adaptToTheme();\r\n updateTheme();\r\n }\r\n });\r\n // Create some CSS to apply to the shadow DOM\r\n // See https://css-tricks.com/styling-a-web-component/\r\n const style = document.createElement(\"style\");\r\n style.textContent = generateStyle();\r\n this.shadowRoot.append(style);\r\n }\r\n\r\n onClick() {\r\n const oldTheme = getUserThemeSelection();\r\n this.toggleTheme(oldTheme);\r\n const newTheme = getUserThemeSelection();\r\n const event = this.createEvent(oldTheme, newTheme);\r\n this.dispatchEvent(event);\r\n }\r\n\r\n // See https://stackoverflow.com/a/53804106/8583692\r\n createEvent(oldTheme, newTheme) {\r\n return new CustomEvent(CUSTOM_EVENT_NAME, {\r\n detail: {\r\n originId: this.identifier,\r\n oldState: oldTheme,\r\n newState: newTheme\r\n },\r\n bubbles: true,\r\n composed: true,\r\n cancelable: false\r\n });\r\n }\r\n\r\n // See https://stackoverflow.com/q/48316611\r\n toggleTheme(currentTheme) {\r\n if (currentTheme === THEME_AUTO) {\r\n localStorage.setItem(THEME_KEY, THEME_LIGHT);\r\n this.animateThemeButtonIconToLight();\r\n } else if (currentTheme === THEME_DARK) {\r\n localStorage.setItem(THEME_KEY, THEME_AUTO);\r\n this.animateThemeButtonIconToAuto();\r\n } else /* if (theme === THEME_LIGHT) */ {\r\n localStorage.setItem(THEME_KEY, THEME_DARK);\r\n this.animateThemeButtonIconToDark();\r\n }\r\n updateTheme();\r\n }\r\n\r\n adaptToTheme() {\r\n const theme = getUserThemeSelection();\r\n if (theme === THEME_AUTO) {\r\n this.animateThemeButtonIconToAuto();\r\n } else if (theme === THEME_DARK) {\r\n this.animateThemeButtonIconToDark();\r\n } else /* if (theme === THEME_LIGHT) */ {\r\n this.animateThemeButtonIconToLight();\r\n }\r\n }\r\n\r\n animateThemeButtonIconToLight() {\r\n this.shadowRoot.getElementById(\"letter-anim-hide\").beginElement();\r\n this.shadowRoot.getElementById(\"core-anim-shrink\").beginElement();\r\n this.shadowRoot.getElementById(\"rays-anim-rotate\").beginElement();\r\n this.shadowRoot.getElementById(\"rays-anim-show\").beginElement();\r\n }\r\n\r\n animateThemeButtonIconToAuto() {\r\n this.shadowRoot.getElementById(\"eclipse-anim-go\").beginElement();\r\n this.shadowRoot.getElementById(\"letter-anim-show\").beginElement();\r\n }\r\n\r\n animateThemeButtonIconToDark() {\r\n this.shadowRoot.getElementById(\"rays-anim-hide\").beginElement();\r\n this.shadowRoot.getElementById(\"core-anim-enlarge\").beginElement();\r\n this.shadowRoot.getElementById(\"eclipse-anim-come\").beginElement();\r\n }\r\n}\r\n\r\nfunction generateIcon(circleRadius, raysOpacity, eclipseCenterX, letterOffset) {\r\n return `ICON_TEMPLATE`;\r\n}\r\n\r\nfunction generateStyle() {\r\n return `STYLES_TEMPLATE`;\r\n}\r\n\r\nupdateTheme();\r\nwindow.customElements.define(ELEMENT_NAME, ThemeSwitchElement);\r\nwindow\r\n .matchMedia(COLOR_SCHEME_DARK)\r\n .addEventListener(\"change\", updateTheme);\r\n\r\nfunction updateTheme() {\r\n let theme = getUserThemeSelection();\r\n if (theme === THEME_AUTO) theme = getSystemTheme();\r\n document.documentElement.setAttribute(THEME_ATTRIBUTE, theme);\r\n}\r\n\r\nfunction getUserThemeSelection() {\r\n const userSelection = localStorage.getItem(THEME_KEY);\r\n return THEME_VALUES.includes(userSelection) ? userSelection : THEME_DEFAULT;\r\n}\r\n\r\nfunction getSystemTheme() {\r\n const isDark = window.matchMedia(COLOR_SCHEME_DARK).matches;\r\n return isDark ? THEME_DARK : THEME_LIGHT;\r\n}\r\n\r\nfunction getInitialStateForIcon() {\r\n const theme = getUserThemeSelection();\r\n if (theme === THEME_AUTO) {\r\n return ICON_INITIAL_STATE_FOR_AUTO;\r\n } else if (theme === THEME_DARK) {\r\n return ICON_INITIAL_STATE_FOR_DARK;\r\n } else /* if (theme === THEME_LIGHT) */ {\r\n return ICON_INITIAL_STATE_FOR_LIGHT;\r\n }\r\n}\r\n\r\n// Export for tests run by npm (no longer needed; kept for future reference)\r\n// See https://stackoverflow.com/q/63752210/8583692\r\n// and https://stackoverflow.com/a/54680602/8583692\r\n// and https://stackoverflow.com/q/43042889/8583692\r\n// and https://stackoverflow.com/a/1984728/8583692\r\n// if (typeof module !== \"undefined\") {\r\n// module.exports = {\r\n// updateTheme,\r\n// toggleTheme,\r\n// getSystemTheme,\r\n// getInitialStateForIcon,\r\n// animateThemeButtonIconToAuto,\r\n// animateThemeButtonIconToDark,\r\n// animateThemeButtonIconToLight\r\n// };\r\n// }\r\n"],"names":["THEME_KEY","THEME_AUTO","THEME_DARK","THEME_LIGHT","THEME_VALUES","COLOR_SCHEME_DARK","CUSTOM_EVENT_NAME","ICON_INITIAL_STATE_FOR_AUTO","ICON_INITIAL_STATE_FOR_DARK","ICON_INITIAL_STATE_FOR_LIGHT","ThemeSwitchElement","HTMLElement","shadowRoot","static","identifier","counter","constructor","super","this","attachShadow","mode","innerHTML","circleRadius","raysOpacity","eclipseCenterX","letterOffset","generateIcon","theme","getUserThemeSelection","getInitialStateForIcon","host","addEventListener","onClick","document","event","detail","originId","adaptToTheme","window","key","updateTheme","style","createElement","textContent","append","oldTheme","toggleTheme","newTheme","createEvent","dispatchEvent","CustomEvent","oldState","newState","bubbles","composed","cancelable","currentTheme","localStorage","setItem","animateThemeButtonIconToLight","animateThemeButtonIconToAuto","animateThemeButtonIconToDark","getElementById","beginElement","matchMedia","matches","documentElement","setAttribute","userSelection","getItem","includes","customElements","define"],"mappings":"yBA8GA,MAGMA,EAAY,QACZC,EAAa,OACbC,EAAa,OACbC,EAAc,QACdC,EAAe,CAACH,EAAYC,EAAYC,GAGxCE,EAAoB,+BACpBC,EAAoB,cAEpBC,EAA8B,CAAC,GAAI,EAAG,GAAI,GAC1CC,EAA8B,CAAC,GAAI,EAAG,GAAI,GAC1CC,EAA+B,CAAC,EAAG,EAAG,GAAI,GAEhD,MAAMC,UAA2BC,YAC7BC,WACAC,eAAiB,EACjBC,WAAaJ,EAAmBK,UAEhCC,cACIC,QAEAC,KAAKN,WAAaM,KAAKC,aAAa,CAAEC,KAAM,SAC5CF,KAAKN,WAAWS,UA4FxB,SAAsBC,EAAcC,EAAaC,EAAgBC,GAC7D,MAAO,8NAAcD,6fAAAC,oiBAAAF,44BAAAD,uYA7FWI,IA0HpC,WACI,MAAMC,EAAQC,IACd,OAAID,IAAU1B,EACHM,EACAoB,IAAUzB,EACVM,EAEAC,EAjIqCoB,IAG5CX,KAAKN,WAAWkB,KAAKC,iBAAiB,QAASb,KAAKc,SAEpDC,SAASF,iBAAiBzB,GAAmB4B,IACrCA,EAAMC,OAAOC,WAAalB,KAAKJ,YAC/BI,KAAKmB,kBAKbC,OAAOP,iBAAiB,WAAWG,IAC3BA,EAAMK,MAAQvC,IACdkB,KAAKmB,eACLG,QAKR,MAAMC,EAAQR,SAASS,cAAc,SACrCD,EAAME,YA4EH,8RA3EHzB,KAAKN,WAAWgC,OAAOH,GAG3BT,UACI,MAAMa,EAAWjB,IACjBV,KAAK4B,YAAYD,GACjB,MAAME,EAAWnB,IACXM,EAAQhB,KAAK8B,YAAYH,EAAUE,GACzC7B,KAAK+B,cAAcf,GAIvBc,YAAYH,EAAUE,GAClB,OAAO,IAAIG,YAAY5C,EAAmB,CACtC6B,OAAQ,CACJC,SAAUlB,KAAKJ,WACfqC,SAAUN,EACVO,SAAUL,GAEdM,SAAS,EACTC,UAAU,EACVC,YAAY,IAKpBT,YAAYU,GACJA,IAAiBvD,GACjBwD,aAAaC,QAAQ1D,EAAWG,GAChCe,KAAKyC,iCACEH,IAAiBtD,GACxBuD,aAAaC,QAAQ1D,EAAWC,GAChCiB,KAAK0C,iCAELH,aAAaC,QAAQ1D,EAAWE,GAChCgB,KAAK2C,gCAETrB,IAGJH,eACI,MAAMV,EAAQC,IACVD,IAAU1B,EACViB,KAAK0C,+BACEjC,IAAUzB,EACjBgB,KAAK2C,+BAEL3C,KAAKyC,gCAIbA,gCACIzC,KAAKN,WAAWkD,eAAe,oBAAoBC,eACnD7C,KAAKN,WAAWkD,eAAe,oBAAoBC,eACnD7C,KAAKN,WAAWkD,eAAe,oBAAoBC,eACnD7C,KAAKN,WAAWkD,eAAe,kBAAkBC,eAGrDH,+BACI1C,KAAKN,WAAWkD,eAAe,mBAAmBC,eAClD7C,KAAKN,WAAWkD,eAAe,oBAAoBC,eAGvDF,+BACI3C,KAAKN,WAAWkD,eAAe,kBAAkBC,eACjD7C,KAAKN,WAAWkD,eAAe,qBAAqBC,eACpD7C,KAAKN,WAAWkD,eAAe,qBAAqBC,gBAkB5D,SAASvB,IACL,IAAIb,EAAQC,IACRD,IAAU1B,IAAY0B,EAUXW,OAAO0B,WAAW3D,GAAmB4D,QACpC/D,EAAaC,GAV7B8B,SAASiC,gBAAgBC,aA9HL,aA8HmCxC,GAG3D,SAASC,IACL,MAAMwC,EAAgBX,aAAaY,QAAQrE,GAC3C,OAAOI,EAAakE,SAASF,GAAiBA,EApI5BjE,QAsHtBqC,IACAF,OAAOiC,eAAeC,OA/HD,eA+HsB9D,GAC3C4B,OACK0B,WAAW3D,GACX0B,iBAAiB,SAAUS"} \ No newline at end of file diff --git a/src/main.js b/src/main.js index 7738170..dd9dd87 100644 --- a/src/main.js +++ b/src/main.js @@ -74,6 +74,15 @@ // TODO: Make the component customizable by adding custom attributes: // See https://medium.com/technofunnel/creating-passing-data-to-html-custom-elements-using-attributes-bfd9aa759fd4 +// FIXME: If the switch is toggled too quickly, the switches in other open tabs may not update consistently. +// Steps to reproduce the bug: +// - Open two tabs +// - Set the theme of both tabs to "auto" +// - In one of the pages, click the switch three times in rapid succession so its new state is "auto" again +// The other tab should also show auto icon but it shows light icon. +// To resolve this, instead of listening for localstorage events, maybe we can pull the +// localstorage state for changes every 100 ms and update the switch if necessary. + /* * NOTE: To avoid name collisions if another script declares variables or functions with the same name * as ours (i.e. defining them in the global scope) and browsers complaining about identifiers