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
616const 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+ */
824const 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+ */
2142const 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+ */
2550const 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+ */
3464const 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
4679setThemeAttribute ( getStoredTheme ( ) ) ;
4780
81+ /**
82+ * Handles theme toggle button clicks
83+ * Toggles between light and dark themes and persists the choice
84+ */
4885const 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
5593window . toggleTheme = handleThemeToggle ;
5694
95+ /**
96+ * Sets up theme toggle button event listeners
97+ * @returns {Function } Cleanup function to remove listeners
98+ */
5799const 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+ */
75122const 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+ */
129185const 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+ */
166237const 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
183255if ( 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
189271document . addEventListener ( 'astro:page-load' , enhance ) ;
272+
273+ // Also run after DOM swap for faster perceived performance
190274document . addEventListener ( 'astro:after-swap' , enhance ) ;
191275
192- // Close mobile menu on navigation
276+ // Clean up mobile menu state before page transitions
193277document . 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
205288export { } ;
0 commit comments