From 62ce70852a085c4d94dc4d1b9960f10cb6e3f62a Mon Sep 17 00:00:00 2001 From: connie-ooi Date: Fri, 2 Oct 2026 15:52:44 -0700 Subject: [PATCH 01/14] Add dark mode theme switcher and styles to the Flutter blog --- .../blog/client/blog_categories.dart | 47 ++- .../lib/components/layout/theme_switcher.dart | 145 +++++++-- packages/site_shared/lib/server_util.dart | 1 + .../lib/src/layouts/dash_layout.dart | 20 +- .../lib/src/utils/theme_script.dart | 56 ++++ sites/www/lib/main.client.options.dart | 6 + sites/www/lib/main.server.options.dart | 5 + .../www/lib/src/components/layout/footer.dart | 7 + .../www/lib/src/components/layout/header.dart | 2 + sites/www/lib/src/layouts/blog_layout.dart | 28 ++ sites/www/lib/src/layouts/default_layout.dart | 8 +- sites/www/lib/styles/core/_vars.scss | 5 + sites/www/lib/styles/pages/_blog_page.scss | 292 +++++++++++++++++- sites/www/lib/styles/styles.scss | 1 + 14 files changed, 557 insertions(+), 66 deletions(-) create mode 100644 packages/site_shared/lib/src/utils/theme_script.dart diff --git a/packages/site_shared/lib/components/blog/client/blog_categories.dart b/packages/site_shared/lib/components/blog/client/blog_categories.dart index 2719bde8881..c24de0b78d1 100644 --- a/packages/site_shared/lib/components/blog/client/blog_categories.dart +++ b/packages/site_shared/lib/components/blog/client/blog_categories.dart @@ -61,7 +61,7 @@ class _BlogCategoriesState extends State { void initState() { super.initState(); if (kIsWeb) { - _updateCategoryFromUrl(); + _updateCategoryFromUrl(initial: true); _popStateSubscription = web.EventStreamProviders.popStateEvent .forTarget(web.window) .listen((_) => _updateCategoryFromUrl()); @@ -74,16 +74,26 @@ class _BlogCategoriesState extends State { super.dispose(); } - void _updateCategoryFromUrl() { + void _updateCategoryFromUrl({bool initial = false}) { final uri = Uri.parse(web.window.location.href); final slug = uri.queryParameters['category']; final category = component.categories .where((c) => c.slug == slug) .firstOrNull; - setState(() { + if (initial) { selectedCategory = category; - }); + if (category != null) { + _applyLayout(category); + } + return; + } + + if (category != selectedCategory) { + setState(() { + selectedCategory = category; + }); + } _applyLayout(category); } @@ -107,7 +117,7 @@ class _BlogCategoriesState extends State { final container = web.document.getElementById('blog-container'); if (container != null) { if (category == null) { - container.removeAttribute('data-selected'); + container.setAttribute('data-selected', 'all'); } else { container.setAttribute('data-selected', category.slug); } @@ -121,22 +131,31 @@ class _BlogCategoriesState extends State { final cardCategory = card.getAttribute('data-category'); final isVisible = category == null || cardCategory == category.slug; - - // Remove existing layout classes. - card.classList.remove('layout-featured'); - card.classList.remove('layout-grid'); - card.classList.remove('layout-list'); - + String? targetClass; if (isVisible) { if (visibleCount == 0) { - card.classList.add('layout-featured'); + targetClass = 'layout-featured'; } else if (visibleCount < 5) { - card.classList.add('layout-grid'); + targetClass = 'layout-grid'; } else { - card.classList.add('layout-list'); + targetClass = 'layout-list'; } visibleCount++; } + + for (final layoutClass in const [ + 'layout-featured', + 'layout-grid', + 'layout-list', + ]) { + if (layoutClass == targetClass) { + if (!card.classList.contains(layoutClass)) { + card.classList.add(layoutClass); + } + } else if (card.classList.contains(layoutClass)) { + card.classList.remove(layoutClass); + } + } } } } diff --git a/packages/site_shared/lib/components/layout/theme_switcher.dart b/packages/site_shared/lib/components/layout/theme_switcher.dart index a06e647c477..a9a0960265e 100644 --- a/packages/site_shared/lib/components/layout/theme_switcher.dart +++ b/packages/site_shared/lib/components/layout/theme_switcher.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:jaspr/dom.dart'; import 'package:jaspr/jaspr.dart'; import 'package:universal_web/web.dart' as web; @@ -18,6 +20,15 @@ final class ThemeSwitcher extends StatefulComponent { State createState() => _ThemeSwitcherState(); } +/// A [ThemeSwitcher] variant for use inside an existing `@client` component, +/// such as a client-hydrated header, to avoid nested `@client` anchors. +final class NestedThemeSwitcher extends StatefulComponent { + const NestedThemeSwitcher({super.key}); + + @override + State createState() => _ThemeSwitcherState(); +} + enum _Theme { light('Light', 'Switch to the light theme.', 'light_mode'), dark('Dark', 'Switch to the dark theme.', 'dark_mode'), @@ -32,46 +43,123 @@ enum _Theme { String get id => '$name-mode'; } -final class _ThemeSwitcherState extends State { +final class _ThemeSwitcherState extends State { _Theme _currentTheme = _Theme.light; + StreamSubscription? _pageShowSubscription; + StreamSubscription? _storageSubscription; + StreamSubscription? _mediaQuerySubscription; @override void initState() { + super.initState(); if (kIsWeb) { - final classList = web.document.body!.classList; - // If them theme is auto, it and the result will be added as classes. - // So it should be checked for first. - if (classList.contains(_Theme.auto.id)) { - _currentTheme = _Theme.auto; - } else if (classList.contains(_Theme.dark.id)) { - _currentTheme = _Theme.dark; - } else if (classList.contains(_Theme.light.id)) { - _currentTheme = _Theme.light; - } else { - // Default to light mode if no theme is set yet. - _currentTheme = _Theme.light; - classList.add(_Theme.light.id); + _syncThemeFromStorage(); + _pageShowSubscription = + const web.EventStreamProvider( + 'pageshow', + ) + .forTarget(web.window) + .listen( + (_) => _syncThemeFromStorage(updateState: true), + ); + _storageSubscription = web.EventStreamProviders.storageEvent + .forTarget(web.window) + .listen((_) => _syncThemeFromStorage(updateState: true)); + _mediaQuerySubscription = + const web.EventStreamProvider( + 'change', + ) + .forTarget(web.window.matchMedia('(prefers-color-scheme: dark)')) + .listen( + (_) { + if (_currentTheme == _Theme.auto) { + _applyThemeToBody(_Theme.auto); + } + }, + ); + } + } + + @override + void dispose() { + unawaited(_pageShowSubscription?.cancel()); + unawaited(_storageSubscription?.cancel()); + unawaited(_mediaQuerySubscription?.cancel()); + super.dispose(); + } + + _Theme _themeFromBodyClasses() { + final classList = + (web.document.body ?? web.document.documentElement)?.classList; + if (classList == null) return _Theme.light; + if (classList.contains(_Theme.auto.id)) { + return _Theme.auto; + } else if (classList.contains(_Theme.dark.id)) { + return _Theme.dark; + } else { + return _Theme.light; + } + } + + void _applyThemeToBody(_Theme theme) { + final isAuto = theme == _Theme.auto; + final isDark = isAuto + ? web.window.matchMedia('(prefers-color-scheme: dark)').matches + : theme == _Theme.dark; + final resolvedId = isDark ? _Theme.dark.id : _Theme.light.id; + final oppositeId = isDark ? _Theme.light.id : _Theme.dark.id; + + for (final element in [ + web.document.documentElement, + web.document.body, + ]) { + if (element == null) continue; + final classList = element.classList; + if (classList.contains(resolvedId) && + classList.contains(_Theme.auto.id) == isAuto && + !classList.contains(oppositeId)) { + continue; + } + for (final mode in _Theme.values) { + classList.remove(mode.id); + } + classList.add(resolvedId); + if (isAuto) { + classList.add(_Theme.auto.id); } } + } - super.initState(); + void _syncThemeFromStorage({bool updateState = false}) { + String? storedThemeId; + try { + storedThemeId = web.window.localStorage.getItem('theme'); + } catch (_) { + // localStorage is not available, fall back to body classes. + } + + final resolvedTheme = switch (storedThemeId) { + 'auto-mode' => _Theme.auto, + 'dark-mode' => _Theme.dark, + 'light-mode' => _Theme.light, + _ => _themeFromBodyClasses(), + }; + + _applyThemeToBody(resolvedTheme); + + if (updateState && resolvedTheme != _currentTheme) { + setState(() { + _currentTheme = resolvedTheme; + }); + } else { + _currentTheme = resolvedTheme; + } } void _setTheme(_Theme newTheme) { if (newTheme == _currentTheme) return; - final classList = web.document.body!.classList; - for (final mode in _Theme.values) { - classList.remove(mode.id); - } - classList.add(newTheme.id); - if (newTheme == _Theme.auto) { - classList.add( - web.window.matchMedia('(prefers-color-scheme: dark)').matches - ? _Theme.dark.id - : _Theme.light.id, - ); - } + _applyThemeToBody(newTheme); try { web.window.localStorage.setItem('theme', newTheme.id); @@ -120,11 +208,12 @@ final class _ThemeButtonEntry extends StatelessComponent { final void Function(_Theme) setMode; @override - Component build(BuildContext _) => li([ + Component build(BuildContext context) => li([ button( events: { 'click': (_) { setMode(mode); + context.findAncestorStateOfType()?.toggle(to: false); }, }, attributes: { diff --git a/packages/site_shared/lib/server_util.dart b/packages/site_shared/lib/server_util.dart index be8240d576f..e8033128825 100644 --- a/packages/site_shared/lib/server_util.dart +++ b/packages/site_shared/lib/server_util.dart @@ -6,3 +6,4 @@ library; export 'src/utils/cache_busted_build_asset_url.dart'; +export 'src/utils/theme_script.dart'; diff --git a/packages/site_shared/lib/src/layouts/dash_layout.dart b/packages/site_shared/lib/src/layouts/dash_layout.dart index ddd2b23d172..2553ad6b63a 100644 --- a/packages/site_shared/lib/src/layouts/dash_layout.dart +++ b/packages/site_shared/lib/src/layouts/dash_layout.dart @@ -11,6 +11,7 @@ import 'package:jaspr_content/jaspr_content.dart'; import '../../components/layout/banner.dart'; import '../../util.dart'; import '../utils/cache_busted_build_asset_url.dart'; +import '../utils/theme_script.dart'; /// The base Jaspr Content layout for all sites. abstract class DashLayout implements PageLayout { @@ -247,24 +248,7 @@ function glueCookieNotificationBarLoaded() { children: [ // The theme setting logic should remain before other scripts to // avoid a flash of the initial theme on load. - const script( - content: ''' -try { - const storedTheme = window.localStorage.getItem('theme') ?? 'light-mode'; - if (storedTheme === 'auto-mode') { - const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); - document.body.classList.add( - 'auto-mode', - prefersDarkMode.matches ? 'dark-mode' : 'light-mode', - ); - } else { - document.body.classList.add(storedTheme); - } -} catch (e) { - // localStorage is not available, do nothing and fall back to default. -} - ''', - ), + themeInitScript, const a( id: 'skip-to-main', classes: 'filled-button', diff --git a/packages/site_shared/lib/src/utils/theme_script.dart b/packages/site_shared/lib/src/utils/theme_script.dart new file mode 100644 index 00000000000..1367c2b9b04 --- /dev/null +++ b/packages/site_shared/lib/src/utils/theme_script.dart @@ -0,0 +1,56 @@ +// Copyright 2026, the Flutter authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +/// Inline `