@anmiles/theme-switcher 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.eslintignore +2 -0
  2. package/.eslintrc.js +10 -0
  3. package/.vscode/settings.json +6 -0
  4. package/CHANGELOG.md +14 -0
  5. package/LICENSE.md +21 -0
  6. package/README.md +88 -0
  7. package/coverage.config.js +8 -0
  8. package/dev/index.html +35 -0
  9. package/dist/theme-switcher-0.1.0.js +2194 -0
  10. package/dist/theme-switcher-0.1.0.min.js +2 -0
  11. package/dist/theme-switcher-0.1.0.min.js.LICENSE.txt +9 -0
  12. package/jest.config.js +26 -0
  13. package/package.json +71 -0
  14. package/src/__mocks__/css.ts +1 -0
  15. package/src/__tests__/index.test.tsx +25 -0
  16. package/src/__tests__/theme.test.ts +23 -0
  17. package/src/components/App.tsx +55 -0
  18. package/src/components/Icon.tsx +19 -0
  19. package/src/components/ThemeSelector.tsx +34 -0
  20. package/src/components/__tests__/App.test.tsx +350 -0
  21. package/src/components/__tests__/__snapshots__/App.test.tsx.snap +153 -0
  22. package/src/components/icons/Checked.tsx +24 -0
  23. package/src/components/icons/Dark.tsx +13 -0
  24. package/src/components/icons/Light.tsx +21 -0
  25. package/src/components/icons/System.tsx +18 -0
  26. package/src/index.tsx +20 -0
  27. package/src/lib/__tests__/eventEmitter.test.ts +109 -0
  28. package/src/lib/eventEmitter.ts +32 -0
  29. package/src/lib/theme.ts +21 -0
  30. package/src/providers/__tests__/systemProvider.test.ts +102 -0
  31. package/src/providers/__tests__/userProvider.test.ts +60 -0
  32. package/src/providers/systemProvider.ts +40 -0
  33. package/src/providers/userProvider.ts +24 -0
  34. package/src/styles/style.css +52 -0
  35. package/tsconfig.build.json +7 -0
  36. package/tsconfig.json +16 -0
  37. package/tsconfig.test.json +7 -0
  38. package/webpack.config.js +67 -0
@@ -0,0 +1,2 @@
1
+ /*! For license information please see theme-switcher-0.1.0.min.js.LICENSE.txt */
2
+ !function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t(require("React"),require("ReactDOM"));else if("function"==typeof define&&define.amd)define(["React","ReactDOM"],t);else{var r="object"==typeof exports?t(require("React"),require("ReactDOM")):t(e.React,e.ReactDOM);for(var n in r)("object"==typeof exports?exports:e)[n]=r[n]}}(self,((e,t)=>(()=>{"use strict";var r={919:(e,t,r)=>{r.d(t,{A:()=>i});var n=r(601),o=r.n(n),s=r(314),a=r.n(s)()(o());a.push([e.id,'.themeSwitcher {\n\tcursor: pointer;\n\tposition: relative;\n}\n\n.themeSwitcher > svg:hover,\n.themeSwitcher li:hover {\n\tfilter: brightness(1.5);\n}\n\n.themeSwitcher svg {\n\twidth: 2em;\n\theight: 2em;\n\tstroke: currentColor;\n\tdisplay: block;\n}\n\n.themeSwitcher ul {\n\tlist-style-type: none;\n\tposition: absolute;\n\tleft: 0;\n\tmargin: 0.5em 0;\n\tpadding: 0;\n\tgap: 0;\n\toverflow-y: visible;\n\tz-index: 1;\n}\n\n.themeSwitcher li {\n\tpadding: 0.5em 1em;\n}\n\n.themeSwitcher[data-float="right"] ul {\n\tleft: auto;\n\tright: 0;\n}\n\n.themeSwitcher li {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 0.5em;\n}\n\n.themeSwitcher li svg {\n\twidth: 1.5em;\n\theight: 1.5em;\n}\n\n.themeSwitcher svg.checked {\n\twidth: 16px;\n\theight: 13.5px;\n}\n',""]);const i=a},314:e=>{e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var r="",n=void 0!==t[5];return t[4]&&(r+="@supports (".concat(t[4],") {")),t[2]&&(r+="@media ".concat(t[2]," {")),n&&(r+="@layer".concat(t[5].length>0?" ".concat(t[5]):""," {")),r+=e(t),n&&(r+="}"),t[2]&&(r+="}"),t[4]&&(r+="}"),r})).join("")},t.i=function(e,r,n,o,s){"string"==typeof e&&(e=[[null,e,void 0]]);var a={};if(n)for(var i=0;i<this.length;i++){var c=this[i][0];null!=c&&(a[c]=!0)}for(var l=0;l<e.length;l++){var u=[].concat(e[l]);n&&a[u[0]]||(void 0!==s&&(void 0===u[5]||(u[1]="@layer".concat(u[5].length>0?" ".concat(u[5]):""," {").concat(u[1],"}")),u[5]=s),r&&(u[2]?(u[1]="@media ".concat(u[2]," {").concat(u[1],"}"),u[2]=r):u[2]=r),o&&(u[4]?(u[1]="@supports (".concat(u[4],") {").concat(u[1],"}"),u[4]=o):u[4]="".concat(o)),t.push(u))}},t}},601:e=>{e.exports=function(e){return e[1]}},338:(e,t,r)=>{var n=r(845);t.createRoot=n.createRoot,t.hydrateRoot=n.hydrateRoot},20:(e,t,r)=>{var n=r(883),o=Symbol.for("react.element"),s=Symbol.for("react.fragment"),a=Object.prototype.hasOwnProperty,i=n.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,c={key:!0,ref:!0,__self:!0,__source:!0};function l(e,t,r){var n,s={},l=null,u=null;for(n in void 0!==r&&(l=""+r),void 0!==t.key&&(l=""+t.key),void 0!==t.ref&&(u=t.ref),t)a.call(t,n)&&!c.hasOwnProperty(n)&&(s[n]=t[n]);if(e&&e.defaultProps)for(n in t=e.defaultProps)void 0===s[n]&&(s[n]=t[n]);return{$$typeof:o,type:e,key:l,ref:u,props:s,_owner:i.current}}t.Fragment=s,t.jsx=l,t.jsxs=l},848:(e,t,r)=>{e.exports=r(20)},966:(e,t,r)=>{r.r(t),r.d(t,{default:()=>y});var n=r(72),o=r.n(n),s=r(825),a=r.n(s),i=r(659),c=r.n(i),l=r(56),u=r.n(l),d=r(540),f=r.n(d),h=r(113),m=r.n(h),p=r(919),v={};v.styleTagTransform=m(),v.setAttributes=u(),v.insert=c().bind(null,"head"),v.domAPI=a(),v.insertStyleElement=f(),o()(p.A,v);const y=p.A&&p.A.locals?p.A.locals:void 0},72:e=>{var t=[];function r(e){for(var r=-1,n=0;n<t.length;n++)if(t[n].identifier===e){r=n;break}return r}function n(e,n){for(var s={},a=[],i=0;i<e.length;i++){var c=e[i],l=n.base?c[0]+n.base:c[0],u=s[l]||0,d="".concat(l," ").concat(u);s[l]=u+1;var f=r(d),h={css:c[1],media:c[2],sourceMap:c[3],supports:c[4],layer:c[5]};if(-1!==f)t[f].references++,t[f].updater(h);else{var m=o(h,n);n.byIndex=i,t.splice(i,0,{identifier:d,updater:m,references:1})}a.push(d)}return a}function o(e,t){var r=t.domAPI(t);return r.update(e),function(t){if(t){if(t.css===e.css&&t.media===e.media&&t.sourceMap===e.sourceMap&&t.supports===e.supports&&t.layer===e.layer)return;r.update(e=t)}else r.remove()}}e.exports=function(e,o){var s=n(e=e||[],o=o||{});return function(e){e=e||[];for(var a=0;a<s.length;a++){var i=r(s[a]);t[i].references--}for(var c=n(e,o),l=0;l<s.length;l++){var u=r(s[l]);0===t[u].references&&(t[u].updater(),t.splice(u,1))}s=c}}},659:e=>{var t={};e.exports=function(e,r){var n=function(e){if(void 0===t[e]){var r=document.querySelector(e);if(window.HTMLIFrameElement&&r instanceof window.HTMLIFrameElement)try{r=r.contentDocument.head}catch(e){r=null}t[e]=r}return t[e]}(e);if(!n)throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");n.appendChild(r)}},540:e=>{e.exports=function(e){var t=document.createElement("style");return e.setAttributes(t,e.attributes),e.insert(t,e.options),t}},56:(e,t,r)=>{e.exports=function(e){var t=r.nc;t&&e.setAttribute("nonce",t)}},825:e=>{e.exports=function(e){if("undefined"==typeof document)return{update:function(){},remove:function(){}};var t=e.insertStyleElement(e);return{update:function(r){!function(e,t,r){var n="";r.supports&&(n+="@supports (".concat(r.supports,") {")),r.media&&(n+="@media ".concat(r.media," {"));var o=void 0!==r.layer;o&&(n+="@layer".concat(r.layer.length>0?" ".concat(r.layer):""," {")),n+=r.css,o&&(n+="}"),r.media&&(n+="}"),r.supports&&(n+="}");var s=r.sourceMap;s&&"undefined"!=typeof btoa&&(n+="\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(s))))," */")),t.styleTagTransform(n,e,t.options)}(t,e,r)},remove:function(){!function(e){if(null===e.parentNode)return!1;e.parentNode.removeChild(e)}(t)}}}},113:e=>{e.exports=function(e,t){if(t.styleSheet)t.styleSheet.cssText=e;else{for(;t.firstChild;)t.removeChild(t.firstChild);t.appendChild(document.createTextNode(e))}}},759:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const o=r(848),s=r(883),a=r(12),i=r(6);r(966);const c=n(r(771)),l=n(r(210));t.default=function({float:e}){const t=(0,s.useMemo)((()=>new a.UserProvider),[]),r=(0,s.useMemo)((()=>new i.SystemProvider),[]),[n,u]=(0,s.useState)(t.get()),[d,f]=(0,s.useState)(r.get()),[h,m]=(0,s.useState)(!1),p=n??d;return(0,s.useEffect)((()=>{document.body.setAttribute("data-theme",p),t.on("change",u),r.on("change",f),r.watch()}),[p,t,r]),(0,o.jsxs)("div",{className:"themeSwitcher","data-testid":"theme-switcher","data-float":e,onClick:()=>{m(!h)},children:[(0,o.jsx)(c.default,{theme:p}),h?(0,o.jsx)(l.default,{currentUserTheme:n,onListItemClick:e=>{t.set(e),m(!1)}}):null]})}},771:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const o=r(848),s=n(r(77)),a=n(r(277)),i=n(r(950));t.default=function({theme:e}){switch(e){case"light":return(0,o.jsx)(a.default,{});case"dark":return(0,o.jsx)(s.default,{});default:return(0,o.jsx)(i.default,{})}}},210:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const o=r(848),s=r(48),a=n(r(771)),i=n(r(625));t.default=function({currentUserTheme:e,onListItemClick:t}){return(0,o.jsx)("ul",{"data-testid":"theme-selector",children:[...s.themes,void 0].map((r=>{const n=(0,s.getThemeName)(r);return(0,o.jsxs)("li",{"data-testid":`theme-item-${n.toLowerCase()}`,onClick:()=>{t(r)},children:[(0,o.jsx)(a.default,{theme:r}),(0,o.jsx)("span",{children:n}),e===r&&(0,o.jsx)(i.default,{})]},n)}))})}},625:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0});const n=r(848);t.default=function(){return(0,n.jsx)("svg",{viewBox:"0 0 640 540",xmlns:"http://www.w3.org/2000/svg",className:"checked",children:(0,n.jsx)("path",{fill:"currentColor",d:"\n\t\t\t\t\tM 12,370\n\t\t\t\t\t\ta 40,40,0,0,1,56.56,-56.56\n\t\t\t\t\t\tl 130,130\n\t\t\t\t\t\tl 370,-430\n\t\t\t\t\t\ta 40,40,0,0,1,56.56,56.56\n\t\t\t\t\t\tl -398.28,458.28\n\t\t\t\t\t\ta 40,40,0,0,1,-56.56,0\n\t\t\t\t\t\tl -140,-140\n\t\t\t\t\tZ"})})}},77:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0});const n=r(848);t.default=function(){return(0,n.jsxs)("svg",{viewBox:"0 0 100 100",xmlns:"http://www.w3.org/2000/svg",className:"dark",strokeWidth:"8",strokeLinecap:"round",fill:"none",children:[(0,n.jsx)("circle",{cx:"50",cy:"50",r:"46",strokeDasharray:"180",transform:"rotate(22.5 50 50)"}),(0,n.jsx)("circle",{cx:"75",cy:"25",r:"46",strokeDasharray:"108 200",transform:"rotate(67.5 75 25)"})]})}},277:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0});const n=r(848);t.default=function(){return(0,n.jsxs)("svg",{viewBox:"0 0 100 100",xmlns:"http://www.w3.org/2000/svg",className:"light",strokeWidth:"8",strokeLinecap:"round",fill:"none",children:[(0,n.jsx)("circle",{cx:"50",cy:"50",r:"20"}),(0,n.jsx)("path",{d:"M 50 86 v 10",transform:"rotate(0 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 10",transform:"rotate(90 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 10",transform:"rotate(180 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 10",transform:"rotate(270 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 15",transform:"rotate(45 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 15",transform:"rotate(135 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 15",transform:"rotate(225 50 50)"}),(0,n.jsx)("path",{d:"M 50 86 v 15",transform:"rotate(315 50 50)"})]})}},950:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0});const n=r(848);t.default=function(){return(0,n.jsxs)("svg",{viewBox:"0 0 100 100",xmlns:"http://www.w3.org/2000/svg",className:"system",strokeWidth:"8",strokeLinecap:"round",fill:"none",children:[(0,n.jsx)("circle",{cx:"50",cy:"50",r:"46"}),(0,n.jsx)("path",{strokeWidth:"0",fill:"currentColor",d:"\n\t\t\t\t\tM 50,0\n\t\t\t\t\t\ta 50,50,0,1,1,0,100\n\t\t\t\t\tZ"})]})}},443:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeSwitcher=t.ThemeSwitcherElement=void 0;const o=r(848),s=r(883),a=r(338),i=n(r(759));t.ThemeSwitcher=i.default,t.ThemeSwitcherElement=class{props;constructor(e){this.props=e}render(e){(0,a.createRoot)(e).render((0,o.jsx)(s.StrictMode,{children:(0,o.jsx)(i.default,{...this.props})}))}}},875:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EventEmitter=void 0,t.EventEmitter=class{listeners={};on(e,t){(this.listeners[e]??=[]).push(t)}off(e,t){const r=this.listeners[e]??=[];r.splice(r.indexOf(t),1)}emit(e,...t){this.listeners[e]?.forEach((e=>{e(...t)}))}}},48:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getThemeName=t.isTheme=t.defaultTheme=t.themes=void 0;const r=["light","dark"];t.themes=r,t.defaultTheme="light",t.isTheme=function(e){return"string"==typeof e&&r.includes(e)},t.getThemeName=function(e){switch(e){case"light":return"Light";case"dark":return"Dark";default:return"System"}}},6:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SystemProvider=void 0;const n=r(875),o=r(48);class s extends n.EventEmitter{get(){if(!("matchMedia"in window))return o.defaultTheme;for(const e of o.themes)if(window.matchMedia(`(prefers-color-scheme: ${e})`).matches)return e;return o.defaultTheme}watch(){if("matchMedia"in window)for(const e of o.themes)window.matchMedia(`(prefers-color-scheme: ${e})`).addEventListener("change",(t=>{t.matches&&this.emit("change",e)}))}}t.SystemProvider=s},12:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UserProvider=void 0;const n=r(875),o=r(48);class s extends n.EventEmitter{storageKey="theme";get(){const e=localStorage.getItem(this.storageKey);return(0,o.isTheme)(e)?e:void 0}set(e){e?localStorage.setItem(this.storageKey,e):localStorage.removeItem(this.storageKey),this.emit("change",e)}}t.UserProvider=s},883:t=>{t.exports=e},845:e=>{e.exports=t}},n={};function o(e){var t=n[e];if(void 0!==t)return t.exports;var s=n[e]={id:e,exports:{}};return r[e].call(s.exports,s,s.exports,o),s.exports}return o.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return o.d(t,{a:t}),t},o.d=(e,t)=>{for(var r in t)o.o(t,r)&&!o.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),o.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.nc=void 0,o(443)})()));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @license React
3
+ * react-jsx-runtime.production.min.js
4
+ *
5
+ * Copyright (c) Facebook, Inc. and its affiliates.
6
+ *
7
+ * This source code is licensed under the MIT license found in the
8
+ * LICENSE file in the root directory of this source tree.
9
+ */
package/jest.config.js ADDED
@@ -0,0 +1,26 @@
1
+ module.exports = {
2
+ preset : 'ts-jest',
3
+ transform : {
4
+ '^.+\\.tsx?$' : [
5
+ 'ts-jest',
6
+ {
7
+ tsconfig : '<rootDir>/tsconfig.test.json',
8
+ },
9
+ ],
10
+ },
11
+
12
+ clearMocks : true,
13
+ testEnvironment : 'jsdom',
14
+
15
+ roots : [ '<rootDir>/src' ],
16
+ testMatch : [ '<rootDir>/src/**/__tests__/*.test.{ts,tsx}' ],
17
+
18
+ collectCoverageFrom : [
19
+ '<rootDir>/src/**/*.{ts,tsx}',
20
+ '!<rootDir>/src/**/__tests__/**',
21
+ ],
22
+
23
+ moduleNameMapper : {
24
+ '\\.(css|less)$' : '<rootDir>/src/__mocks__/css.ts',
25
+ },
26
+ };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@anmiles/theme-switcher",
3
+ "version": "1.0.0",
4
+ "description": "Theme switcher for websites",
5
+ "keywords": [
6
+ "theme",
7
+ "switcher",
8
+ "websites"
9
+ ],
10
+ "author": "Anatoliy Oblaukhov",
11
+ "homepage": "https://github.com/anmiles/theme-switcher",
12
+ "repository": "github:anmiles/theme-switcher",
13
+ "license": "MIT",
14
+ "engines": {
15
+ "node": ">=18.18.0"
16
+ },
17
+ "main": "src/index.tsx",
18
+ "scripts": {
19
+ "build": "rimraf dist && tsc -p ./tsconfig.build.json --noEmit && webpack --env mode=development && webpack --env mode=production",
20
+ "lint": "eslint .",
21
+ "lint:fix": "npm run lint -- --fix",
22
+ "test": "jest --verbose",
23
+ "test:coverage": "npm test -- --coverage",
24
+ "test:ci": "npm test -- --ci --coverage",
25
+ "test:watch": "npm test -- --watch",
26
+ "test:watch:coverage": "npm test -- --watch --coverage",
27
+ "test:report:coverage": "nyc report --nycrc-path ./coverage.config.js -t ./coverage --report-dir ./coverage",
28
+ "dev": "webpack serve --env mode=development --hot --open"
29
+ },
30
+ "dependencies": {
31
+ "@anmiles/logger": "^7.0.2",
32
+ "react": "^18.3.1",
33
+ "react-dom": "^18.3.1"
34
+ },
35
+ "devDependencies": {
36
+ "@anmiles/eslint-config": "^7.1.1",
37
+ "@anmiles/tsconfig": "^3.0.1",
38
+ "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
39
+ "@stylistic/eslint-plugin": "^1.7.0",
40
+ "@testing-library/jest-dom": "^6.6.3",
41
+ "@testing-library/react": "^16.0.1",
42
+ "@types/jest": "^29.5.12",
43
+ "@types/react": "^18.3.12",
44
+ "@types/react-dom": "^18.3.1",
45
+ "@typescript-eslint/eslint-plugin": "^7.3.1",
46
+ "@typescript-eslint/parser": "^7.3.1",
47
+ "css-loader": "^7.1.2",
48
+ "eslint": "^8.57.0",
49
+ "eslint-import-resolver-typescript": "^3.6.1",
50
+ "eslint-plugin-align-assignments": "^1.1.2",
51
+ "eslint-plugin-import": "^2.29.1",
52
+ "eslint-plugin-jest": "^27.9.0",
53
+ "eslint-plugin-jsonc": "^2.14.1",
54
+ "eslint-plugin-n": "^16.6.2",
55
+ "eslint-plugin-promise": "^6.1.1",
56
+ "jest": "^29.7.0",
57
+ "jest-environment-jsdom": "^29.7.0",
58
+ "nyc": "^15.1.0",
59
+ "react-refresh": "^0.14.2",
60
+ "react-refresh-typescript": "^2.0.9",
61
+ "rimraf": "^5.0.5",
62
+ "style-loader": "^4.0.0",
63
+ "ts-jest": "^29.1.2",
64
+ "ts-loader": "^9.5.1",
65
+ "typescript": "^5.4.2",
66
+ "webpack": "^5.96.1",
67
+ "webpack-cli": "^5.1.4",
68
+ "webpack-dev-server": "^5.1.0",
69
+ "webpack-merge": "^6.0.1"
70
+ }
71
+ }
@@ -0,0 +1 @@
1
+ module.exports = {};
@@ -0,0 +1,25 @@
1
+ import '@testing-library/jest-dom';
2
+ import { act } from '@testing-library/react';
3
+ import type App from '../components/App';
4
+ import { ThemeSwitcherElement } from '../index';
5
+
6
+ const testId = 'app-test-id';
7
+
8
+ jest.mock<typeof App>('../components/App', () => function App() {
9
+ return <div>{ testId }</div>;
10
+ });
11
+
12
+ describe('src/index', () => {
13
+ describe('ThemeSwitcher', () => {
14
+ it('should render App and pass float prop', () => {
15
+ const parentNode = document.createElement('div');
16
+ document.body.appendChild(parentNode);
17
+
18
+ act(() => {
19
+ new ThemeSwitcherElement({ float : 'left' }).render(parentNode);
20
+ });
21
+
22
+ expect(parentNode.innerHTML).toEqual(`<div>${testId}</div>`);
23
+ });
24
+ });
25
+ });
@@ -0,0 +1,23 @@
1
+ import { isTheme, getThemeName } from '../lib/theme';
2
+
3
+ describe('src/lib/theme', () => {
4
+ describe('isTheme', () => {
5
+ it('should return true if argument is theme', () => {
6
+ expect(isTheme('light')).toBe(true);
7
+ expect(isTheme('dark')).toBe(true);
8
+ });
9
+
10
+ it('should return false if argument is not a theme', () => {
11
+ expect(isTheme('unknown')).toBe(false);
12
+ expect(isTheme(undefined)).toBe(false);
13
+ });
14
+ });
15
+
16
+ describe('getThemeName', () => {
17
+ it('should match theme names', () => {
18
+ expect(getThemeName('light')).toEqual('Light');
19
+ expect(getThemeName('dark')).toEqual('Dark');
20
+ expect(getThemeName(undefined)).toEqual('System');
21
+ });
22
+ });
23
+ });
@@ -0,0 +1,55 @@
1
+ import type { CSSProperties } from 'react';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+
4
+ import { UserProvider } from '../providers/userProvider';
5
+ import { SystemProvider } from '../providers/systemProvider';
6
+ import '../styles/style.css';
7
+ import Icon from './Icon';
8
+ import ThemeSelector from './ThemeSelector';
9
+
10
+ export interface AppProps {
11
+ readonly float? : CSSProperties['float'];
12
+ }
13
+
14
+ export default function App({ float }: AppProps) {
15
+ const userProvider = useMemo(() => new UserProvider(), []);
16
+ const systemProvider = useMemo(() => new SystemProvider(), []);
17
+
18
+ const [ userTheme, setUserTheme ] = useState(userProvider.get());
19
+ const [ systemTheme, setSystemTheme ] = useState(systemProvider.get());
20
+ const [ showList, setShowList ] = useState(false);
21
+
22
+ const theme = userTheme ?? systemTheme;
23
+
24
+ useEffect(() => {
25
+ document.body.setAttribute('data-theme', theme);
26
+ userProvider.on('change', setUserTheme);
27
+ systemProvider.on('change', setSystemTheme);
28
+ systemProvider.watch();
29
+ }, [ theme, userProvider, systemProvider ]);
30
+
31
+ return (
32
+ <div
33
+ className="themeSwitcher"
34
+ data-testid="theme-switcher"
35
+ data-float={ float }
36
+ onClick={ () => {
37
+ setShowList(!showList);
38
+ } }
39
+ >
40
+ <Icon theme={ theme } />
41
+
42
+ { !showList
43
+ ? null
44
+ : (
45
+ <ThemeSelector
46
+ currentUserTheme={ userTheme }
47
+ onListItemClick={ (theme) => {
48
+ userProvider.set(theme);
49
+ setShowList(false);
50
+ } }
51
+ />
52
+ ) }
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,19 @@
1
+ import type { Theme } from '../lib/theme';
2
+ import Dark from './icons/Dark';
3
+ import Light from './icons/Light';
4
+ import System from './icons/System';
5
+
6
+ interface IconProps {
7
+ readonly theme : Theme | undefined;
8
+ }
9
+
10
+ export default function Icon({ theme }: IconProps) {
11
+ switch (theme) {
12
+ case 'light':
13
+ return <Light />;
14
+ case 'dark':
15
+ return <Dark />;
16
+ default:
17
+ return <System />;
18
+ }
19
+ }
@@ -0,0 +1,34 @@
1
+ import type { Theme } from '../lib/theme';
2
+ import { getThemeName, themes } from '../lib/theme';
3
+ import Icon from './Icon';
4
+ import Checked from './icons/Checked';
5
+
6
+ interface ThemeSelectorProps {
7
+ readonly currentUserTheme : Theme | undefined;
8
+ readonly onListItemClick : (theme: Theme | undefined) => void;
9
+ }
10
+
11
+ export default function ThemeSelector({ currentUserTheme, onListItemClick }: ThemeSelectorProps) {
12
+
13
+ return (
14
+ <ul data-testid="theme-selector">
15
+ { [ ...themes, undefined ].map((theme) => {
16
+ const themeName = getThemeName(theme);
17
+
18
+ return (
19
+ <li
20
+ key={ themeName }
21
+ data-testid={ `theme-item-${themeName.toLowerCase()}` }
22
+ onClick={ () => {
23
+ onListItemClick(theme);
24
+ } }
25
+ >
26
+ <Icon theme={ theme } />
27
+ <span>{ themeName }</span>
28
+ { currentUserTheme === theme && <Checked /> }
29
+ </li>
30
+ );
31
+ }) }
32
+ </ul>
33
+ );
34
+ }
@@ -0,0 +1,350 @@
1
+ import { render, screen, fireEvent, act } from '@testing-library/react';
2
+ import type { UserProvider } from '../../providers/userProvider';
3
+ import type { SystemProvider } from '../../providers/systemProvider';
4
+ import type { AppProps } from '../App';
5
+ import App from '../App';
6
+ import { EventEmitter } from '../../lib/eventEmitter';
7
+ import type { Theme } from '../../lib/theme';
8
+ import '@testing-library/jest-dom';
9
+
10
+ jest.mock('../icons/Checked.tsx', () => function Checked() {
11
+ return '{checked}';
12
+ });
13
+
14
+ jest.mock('../icons/Dark.tsx', () => function Dark() {
15
+ return '{dark}';
16
+ });
17
+
18
+ jest.mock('../icons/Light.tsx', () => function Light() {
19
+ return '{light}';
20
+ });
21
+
22
+ jest.mock('../icons/System.tsx', () => function System() {
23
+ return '{system}';
24
+ });
25
+
26
+ let userTheme: Theme | undefined;
27
+ let systemTheme: Theme;
28
+
29
+ const userEventEmitter = new EventEmitter<{ change : [Theme | undefined] }>();
30
+ const systemEventEmitter = new EventEmitter<{ change : [Theme | undefined] }>();
31
+
32
+ const userProvider: Partial<InstanceType<typeof UserProvider>> = {
33
+ get : jest.fn().mockImplementation(() => userTheme),
34
+
35
+ set : jest.fn().mockImplementation((theme: Theme | undefined) => {
36
+ userEventEmitter['emit']('change', theme);
37
+ }),
38
+
39
+ on : jest.fn().mockImplementation(userEventEmitter.on.bind(userEventEmitter)),
40
+ off : jest.fn().mockImplementation(userEventEmitter.off.bind(userEventEmitter)),
41
+ };
42
+
43
+ const systemProvider: Partial<InstanceType<typeof SystemProvider>> = {
44
+ get : jest.fn().mockImplementation(() => systemTheme),
45
+ watch : jest.fn(),
46
+ on : jest.fn().mockImplementation(systemEventEmitter.on.bind(systemEventEmitter)),
47
+ off : jest.fn().mockImplementation(systemEventEmitter.on.bind(systemEventEmitter)),
48
+ };
49
+
50
+ jest.mock<{ UserProvider : typeof UserProvider }>('../../providers/userProvider', () => ({
51
+ UserProvider : jest.fn().mockImplementation(() => userProvider),
52
+ }));
53
+
54
+ jest.mock<{ SystemProvider : typeof SystemProvider }>('../../providers/systemProvider', () => ({
55
+ SystemProvider : jest.fn().mockImplementation(() => systemProvider),
56
+ }));
57
+
58
+ function renderApp({ float }: AppProps) {
59
+ render(
60
+ <App float={ float } />,
61
+ );
62
+ }
63
+
64
+ afterEach(() => {
65
+ document.body.removeAttribute('data-theme');
66
+ });
67
+
68
+ describe('src/App', () => {
69
+ describe('theme', () => {
70
+ function clickDropdownItem(theme: Theme | 'system'): void {
71
+ renderApp({});
72
+
73
+ const themeSwitcher = screen.getByTestId('theme-switcher');
74
+
75
+ act(() => {
76
+ fireEvent.click(themeSwitcher);
77
+ });
78
+
79
+ const dropdownItemTestId = `theme-item-${theme}`;
80
+ const dropdownItem = screen.queryByTestId(dropdownItemTestId);
81
+
82
+ if (!dropdownItem) {
83
+ throw new Error(`Dropdown does not have an item with test id ${dropdownItemTestId}`);
84
+ }
85
+
86
+ act(() => {
87
+ fireEvent.click(dropdownItem);
88
+ });
89
+ }
90
+
91
+ it('should be forced to user theme (light)', () => {
92
+ systemTheme = 'dark';
93
+ userTheme = 'light';
94
+
95
+ renderApp({});
96
+
97
+ expect(document.body.getAttribute('data-theme')).toBe('light');
98
+ });
99
+
100
+ it('should be forced to user theme (dark)', () => {
101
+ systemTheme = 'light';
102
+ userTheme = 'dark';
103
+
104
+ renderApp({});
105
+
106
+ expect(document.body.getAttribute('data-theme')).toBe('dark');
107
+ });
108
+
109
+ it('should fallback to system theme(light) if user theme is not set', () => {
110
+ userTheme = undefined;
111
+ systemTheme = 'light';
112
+
113
+ renderApp({});
114
+
115
+ expect(document.body.getAttribute('data-theme')).toBe('light');
116
+ });
117
+
118
+ it('should fallback to system theme(dark) if user theme is not set', () => {
119
+ userTheme = undefined;
120
+ systemTheme = 'dark';
121
+
122
+ renderApp({});
123
+
124
+ expect(document.body.getAttribute('data-theme')).toBe('dark');
125
+ });
126
+
127
+ it('should be changed on change user provider', () => {
128
+ userTheme = 'light';
129
+
130
+ renderApp({});
131
+
132
+ expect(document.body.getAttribute('data-theme')).toBe('light');
133
+
134
+ act(() => {
135
+ userEventEmitter['emit']('change', 'dark');
136
+ });
137
+
138
+ expect(document.body.getAttribute('data-theme')).toBe('dark');
139
+ });
140
+
141
+ it('should be changed on change system provider if user theme is not defined', () => {
142
+ userTheme = undefined;
143
+ systemTheme = 'light';
144
+
145
+ renderApp({});
146
+
147
+ expect(document.body.getAttribute('data-theme')).toBe('light');
148
+
149
+ act(() => {
150
+ systemEventEmitter['emit']('change', 'dark');
151
+ });
152
+
153
+ expect(document.body.getAttribute('data-theme')).toBe('dark');
154
+ });
155
+
156
+ it('should change to dark if dark item chosen', () => {
157
+ systemTheme = 'light';
158
+ userTheme = 'light';
159
+
160
+ clickDropdownItem('dark');
161
+
162
+ expect(document.body.getAttribute('data-theme')).toBe('dark');
163
+ });
164
+
165
+ it('should change to light if light item chosen', () => {
166
+ systemTheme = 'dark';
167
+ userTheme = 'dark';
168
+
169
+ clickDropdownItem('light');
170
+
171
+ expect(document.body.getAttribute('data-theme')).toBe('light');
172
+ });
173
+
174
+ it('should change to system dark if system item chosen', () => {
175
+ systemTheme = 'dark';
176
+ userTheme = 'light';
177
+
178
+ clickDropdownItem('system');
179
+
180
+ expect(document.body.getAttribute('data-theme')).toBe('dark');
181
+ });
182
+
183
+ it('should change to system light if system item chosen', () => {
184
+ systemTheme = 'light';
185
+ userTheme = 'dark';
186
+
187
+ clickDropdownItem('system');
188
+
189
+ expect(document.body.getAttribute('data-theme')).toBe('light');
190
+ });
191
+ });
192
+
193
+ describe('icon', () => {
194
+ it('should match light system theme if user theme is not defined', () => {
195
+ userTheme = undefined;
196
+ systemTheme = 'light';
197
+
198
+ renderApp({});
199
+
200
+ const themeSwitcher = screen.getByTestId('theme-switcher');
201
+ expect(themeSwitcher.innerHTML).toEqual('{light}');
202
+ });
203
+
204
+ it('should match dark system theme if user theme is not defined', () => {
205
+ userTheme = undefined;
206
+ systemTheme = 'dark';
207
+
208
+ renderApp({});
209
+
210
+ const themeSwitcher = screen.getByTestId('theme-switcher');
211
+ expect(themeSwitcher.innerHTML).toEqual('{dark}');
212
+ });
213
+
214
+ it('should match light user theme despite of system theme', () => {
215
+ userTheme = 'light';
216
+ systemTheme = 'dark';
217
+
218
+ renderApp({});
219
+
220
+ const themeSwitcher = screen.getByTestId('theme-switcher');
221
+ expect(themeSwitcher.innerHTML).toEqual('{light}');
222
+ });
223
+
224
+ it('should match dark user theme despite of system theme', () => {
225
+ userTheme = 'dark';
226
+ systemTheme = 'light';
227
+
228
+ renderApp({});
229
+
230
+ const themeSwitcher = screen.getByTestId('theme-switcher');
231
+ expect(themeSwitcher.innerHTML).toEqual('{dark}');
232
+ });
233
+ });
234
+
235
+ describe('switcher', () => {
236
+ describe('html', () => {
237
+
238
+ function actOnThemeSwitcher(): HTMLElement {
239
+ renderApp({});
240
+
241
+ const themeSwitcher = screen.getByTestId('theme-switcher');
242
+
243
+ act(() => {
244
+ fireEvent.click(themeSwitcher);
245
+ });
246
+
247
+ return themeSwitcher;
248
+ }
249
+
250
+ it('should match snapshot on light system theme', () => {
251
+ userTheme = undefined;
252
+ systemTheme = 'light';
253
+
254
+ expect(actOnThemeSwitcher()).toMatchSnapshot();
255
+ });
256
+
257
+ it('should match snapshot on dark system theme', () => {
258
+ userTheme = undefined;
259
+ systemTheme = 'light';
260
+
261
+ expect(actOnThemeSwitcher()).toMatchSnapshot();
262
+ });
263
+
264
+ it('should match snapshot on light user theme', () => {
265
+ userTheme = 'light';
266
+ systemTheme = 'dark';
267
+
268
+ expect(actOnThemeSwitcher()).toMatchSnapshot();
269
+ });
270
+
271
+ it('should match snapshot on dark user theme', () => {
272
+ userTheme = 'dark';
273
+ systemTheme = 'light';
274
+
275
+ expect(actOnThemeSwitcher()).toMatchSnapshot();
276
+ });
277
+ });
278
+
279
+ describe('data-float', () => {
280
+ it('should be set left from float prop', () => {
281
+ renderApp({ float : 'left' });
282
+
283
+ const themeSwitcher = screen.getByTestId('theme-switcher');
284
+ expect(themeSwitcher.getAttribute('data-float')).toEqual('left');
285
+ });
286
+
287
+ it('should be set right from float prop', () => {
288
+ renderApp({ float : 'right' });
289
+
290
+ const themeSwitcher = screen.getByTestId('theme-switcher');
291
+ expect(themeSwitcher.getAttribute('data-float')).toEqual('right');
292
+ });
293
+
294
+ it('should be empty if float prop is not set', () => {
295
+ renderApp({});
296
+
297
+ const themeSwitcher = screen.getByTestId('theme-switcher');
298
+ expect(themeSwitcher.getAttribute('data-float')).toEqual(null);
299
+ });
300
+ });
301
+
302
+ describe('interaction', () => {
303
+ it('should toggle on themeSwitcher click', () => {
304
+ systemTheme = 'light';
305
+ userTheme = 'dark';
306
+
307
+ renderApp({});
308
+
309
+ const themeSwitcher = screen.getByTestId('theme-switcher');
310
+
311
+ act(() => {
312
+ fireEvent.click(themeSwitcher);
313
+ });
314
+
315
+ expect(screen.queryByTestId('theme-selector')).toBeInTheDocument();
316
+
317
+ act(() => {
318
+ fireEvent.click(themeSwitcher);
319
+ });
320
+
321
+ expect(screen.queryByTestId('theme-selector')).not.toBeInTheDocument();
322
+ });
323
+
324
+ it('should hide on dropdown item click', () => {
325
+ systemTheme = 'light';
326
+ userTheme = 'dark';
327
+
328
+ renderApp({});
329
+
330
+ const themeSwitcher = screen.getByTestId('theme-switcher');
331
+
332
+ act(() => {
333
+ fireEvent.click(themeSwitcher);
334
+ });
335
+
336
+ const dropdownItem = themeSwitcher.querySelector('li');
337
+
338
+ if (!dropdownItem) {
339
+ throw new Error('Dropdown does not have items');
340
+ }
341
+
342
+ act(() => {
343
+ fireEvent.click(dropdownItem);
344
+ });
345
+
346
+ expect(screen.queryByTestId('theme-selector')).not.toBeInTheDocument();
347
+ });
348
+ });
349
+ });
350
+ });