1- <theme-toggle class =" ms-2 sm:ms-4" >
2- <button class =" relative h-9 w-9 rounded-md p-2 ring-zinc-400 transition-all hover:ring-2" type =" button" >
3- <span class =" sr-only" >Dark Theme</span >
4- <svg aria-hidden =" true" class =" absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0" fill =" none" focusable =" false" id =" sun-svg" stroke-width =" 1.5" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
5- <path d =" M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
6- <path d =" M22 12L23 12" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
7- <path d =" M12 2V1" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
8- <path d =" M12 23V22" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
9- <path d =" M20 20L19 19" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
10- <path d =" M20 4L19 5" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
11- <path d =" M4 20L5 19" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
12- <path d =" M4 4L5 5" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
13- <path d =" M1 12L2 12" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
14- </svg >
15- <svg aria-hidden =" true" class =" absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100" fill =" none" focusable =" false" id =" moon-svg" stroke =" currentColor" stroke-width =" 1.5" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
16- <path d =" M0 0h24v24H0z" fill =" none" stroke =" none" ></path >
17- <path d =" M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" ></path >
18- <path d =" M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" ></path >
19- <path d =" M19 11h2m-1 -1v2" ></path >
20- </svg >
21- </button >
22- </theme-toggle>
23-
24- <script >
25- // Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute
26- import { rootInDarkMode } from '@utils';
27-
28- class ThemeToggle extends HTMLElement {
29- #controller: AbortController | undefined;
30-
31- connectedCallback() {
32- const button = this.querySelector('button')!;
33- // set aria role value
34- button.setAttribute('role', 'switch');
35- button.setAttribute('aria-checked', String(rootInDarkMode()));
36- // Abort signal
37- const { signal } = (this.#controller = new AbortController());
38-
39- // button event
40- button.addEventListener(
41- 'click',
42- () => {
43- // invert theme
44- let themeChangeEvent = new CustomEvent('theme-change', {
45- detail: {
46- theme: rootInDarkMode() ? 'light' : 'dark',
47- },
48- });
49- // dispatch event -> ThemeProvider.astro
50- document.dispatchEvent(themeChangeEvent);
51-
52- // set the aria-checked attribute
53- button.setAttribute('aria-checked', String(rootInDarkMode()));
54- },
55- { signal },
56- );
57- }
58-
59- disconnectedCallback() {
60- this.#controller?.abort();
61- }
62- }
63-
64- customElements.define('theme-toggle', ThemeToggle);
65- </script >
1+ <theme-toggle class =" ms-2 sm:ms-4" >
2+ <div class =" relative" >
3+ <button class =" relative h-9 w-9 rounded-md p-2 ring-zinc-400 transition-all hover:ring-2" type =" button" id =" theme-button" aria-label =" Toggle theme" aria-haspopup =" true" >
4+ <svg aria-hidden =" true" class =" absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0" fill =" none" focusable =" false" id =" sun-svg" stroke-width =" 1.5" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
5+ <path d =" M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
6+ <path d =" M22 12L23 12" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
7+ <path d =" M12 2V1" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
8+ <path d =" M12 23V22" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
9+ <path d =" M20 20L19 19" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
10+ <path d =" M20 4L19 5" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
11+ <path d =" M4 20L5 19" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
12+ <path d =" M4 4L5 5" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
13+ <path d =" M1 12L2 12" stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" ></path >
14+ </svg >
15+ <svg aria-hidden =" true" class =" absolute start-1/2 top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100" fill =" none" focusable =" false" id =" moon-svg" stroke =" currentColor" stroke-width =" 1.5" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
16+ <path d =" M0 0h24v24H0z" fill =" none" stroke =" none" ></path >
17+ <path d =" M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" ></path >
18+ <path d =" M17 4a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" ></path >
19+ <path d =" M19 11h2m-1 -1v2" ></path >
20+ </svg >
21+ </button >
22+
23+ <div id =" theme-dropdown" class =" absolute right-0 mt-1 hidden w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 dark:bg-zinc-800" style =" transform-origin: top right;" >
24+ <div class =" px-4 py-2 text-sm text-gray-700 dark:text-gray-200" >Theme settings</div >
25+ <div class =" border-t border-gray-200 dark:border-gray-700" ></div >
26+
27+ <button id =" light-theme-option" class =" w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-zinc-700" >
28+ <span class =" flex items-center" >
29+ <svg class =" mr-2 h-4 w-4" fill =" none" stroke =" currentColor" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
30+ <circle cx =" 12" cy =" 12" r =" 5" stroke-width =" 2" ></circle >
31+ <path d =" M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" stroke-width =" 2" ></path >
32+ </svg >
33+ Light
34+ </span >
35+ </button >
36+
37+ <button id =" dark-theme-option" class =" w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-zinc-700" >
38+ <span class =" flex items-center" >
39+ <svg class =" mr-2 h-4 w-4" fill =" none" stroke =" currentColor" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
40+ <path d =" M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke-width =" 2" ></path >
41+ </svg >
42+ Dark
43+ </span >
44+ </button >
45+
46+ <div class =" border-t border-gray-200 dark:border-gray-700" ></div >
47+
48+ <button id =" system-sync-option" class =" w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-zinc-700" >
49+ <span class =" flex items-center" >
50+ <svg class =" mr-2 h-4 w-4" fill =" none" stroke =" currentColor" viewBox =" 0 0 24 24" xmlns =" http://www.w3.org/2000/svg" >
51+ <path d =" M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke-width =" 2" ></path >
52+ </svg >
53+ <span id =" system-sync-text" >Sync with system</span >
54+ </span >
55+ <span id =" system-sync-indicator" class =" ml-2 inline-block h-4 w-4 rounded-full" ></span >
56+ </button >
57+ </div >
58+ </div >
59+ </theme-toggle>
60+
61+ <script >
62+ // Note that if you fire the theme-change event outside of this component, it will not be reflected in the button's aria-checked attribute
63+ import { rootInDarkMode } from '@utils';
64+
65+ class ThemeToggle extends HTMLElement {
66+ #controller: AbortController | undefined;
67+ #button: HTMLButtonElement;
68+ #dropdown: HTMLElement;
69+ #lightOption: HTMLButtonElement;
70+ #darkOption: HTMLButtonElement;
71+ #systemOption: HTMLButtonElement;
72+ #systemText: HTMLElement;
73+ #systemIndicator: HTMLElement;
74+
75+ connectedCallback() {
76+ this.#button = this.querySelector('#theme-button')!;
77+ this.#dropdown = this.querySelector('#theme-dropdown')!;
78+ this.#lightOption = this.querySelector('#light-theme-option')!;
79+ this.#darkOption = this.querySelector('#dark-theme-option')!;
80+ this.#systemOption = this.querySelector('#system-sync-option')!;
81+ this.#systemText = this.querySelector('#system-sync-text')!;
82+ this.#systemIndicator = this.querySelector('#system-sync-indicator')!;
83+
84+ // Set aria role value
85+ this.#button.setAttribute('role', 'switch');
86+ this.#button.setAttribute('aria-checked', String(rootInDarkMode()));
87+
88+ // Abort signal
89+ const { signal } = (this.#controller = new AbortController());
90+
91+ // Check if theme sync is enabled
92+ this.updateSyncStatus();
93+
94+ // Toggle dropdown
95+ this.#button.addEventListener('click', (e) => {
96+ e.stopPropagation();
97+ const isHidden = this.#dropdown.classList.contains('hidden');
98+
99+ // Always update status before showing dropdown
100+ if (isHidden) {
101+ this.updateSyncStatus();
102+ }
103+
104+ this.#dropdown.classList.toggle('hidden');
105+ }, { signal });
106+
107+ // Handle option clicks
108+ this.#lightOption.addEventListener('click', () => this.setTheme('light', false), { signal });
109+ this.#darkOption.addEventListener('click', () => this.setTheme('dark', false), { signal });
110+ this.#systemOption.addEventListener('click', this.toggleSystemSync.bind(this), { signal });
111+
112+ // Close dropdown when clicking outside
113+ document.addEventListener('click', this.handleOutsideClick.bind(this), { signal });
114+ }
115+
116+ handleOutsideClick(e: Event) {
117+ if (!this.contains(e.target as Node) && !this.#dropdown.classList.contains('hidden')) {
118+ this.#dropdown.classList.add('hidden');
119+ }
120+ }
121+
122+ updateSyncStatus() {
123+ const isSyncing = window.themeUtils?.isSyncingWithOS() || false;
124+ const currentTheme = document.documentElement.getAttribute('data-theme');
125+
126+ // Update sync status
127+ this.#systemText.textContent = `Sync with system ${isSyncing ? '(on)' : '(off)'}`;
128+ this.#systemIndicator.classList.toggle('bg-green-500', isSyncing);
129+ this.#systemIndicator.classList.toggle('bg-gray-300', !isSyncing);
130+
131+ // Update active theme indicators
132+ if (currentTheme === 'light') {
133+ this.#lightOption.classList.add('theme-active-indicator');
134+ this.#darkOption.classList.remove('theme-active-indicator');
135+ } else {
136+ this.#lightOption.classList.remove('theme-active-indicator');
137+ this.#darkOption.classList.add('theme-active-indicator');
138+ }
139+ }
140+
141+ toggleSystemSync() {
142+ if (window.themeUtils) {
143+ window.themeUtils.toggleSyncWithOS();
144+ this.updateSyncStatus();
145+ this.#button.setAttribute('aria-checked', String(rootInDarkMode()));
146+ this.#dropdown.classList.add('hidden');
147+ }
148+ }
149+
150+ setTheme(theme: string, syncWithOS = false) {
151+ let themeChangeEvent = new CustomEvent('theme-change', {
152+ detail: {
153+ theme,
154+ syncWithOS
155+ },
156+ });
157+
158+ // Dispatch event -> ThemeProvider.astro
159+ document.dispatchEvent(themeChangeEvent);
160+
161+ // Set the aria-checked attribute
162+ this.#button.setAttribute('aria-checked', String(rootInDarkMode()));
163+ this.#dropdown.classList.add('hidden');
164+ this.updateSyncStatus();
165+ }
166+
167+ disconnectedCallback() {
168+ this.#controller?.abort();
169+ }
170+ }
171+
172+ customElements.define('theme-toggle', ThemeToggle);
173+
174+ // TypeScript interface for window.themeUtils
175+ interface Window {
176+ themeUtils?: {
177+ getTheme: () => string | null;
178+ setTheme: (theme: string, syncWithOS?: boolean) => void;
179+ isSyncingWithOS: () => boolean;
180+ toggleSyncWithOS: () => boolean;
181+ };
182+ }
183+ </script >
0 commit comments