PageSourceSearch

https://railsblocks.com/assets/controllers/theme_controller-92f859…ec06b5c71d50f5bcdcc3ba4431ba0f8cbaedeccf.js

js railsblocks.com collected 2026-09-25 20:24:29 UTC 5,240 bytes, 191 lines download raw bytes

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.