feat(style): add CSS theme system with light/dark mode toggle
Introduces a global design system using CSS custom properties as design tokens. Light and dark themes are defined via [data-theme] attribute on <html>; the system preference (prefers-color-scheme) is the default when no explicit choice is stored. ThemeToggle component (Auto → Light → Dark cycle) persists the choice to localStorage and applies it on hydration without flash. New themes can be added by defining a [data-theme="name"] CSS block and adding a variant to ThemeChoice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
10
src/app.rs
10
src/app.rs
@@ -11,7 +11,7 @@ use leptos_router::{
|
||||
path,
|
||||
};
|
||||
|
||||
use crate::client::{home::HomePage, networks::NetworksPage};
|
||||
use crate::client::{home::HomePage, networks::NetworksPage, theme::ThemeToggle};
|
||||
|
||||
// Shell — full HTML document rendered by the Axum server.
|
||||
//
|
||||
@@ -83,11 +83,11 @@ pub fn App() -> impl IntoView {
|
||||
// Router handles client-side navigation without full page reloads.
|
||||
// On the server, it determines which component to render for the requested URL.
|
||||
<Router>
|
||||
// Temporary navigation bar — will be replaced by task #9.
|
||||
<nav>
|
||||
<a href="/">"Home"</a>
|
||||
" | "
|
||||
<nav>
|
||||
<a href="/">"Rust IPAM"</a>
|
||||
<a href="/networks">"Networks"</a>
|
||||
<span class="nav-spacer"/>
|
||||
<ThemeToggle/>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
|
||||
pub mod home; // Home page
|
||||
pub mod networks; // Networks list and creation
|
||||
pub mod theme; // Theme toggle component (light / dark / system)
|
||||
|
||||
@@ -109,40 +109,37 @@ pub fn NetworksPage() -> impl IntoView {
|
||||
.into_any(),
|
||||
|
||||
Ok(list) => view! {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"CIDR"</th>
|
||||
<th>"Actions"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
// `collect_view()` turns an iterator of views
|
||||
// into a single renderable fragment.
|
||||
{list
|
||||
.into_iter()
|
||||
.map(|network| {
|
||||
// Capture `id` as a plain i64 (Copy).
|
||||
// Closures in event handlers must be 'static,
|
||||
// so we can't move the full `network` struct.
|
||||
let id = network.id;
|
||||
view! {
|
||||
<tr>
|
||||
<td>{network.cidr}</td>
|
||||
<td>
|
||||
<button on:click=move |_| {
|
||||
delete_action
|
||||
.dispatch(DeleteNetwork { id });
|
||||
}>
|
||||
"Delete"
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"CIDR"</th>
|
||||
<th>"Actions"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list
|
||||
.into_iter()
|
||||
.map(|network| {
|
||||
let id = network.id;
|
||||
view! {
|
||||
<tr>
|
||||
<td>{network.cidr}</td>
|
||||
<td>
|
||||
<button on:click=move |_| {
|
||||
delete_action
|
||||
.dispatch(DeleteNetwork { id });
|
||||
}>
|
||||
"Delete"
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
})
|
||||
|
||||
136
src/client/theme.rs
Normal file
136
src/client/theme.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
// client/theme.rs — Theme toggle component
|
||||
//
|
||||
// Architecture for multi-theme support:
|
||||
// - ThemeChoice enum: each variant maps to a `data-theme` attribute value
|
||||
// (None for "System" = follow OS preference via CSS media query)
|
||||
// - Adding a future theme: add a variant here + a CSS [data-theme="..."] block
|
||||
// - The active theme is persisted in localStorage under "ipam-theme"
|
||||
// - When no value is stored, the CSS media query `prefers-color-scheme` decides
|
||||
//
|
||||
// SSR vs WASM:
|
||||
// - SSR renders the button with no data-theme on <html> (system default applies)
|
||||
// - On hydration, the WASM Effect reads localStorage and applies the stored choice
|
||||
// - The `#[cfg(target_arch = "wasm32")]` guards prevent DOM/localStorage calls
|
||||
// from being compiled into the server binary
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
// Used only in WASM builds — suppress false-positive dead_code warnings from SSR
|
||||
#[allow(dead_code)]
|
||||
const STORAGE_KEY: &str = "ipam-theme";
|
||||
|
||||
// Each variant corresponds to a CSS `[data-theme]` value (None = follow OS).
|
||||
// To add a theme: add a variant, implement the methods, add CSS variables.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub enum ThemeChoice {
|
||||
#[default]
|
||||
System, // No data-theme attribute; CSS prefers-color-scheme decides
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ThemeChoice {
|
||||
// The attribute value written to <html data-theme="...">, or None to remove it.
|
||||
fn attr_value(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::System => None,
|
||||
Self::Light => Some("light"),
|
||||
Self::Dark => Some("dark"),
|
||||
}
|
||||
}
|
||||
|
||||
// Label shown in the toggle button.
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Auto",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
}
|
||||
}
|
||||
|
||||
// Cycles to the next theme. Extend this as new variants are added.
|
||||
fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::System => Self::Light,
|
||||
Self::Light => Self::Dark,
|
||||
Self::Dark => Self::System,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_stored(s: &str) -> Self {
|
||||
match s {
|
||||
"light" => Self::Light,
|
||||
"dark" => Self::Dark,
|
||||
_ => Self::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
|
||||
|
||||
// Reads the stored theme name from localStorage.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_stored_theme() -> Option<ThemeChoice> {
|
||||
let storage = web_sys::window()?.local_storage().ok()??;
|
||||
let value = storage.get_item(STORAGE_KEY).ok()??;
|
||||
Some(ThemeChoice::from_stored(&value))
|
||||
}
|
||||
|
||||
// Applies `data-theme` attribute to <html> and persists to localStorage.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn apply_and_persist(choice: &ThemeChoice) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(root) = document.document_element() else { return };
|
||||
|
||||
match choice.attr_value() {
|
||||
Some(v) => { let _ = root.set_attribute("data-theme", v); }
|
||||
None => { let _ = root.remove_attribute("data-theme"); }
|
||||
}
|
||||
|
||||
if let Ok(Some(storage)) = window.local_storage() {
|
||||
match choice.attr_value() {
|
||||
Some(v) => { let _ = storage.set_item(STORAGE_KEY, v); }
|
||||
None => { let _ = storage.remove_item(STORAGE_KEY); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
let theme = RwSignal::new(ThemeChoice::System);
|
||||
|
||||
// Effect 1: runs once on mount — reads localStorage and initializes the signal.
|
||||
// Does NOT track `theme`, so it never re-runs after the initial mount.
|
||||
// Setting the signal here triggers Effect 2 below.
|
||||
Effect::new(move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(stored) = load_stored_theme() {
|
||||
theme.set(stored);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect 2: tracks `theme` — applies the choice to the DOM and localStorage
|
||||
// whenever the signal changes (both on init and after user clicks).
|
||||
Effect::new(move |_| {
|
||||
let current = theme.get(); // tracked — re-runs when theme changes
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
apply_and_persist(¤t);
|
||||
// Suppress unused variable warning when compiling for SSR
|
||||
let _ = current;
|
||||
});
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="theme-toggle"
|
||||
title="Toggle color theme"
|
||||
on:click=move |_| theme.update(|t| *t = t.next())
|
||||
>
|
||||
{move || theme.get().label()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user