1import { Controller } from "@hotwired/stimulus"; 2 3const STORAGE_KEY = "theme"; 4const SYSTEM_MODE = "system"; 5const AVAILABLE_MODES = [SYSTEM_MODE, "light", "dark"]; 6const MODE_LABELS = { system: "System", light: "Light", dark: "Dark" }; 7 8export default class extends Controller { 9 static classes = ["selected", "unselected"]; 10 static targets = ["option", "picker", "cycleButton", "cycleIcon", "cycleLabel"]; 11 12 initialize() { 13 this.colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 14 this.systemPreferenceChanged = this.systemPreferenceChanged.bind(this); 15 this.themeChanged = this.themeChanged.bind(this); 16 this.storageChanged = this.storageChanged.bind(this); 17 18 // The layout's inline script handles the first paint. This keeps the DOM 19 // correct as soon as Stimulus initializes on subsequent renders. 20 this.refresh(); 21 } 22 23 connect() { 24 this.refresh(); 25 if (this.hasPickerTarget) this.pickerTarget.classList.remove("opacity-0"); 26 27 this.colorSchemeQuery.addEventListener("change", this.systemPreferenceChanged); 28 window.addEventListener("theme:changed", this.themeChanged); 29 window.addEventListener("storage", this.storageChanged); 30 } 31 32 disconnect() { 33 this.colorSchemeQuery.removeEventListener("change", this.systemPreferenceChanged); 34 window.removeEventListener("theme:changed", this.themeChanged); 35 window.removeEventListener("storage", this.storageChanged); 36 } 37 38 choose(event) { 39 this.commit(event.currentTarget.dataset.themeMode); 40 } 41 42 cycle() { 43 const currentIndex = AVAILABLE_MODES.indexOf(this.mode); 44 const nextMode = AVAILABLE_MODES[(currentIndex + 1) % AVAILABLE_MODES.length]; 45 46 this.commit(nextMode); 47 } 48 49 navigate(event) { 50 const currentIndex = this.optionTargets.indexOf(event.currentTarget); 51 if (currentIndex === -1) return; 52 53 const nextIndex = this.destinationIndex(event.key, currentIndex); 54 if (nextIndex === null) return; 55 56 event.preventDefault(); 57 58 const nextOption = this.optionTargets[nextIndex]; 59 nextOption.focus(); 60 this.commit(nextOption.dataset.themeMode); 61 } 62 63 commit(mode) { 64 if (!AVAILABLE_MODES.includes(mode)) return; 65 66 this.store(mode); 67 this.refresh(); 68 this.announce(); 69 } 70 71 refresh() { 72 const mode = this.mode; 73 const palette = this.resolve(mode); 74 75 this.paintDocument(palette); 76 this.paintOptions(mode); 77 this.paintCycle(mode); 78 } 79 80 systemPreferenceChanged() { 81 if (this.mode !== SYSTEM_MODE) return; 82 83 this.refresh(); 84 this.announce(); 85 } 86 87 themeChanged() { 88 this.refresh(); 89 } 90 91 storageChanged(event) { 92 if (event.storageArea !== localStorage) return; 93 if (event.key !== STORAGE_KEY && event.key !== null) return; 94 95 this.refresh(); 96 this.announce(); 97 } 98 99 destinationIndex(key, currentIndex) { 100 switch (key) { 101 case "ArrowLeft": 102 case "ArrowUp": 103 return (currentIndex - 1 + this.optionTargets.length) % this.optionTargets.length; 104 case "ArrowRight": 105 case "ArrowDown": 106 return (currentIndex + 1) % this.optionTargets.length; 107 case "Home": 108 return 0; 109 case "End": 110 return this.optionTargets.length - 1; 111 default: 112 return null; 113 } 114 } 115 116 store(mode) { 117 if (mode === SYSTEM_MODE) { 118 localStorage.removeItem(STORAGE_KEY); 119 } else { 120 localStorage.setItem(STORAGE_KEY, mode); 121 } 122 } 123 124 resolve(mode) { 125 if (mode !== SYSTEM_MODE) return mode; 126 127 return this.colorSchemeQuery.matches ? "dark" : "light"; 128 } 129 130 paintDocument(palette) { 131 const dark = palette === "dark"; 132 const root = document.documentElement; 133 134 root.classList.toggle("dark", dark); 135 root.classList.toggle("sl-theme-dark", dark); 136 root.style.colorScheme = palette; 137 root.dataset.theme = palette; 138 } 139 140 paintOptions(mode) { 141 this.optionTargets.forEach((option) => { 142 const selected = option.dataset.themeMode === mode; 143 144 option.setAttribute("aria-checked", selected.toString()); 145 option.tabIndex = selected ? 0 : -1; 146 147 if (selected) { 148 option.classList.add(...this.selectedClasses); 149 option.classList.remove(...this.unselectedClasses); 150 } else { 151 option.classList.remove(...this.selectedClasses); 152 option.classList.add(...this.unselectedClasses); 153 } 154 }); 155 } 156 157 paintCycle(mode) {
158 this.cycleIconTargets.forEach((icon) => { 159 icon.toggleAttribute("hidden", icon.dataset.themeMode !== mode); 160 }); 161 162 if (this.hasCycleLabelTarget) this.cycleLabelTarget.textContent = MODE_LABELS[mode]; 163 if (!this.hasCycleButtonTarget) return; 164 165 const nextIndex = (AVAILABLE_MODES.indexOf(mode) + 1) % AVAILABLE_MODES.length; 166 const nextMode = AVAILABLE_MODES[nextIndex]; 167 168 this.cycleButtonTarget.setAttribute( 169 "aria-label", 170 `${MODE_LABELS[mode]} theme. Switch to ${MODE_LABELS[nextMode].toLowerCase()} theme`, 171 ); 172 } 173 174 announce() { 175 const mode = this.mode; 176 177 this.dispatch("changed", { 178 target: window, 179 detail: { 180 mode, 181 palette: this.resolve(mode), 182 }, 183 }); 184 } 185 186 get mode() { 187 const storedMode = localStorage.getItem(STORAGE_KEY); 188 189 return AVAILABLE_MODES.includes(storedMode) ? storedMode : SYSTEM_MODE; 190 } 191};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.