Skip to content

Commit 4bff1b7

Browse files
committed
jung-li
1 parent eb70c1a commit 4bff1b7

File tree

1 file changed

+126
-43
lines changed

1 file changed

+126
-43
lines changed

src/scripts/main.js

Lines changed: 126 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,103 @@
11
/**
2-
* Global site enhancements: theme persistence and mobile navigation interactions.
3-
* The module is loaded on every page via BaseLayout/HomePage.
2+
* Global site enhancements for Astro static site with View Transitions
3+
*
4+
* Handles:
5+
* - Theme persistence across page navigations
6+
* - Mobile navigation interactions
7+
* - Back-to-top button
8+
*
9+
* Best practices for Astro + GitHub Pages:
10+
* - No external dependencies (pure vanilla JS)
11+
* - Defensive coding for SSG/SSR compatibility
12+
* - Proper cleanup of event listeners for View Transitions
13+
* - FOUC prevention with early theme initialization
414
*/
515

616
const THEME_STORAGE_KEY = 'theme';
17+
const THEME_DARK = 'dark';
18+
const THEME_LIGHT = 'light';
719

20+
/**
21+
* Safely retrieves stored theme preference from localStorage
22+
* @returns {'dark' | 'light'} The stored theme or default 'dark'
23+
*/
824
const getStoredTheme = () => {
25+
if (typeof localStorage === 'undefined') {
26+
return THEME_DARK;
27+
}
28+
929
try {
10-
if (typeof localStorage === 'undefined') {
11-
return 'dark';
12-
}
1330
const stored = localStorage.getItem(THEME_STORAGE_KEY);
14-
return stored === 'light' ? 'light' : 'dark';
31+
return stored === THEME_LIGHT ? THEME_LIGHT : THEME_DARK;
1532
} catch (error) {
16-
console.warn('Failed to access localStorage:', error);
17-
return 'dark';
33+
// localStorage might be blocked in private browsing
34+
return THEME_DARK;
1835
}
1936
};
2037

38+
/**
39+
* Applies theme to document root
40+
* @param {'dark' | 'light'} theme - Theme to apply
41+
*/
2142
const setThemeAttribute = (theme) => {
2243
document.documentElement.setAttribute('data-theme', theme);
2344
};
2445

46+
/**
47+
* Synchronizes theme toggle button icons with current theme
48+
* @param {'dark' | 'light'} theme - Current theme
49+
*/
2550
const syncThemeIcons = (theme) => {
2651
const icon = document.getElementById('theme-icon');
2752
const mobileIcon = document.getElementById('mobile-theme-icon');
28-
const iconText = theme === 'dark' ? '○' : '●';
53+
const iconText = theme === THEME_DARK ? '○' : '●';
2954

3055
if (icon) icon.textContent = iconText;
3156
if (mobileIcon) mobileIcon.textContent = iconText;
3257
};
3358

59+
/**
60+
* Applies theme and optionally persists to localStorage
61+
* @param {'dark' | 'light'} theme - Theme to apply
62+
* @param {boolean} persist - Whether to save to localStorage
63+
*/
3464
const applyTheme = (theme, persist = false) => {
3565
setThemeAttribute(theme);
3666
syncThemeIcons(theme);
37-
if (persist) {
67+
68+
if (persist && typeof localStorage !== 'undefined') {
3869
try {
3970
localStorage.setItem(THEME_STORAGE_KEY, theme);
4071
} catch (error) {
41-
console.warn('Failed to save theme to localStorage:', error);
72+
// Silently fail if localStorage is unavailable
4273
}
4374
}
4475
};
4576

77+
// Critical: Initialize theme before first paint to prevent FOUC
78+
// This runs immediately when the script loads
4679
setThemeAttribute(getStoredTheme());
4780

81+
/**
82+
* Handles theme toggle button clicks
83+
* Toggles between light and dark themes and persists the choice
84+
*/
4885
const handleThemeToggle = () => {
4986
const currentAttr = document.documentElement.getAttribute('data-theme');
50-
const current = currentAttr === 'light' ? 'light' : 'dark';
51-
const next = current === 'dark' ? 'light' : 'dark';
87+
const current = currentAttr === THEME_LIGHT ? THEME_LIGHT : THEME_DARK;
88+
const next = current === THEME_DARK ? THEME_LIGHT : THEME_DARK;
5289
applyTheme(next, true);
5390
};
5491

92+
// Expose for potential programmatic access
5593
window.toggleTheme = handleThemeToggle;
5694

95+
/**
96+
* Sets up theme toggle button event listeners
97+
* @returns {Function} Cleanup function to remove listeners
98+
*/
5799
const setupThemeToggles = () => {
58-
const buttons = Array.from(document.querySelectorAll('.theme-toggle'));
100+
const buttons = document.querySelectorAll('.theme-toggle');
59101

60102
if (buttons.length === 0) {
61103
return () => {};
@@ -72,6 +114,11 @@ const setupThemeToggles = () => {
72114
};
73115
};
74116

117+
/**
118+
* Sets up mobile navigation menu interactions
119+
* Handles menu toggle, outside clicks, and keyboard navigation
120+
* @returns {Function} Cleanup function to remove listeners
121+
*/
75122
const setupMobileNavigation = () => {
76123
const navToggle = document.getElementById('mobile-nav-toggle');
77124
const navMenu = document.getElementById('mobile-nav-menu');
@@ -80,7 +127,7 @@ const setupMobileNavigation = () => {
80127
return () => {};
81128
}
82129

83-
const links = Array.from(navMenu.querySelectorAll('a'));
130+
const links = navMenu.querySelectorAll('a');
84131

85132
const openMenu = () => {
86133
navMenu.classList.add('is-open');
@@ -93,31 +140,35 @@ const setupMobileNavigation = () => {
93140
};
94141

95142
const toggleMenu = () => {
96-
if (navMenu.classList.contains('is-open')) {
97-
closeMenu();
98-
} else {
99-
openMenu();
100-
}
143+
navMenu.classList.contains('is-open') ? closeMenu() : openMenu();
101144
};
102145

103146
const handleDocumentClick = (event) => {
104-
const target = event.target;
105147
if (!navMenu.classList.contains('is-open')) return;
106-
if (target && (navMenu.contains(target) || navToggle.contains(target))) return;
107-
closeMenu();
148+
149+
const target = event.target;
150+
const isInsideMenu = navMenu.contains(target);
151+
const isToggleButton = navToggle.contains(target);
152+
153+
if (!isInsideMenu && !isToggleButton) {
154+
closeMenu();
155+
}
108156
};
109157

110158
const handleDocumentKeydown = (event) => {
111159
if (event.key === 'Escape' && navMenu.classList.contains('is-open')) {
112160
closeMenu();
161+
navToggle.focus(); // Return focus to toggle button for better a11y
113162
}
114163
};
115164

165+
// Event listeners
116166
navToggle.addEventListener('click', toggleMenu);
117167
links.forEach((link) => link.addEventListener('click', closeMenu));
118168
document.addEventListener('click', handleDocumentClick);
119169
document.addEventListener('keydown', handleDocumentKeydown);
120170

171+
// Cleanup function
121172
return () => {
122173
navToggle.removeEventListener('click', toggleMenu);
123174
links.forEach((link) => link.removeEventListener('click', closeMenu));
@@ -126,15 +177,22 @@ const setupMobileNavigation = () => {
126177
};
127178
};
128179

180+
/**
181+
* Sets up back-to-top button interactions
182+
* Shows button after scrolling down 300px
183+
* @returns {Function} Cleanup function to remove listeners
184+
*/
129185
const setupBackToTop = () => {
130186
const backToTopBtn = document.getElementById('back-to-top');
131187

132188
if (!backToTopBtn) {
133189
return () => {};
134190
}
135191

192+
const SCROLL_THRESHOLD = 300;
193+
136194
const toggleVisibility = () => {
137-
if (window.scrollY > 300) {
195+
if (window.scrollY > SCROLL_THRESHOLD) {
138196
backToTopBtn.classList.add('visible');
139197
} else {
140198
backToTopBtn.classList.remove('visible');
@@ -148,58 +206,83 @@ const setupBackToTop = () => {
148206
});
149207
};
150208

209+
// Set initial visibility
151210
toggleVisibility();
152211

212+
// Event listeners with passive flag for better scroll performance
153213
window.addEventListener('scroll', toggleVisibility, { passive: true });
154214
backToTopBtn.addEventListener('click', scrollToTop);
155215

216+
// Cleanup function
156217
return () => {
157218
window.removeEventListener('scroll', toggleVisibility);
158219
backToTopBtn.removeEventListener('click', scrollToTop);
159220
};
160221
};
161222

162-
let teardownNav;
163-
let teardownTheme;
164-
let teardownBackToTop;
223+
// Store cleanup functions for proper listener management with View Transitions
224+
let teardownNav = null;
225+
let teardownTheme = null;
226+
let teardownBackToTop = null;
165227

228+
/**
229+
* Main initialization function for progressive enhancement
230+
* Called on initial page load and after each View Transition
231+
*
232+
* This function:
233+
* 1. Syncs theme icons with current state
234+
* 2. Cleans up old event listeners
235+
* 3. Sets up new event listeners for the current page
236+
*/
166237
const enhance = () => {
167-
const currentAttr = document.documentElement.getAttribute('data-theme');
168-
syncThemeIcons(currentAttr === 'light' ? 'light' : 'dark');
169-
if (teardownTheme) {
170-
teardownTheme();
171-
}
238+
// Ensure theme icons match the current theme
239+
const currentTheme = document.documentElement.getAttribute('data-theme') || THEME_DARK;
240+
syncThemeIcons(currentTheme === THEME_LIGHT ? THEME_LIGHT : THEME_DARK);
241+
242+
// Clean up existing listeners before setting up new ones
243+
// This prevents memory leaks and duplicate event handlers
244+
if (teardownTheme) teardownTheme();
245+
if (teardownNav) teardownNav();
246+
if (teardownBackToTop) teardownBackToTop();
247+
248+
// Setup new listeners for the current page
172249
teardownTheme = setupThemeToggles();
173-
if (teardownNav) {
174-
teardownNav();
175-
}
176250
teardownNav = setupMobileNavigation();
177-
if (teardownBackToTop) {
178-
teardownBackToTop();
179-
}
180251
teardownBackToTop = setupBackToTop();
181252
};
182253

254+
// Initialize on DOM ready
183255
if (document.readyState === 'complete' || document.readyState === 'interactive') {
184256
enhance();
185257
} else {
186258
document.addEventListener('DOMContentLoaded', enhance, { once: true });
187259
}
188260

261+
/**
262+
* Astro View Transitions lifecycle hooks
263+
*
264+
* Best practices for Astro View Transitions:
265+
* - Use astro:page-load for main initialization (fires after transition completes)
266+
* - Use astro:after-swap for immediate DOM updates (fires right after DOM swap)
267+
* - Use astro:before-preparation for cleanup before transition starts
268+
*/
269+
270+
// Re-initialize after each page transition
189271
document.addEventListener('astro:page-load', enhance);
272+
273+
// Also run after DOM swap for faster perceived performance
190274
document.addEventListener('astro:after-swap', enhance);
191275

192-
// Close mobile menu on navigation
276+
// Clean up mobile menu state before page transitions
193277
document.addEventListener('astro:before-preparation', () => {
194278
const navMenu = document.getElementById('mobile-nav-menu');
195279
const navToggle = document.getElementById('mobile-nav-toggle');
196280

197-
if (navMenu && navMenu.classList.contains('is-open')) {
281+
if (navMenu?.classList.contains('is-open')) {
198282
navMenu.classList.remove('is-open');
199-
if (navToggle) {
200-
navToggle.setAttribute('aria-expanded', 'false');
201-
}
283+
navToggle?.setAttribute('aria-expanded', 'false');
202284
}
203285
});
204286

287+
// ESM export to ensure this is treated as a module
205288
export {};

0 commit comments

Comments
 (0)