my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: refactor color theme dropdown to match navigation behavior

- Add shared dropdown state management via new dropdownState store
- Move mobile theme dropdown rendering to Header component
- Implement mutually exclusive dropdown behavior (nav menu vs theme picker)
- Mobile theme dropdown now pushes content down instead of overlaying
- Add proper Svelte 5 $state runes for reactive variables
- Improve accessibility with proper ARIA labels and roles
- Sync active state styling between desktop and mobile dropdowns
- Update "color" to "colour" for British English consistency

+143 -31
+2 -2
docs/README.md
··· 16 16 17 17 ### [Theme System](./theme-system.md) 18 18 Documentation for the centralized color theme system. Learn how to: 19 - - Add new color themes 19 + - Add new Colour Themes 20 20 - Customize existing themes 21 21 - Understand the theme architecture 22 22 - Use the theme configuration API 23 23 24 - **Read this if you want to customize or add color themes.** 24 + **Read this if you want to customize or add Colour Themes.** 25 25 26 26 ## 🚀 Quick Links 27 27
+30 -14
src/lib/components/layout/ColorThemeToggle.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { Palette, Check } from '@lucide/svelte'; 4 4 import { colorTheme, type ColorTheme } from '$lib/stores/colorTheme'; 5 + import { colorThemeDropdownOpen } from '$lib/stores/dropdownState'; 5 6 import { 6 7 getThemesByCategory, 7 8 CATEGORY_LABELS, ··· 24 25 mounted = state.mounted; 25 26 }); 26 27 27 - // Close dropdown when clicking outside 28 + // Subscribe to dropdown state 29 + const unsubDropdown = colorThemeDropdownOpen.subscribe((open) => { 30 + isOpen = open; 31 + }); 32 + 33 + // Close dropdown when clicking outside (desktop only) 28 34 const handleClickOutside = (e: MouseEvent) => { 29 - if (isOpen) { 35 + if (isOpen && window.innerWidth >= 768) { 30 36 const target = e.target as HTMLElement; 31 37 if (!target.closest('.color-theme-dropdown')) { 32 - isOpen = false; 38 + colorThemeDropdownOpen.set(false); 33 39 } 34 40 } 35 41 }; 36 42 document.addEventListener('click', handleClickOutside); 37 43 38 - // Close on Escape key 44 + // Close on Escape key (desktop only, mobile handled by Header) 39 45 const handleEscape = (e: KeyboardEvent) => { 40 - if (e.key === 'Escape' && isOpen) { 41 - isOpen = false; 46 + if (e.key === 'Escape' && isOpen && window.innerWidth >= 768) { 47 + colorThemeDropdownOpen.set(false); 42 48 } 43 49 }; 44 50 document.addEventListener('keydown', handleEscape); 45 51 46 52 return () => { 47 53 unsubscribe(); 54 + unsubDropdown(); 48 55 document.removeEventListener('click', handleClickOutside); 49 56 document.removeEventListener('keydown', handleEscape); 50 57 }; 51 58 }); 52 59 53 60 function toggleDropdown() { 54 - isOpen = !isOpen; 61 + colorThemeDropdownOpen.set(!isOpen); 55 62 } 56 63 57 64 function selectTheme(theme: ColorTheme) { 58 65 colorTheme.setTheme(theme); 59 - isOpen = false; 66 + colorThemeDropdownOpen.set(false); 60 67 } 61 68 </script> 62 69 63 70 <div class="color-theme-dropdown relative"> 64 71 <button 65 72 onclick={toggleDropdown} 66 - class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 67 - aria-label="Change color theme" 73 + class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 74 + aria-label="Change colour theme" 68 75 aria-expanded={isOpen} 69 76 aria-controls="color-theme-menu" 70 77 type="button" ··· 77 84 </button> 78 85 79 86 {#if isOpen} 87 + <!-- Desktop ONLY: Dropdown menu --> 80 88 <div 81 89 id="color-theme-menu" 82 - class="absolute right-0 top-full z-50 mt-2 w-72 rounded-lg border border-canvas-200 bg-canvas-50 shadow-xl dark:border-canvas-800 dark:bg-canvas-950" 90 + class="absolute right-0 top-full z-50 mt-2 hidden w-72 rounded-lg border border-canvas-200 bg-canvas-50 shadow-xl md:block dark:border-canvas-800 dark:bg-canvas-950" 83 91 role="menu" 92 + aria-label="Colour theme menu" 84 93 > 85 94 <div class="max-h-128 overflow-y-auto p-2"> 86 95 <div class="mb-2 px-3 py-2 text-xs font-semibold uppercase text-ink-600 dark:text-ink-400"> 87 - Color Themes 96 + Colour Themes 88 97 </div> 89 98 90 99 {#each Object.entries(themesByCategory) as [category, categoryThemes]} ··· 96 105 {#each categoryThemes as theme} 97 106 <button 98 107 onclick={() => selectTheme(theme.value as ColorTheme)} 99 - class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-canvas-100 focus-visible:bg-canvas-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900" 108 + class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 109 + {currentTheme === theme.value 110 + ? 'bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300' 111 + : 'text-ink-700 hover:bg-canvas-100 focus-visible:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900'}" 100 112 role="menuitem" 101 113 aria-current={currentTheme === theme.value ? 'true' : undefined} 102 114 > ··· 106 118 aria-hidden="true" 107 119 ></div> 108 120 <div class="flex-1 min-w-0"> 109 - <div class="font-medium text-ink-900 dark:text-ink-50">{theme.label}</div> 121 + <div 122 + class="font-medium {currentTheme === theme.value ? '' : 'text-ink-900 dark:text-ink-50'}" 123 + > 124 + {theme.label} 125 + </div> 110 126 <div class="text-xs text-ink-600 dark:text-ink-400">{theme.description}</div> 111 127 </div> 112 128 {#if currentTheme === theme.value}
+107 -15
src/lib/components/layout/Header.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { getStores } from '$app/stores'; 4 - import { Menu, X } from '@lucide/svelte'; 4 + import { Menu, X, Check } from '@lucide/svelte'; 5 5 import * as LucideIcons from '@lucide/svelte'; 6 6 import ThemeToggle from './ThemeToggle.svelte'; 7 7 import WolfToggle from './WolfToggle.svelte'; ··· 9 9 import { navItems } from '$lib/data/navItems'; 10 10 import { fetchProfile, type ProfileData } from '$lib/services/atproto'; 11 11 import { defaultSiteMeta, createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 12 + import { colorThemeDropdownOpen } from '$lib/stores/dropdownState'; 13 + import { colorTheme, type ColorTheme } from '$lib/stores/colorTheme'; 14 + import { 15 + getThemesByCategory, 16 + CATEGORY_LABELS 17 + } from '$lib/config/themes.config'; 12 18 13 19 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); 14 20 const { page } = getStores(); 15 21 16 - let profile: ProfileData | null = null; 17 - let loading = true; 18 - let error: string | null = null; 19 - let imageLoaded = false; 20 - let mobileMenuOpen = false; 22 + let profile = $state<ProfileData | null>(null); 23 + let loading = $state(true); 24 + let error = $state<string | null>(null); 25 + let imageLoaded = $state(false); 26 + let mobileMenuOpen = $state(false); 27 + let colorThemeOpen = $state(false); 28 + let currentTheme = $state<ColorTheme>('slate'); 29 + 30 + // Get themes organized by category 31 + const themesByCategory = getThemesByCategory(); 32 + type Category = keyof typeof CATEGORY_LABELS; 21 33 22 34 // Map of icon names to Lucide components 23 35 let iconComponents: Record<string, any> = {}; ··· 30 42 31 43 function toggleMobileMenu() { 32 44 mobileMenuOpen = !mobileMenuOpen; 33 - // Trap focus when mobile menu opens 45 + // Close color theme dropdown when opening mobile menu 34 46 if (mobileMenuOpen) { 35 - document.body.style.overflow = 'hidden'; 36 - } else { 37 - document.body.style.overflow = ''; 47 + colorThemeDropdownOpen.set(false); 38 48 } 39 49 } 40 50 41 51 function closeMobileMenu() { 42 52 mobileMenuOpen = false; 43 - document.body.style.overflow = ''; 53 + } 54 + 55 + function closeColorThemeDropdown() { 56 + colorThemeDropdownOpen.set(false); 57 + } 58 + 59 + function selectTheme(theme: ColorTheme) { 60 + colorTheme.setTheme(theme); 61 + closeColorThemeDropdown(); 44 62 } 45 63 46 64 function isActive(href: string) { ··· 48 66 } 49 67 50 68 onMount(() => { 69 + // Subscribe to color theme state 70 + const unsubTheme = colorTheme.subscribe((state) => { 71 + currentTheme = state.current; 72 + }); 73 + 74 + // Subscribe to color theme dropdown state 75 + const unsubDropdown = colorThemeDropdownOpen.subscribe((open) => { 76 + colorThemeOpen = open; 77 + // Close mobile menu when opening color theme dropdown 78 + if (open) { 79 + mobileMenuOpen = false; 80 + } 81 + }); 82 + 51 83 // Fetch profile 52 84 fetchProfile() 53 85 .then((data) => { ··· 60 92 loading = false; 61 93 }); 62 94 63 - // Close mobile menu on Escape key 95 + // Close mobile menus on Escape key 64 96 const handleEscape = (e: KeyboardEvent) => { 65 - if (e.key === 'Escape' && mobileMenuOpen) { 66 - closeMobileMenu(); 97 + if (e.key === 'Escape') { 98 + if (mobileMenuOpen) { 99 + closeMobileMenu(); 100 + } 101 + if (colorThemeOpen && window.innerWidth < 768) { 102 + closeColorThemeDropdown(); 103 + } 67 104 } 68 105 }; 69 106 document.addEventListener('keydown', handleEscape); 70 107 71 108 return () => { 109 + unsubTheme(); 110 + unsubDropdown(); 72 111 document.removeEventListener('keydown', handleEscape); 73 - document.body.style.overflow = ''; 74 112 }; 75 113 }); 76 114 </script> ··· 219 257 </li> 220 258 {/each} 221 259 </ul> 260 + </nav> 261 + {/if} 262 + 263 + <!-- Mobile Colour Theme Dropdown --> 264 + {#if colorThemeOpen} 265 + <nav 266 + id="color-theme-menu" 267 + class="border-t border-canvas-200 bg-canvas-50 md:hidden dark:border-canvas-800 dark:bg-canvas-950" 268 + aria-label="Colour theme menu" 269 + > 270 + <div class="container mx-auto flex flex-col px-3 py-2"> 271 + {#each Object.entries(themesByCategory) as [category, categoryThemes]} 272 + <div class="mb-4 last:mb-0"> 273 + <div class="mb-2 px-3 text-xs font-semibold uppercase tracking-wide text-ink-600 dark:text-ink-400"> 274 + {CATEGORY_LABELS[category as Category]} 275 + </div> 276 + <div class="space-y-1"> 277 + {#each categoryThemes as theme} 278 + <button 279 + onclick={() => selectTheme(theme.value as ColorTheme)} 280 + class="flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 281 + {currentTheme === theme.value 282 + ? 'bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300' 283 + : 'text-ink-700 hover:bg-canvas-100 focus-visible:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900'}" 284 + role="menuitem" 285 + aria-current={currentTheme === theme.value ? 'true' : undefined} 286 + > 287 + <div 288 + class="h-7 w-7 shrink-0 rounded-md border border-canvas-300 shadow-sm dark:border-canvas-700" 289 + style="background-color: {theme.color}" 290 + aria-hidden="true" 291 + ></div> 292 + <div class="min-w-0 flex-1"> 293 + <div 294 + class="font-medium {currentTheme === theme.value ? '' : 'text-ink-900 dark:text-ink-50'}" 295 + > 296 + {theme.label} 297 + </div> 298 + <div class="text-sm text-ink-600 dark:text-ink-400"> 299 + {theme.description} 300 + </div> 301 + </div> 302 + {#if currentTheme === theme.value} 303 + <Check 304 + class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400" 305 + aria-hidden="true" 306 + /> 307 + {/if} 308 + </button> 309 + {/each} 310 + </div> 311 + </div> 312 + {/each} 313 + </div> 222 314 </nav> 223 315 {/if} 224 316 </header>
+3
src/lib/stores/dropdownState.ts
··· 1 + import { writable } from 'svelte/store'; 2 + 3 + export const colorThemeDropdownOpen = writable(false);
+1
src/lib/stores/index.ts
··· 1 1 export { wolfMode } from './wolfMode'; 2 + export { colorThemeDropdownOpen } from './dropdownState';