// SPDX-FileCopyrightText: 2026 Proa Labs, Inc. // SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT) //! Theme Toggle component implementation //! //! SSR markup for the light/dark theme toggle button. The JavaScript runtime //! handles the click: it flips the `dark` class on ``, mirrors //! `color-scheme`, and persists the choice under the `theme` localStorage key //! (falling back to `prefers-color-scheme` when nothing is stored). The icon //! swap is pure CSS via the root `.dark` class, so the button needs no //! per-instance state. //! //! The moon and sun glyphs embed SVG geometry adapted from Lucide //! (Feather-derived icons, ISC AND MIT). use super::recipe::{ThemeToggleRecipeProps, THEME_TOGGLE_RECIPE}; use super::THEME_TOGGLE_ANATOMY; use proa_core::{WebContext, WebRenderSync, WriteBuf, WriteError}; use proa_macros::html_sync; /// ThemeToggle - a button that switches the document between light and dark. /// /// # Example /// /// ```ignore /// use proa_ui::ThemeToggle; /// use proa_macros::html_sync; /// /// html_sync! { /// {ThemeToggle::new()} /// } /// ``` pub struct ThemeToggle { /// Accessible label; also used as the hover title. pub aria_label: &'static str, /// Additional classes fused after the recipe classes. pub class: Option<&'static str>, } impl ThemeToggle { pub const fn new() -> Self { Self { aria_label: "Toggle theme", class: None, } } } impl Default for ThemeToggle { fn default() -> Self { Self::new() } } impl WebRenderSync for ThemeToggle { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { let style = THEME_TOGGLE_RECIPE.resolve(ThemeToggleRecipeProps); html_sync! { } .render(cx) } } #[cfg(test)] mod tests { use super::*; use proa_core::ctx::Ctx; #[test] fn theme_toggle_renders_with_data_attributes() { let mut out = Vec::new(); let mut cx = Ctx::with_buffer(&mut out); ThemeToggle::new().render(&mut cx).unwrap(); let html = String::from_utf8(out).unwrap(); assert!(html.contains("data-theme-toggle-button")); assert!(html.contains(r#"data-scope="theme-toggle""#)); assert!(html.contains(r#"data-part="root""#)); assert!(html.contains(r#"data-slot="theme-toggle""#)); assert!(html.contains(r#"aria-label="Toggle theme""#)); assert!(html.contains(r#"type="button""#)); } #[test] fn theme_toggle_icons_swap_via_dark_class_only() { let mut out = Vec::new(); let mut cx = Ctx::with_buffer(&mut out); ThemeToggle::new().render(&mut cx).unwrap(); let html = String::from_utf8(out).unwrap(); // Moon shows in light mode, sun under `.dark` — both statically // rendered so the runtime never touches the icons. assert!(html.contains(r#"data-part="moon-icon""#)); assert!(html.contains(r#"data-part="sun-icon""#)); assert!(html.contains("dark:hidden")); assert!(html.contains("dark:block")); } #[test] fn theme_toggle_fuses_custom_class_and_label() { let mut out = Vec::new(); let mut cx = Ctx::with_buffer(&mut out); ThemeToggle { aria_label: "Switch color scheme", class: Some("custom-toggle"), } .render(&mut cx) .unwrap(); let html = String::from_utf8(out).unwrap(); assert!(html.contains(r#"aria-label="Switch color scheme""#)); assert!(html.contains("custom-toggle")); assert!(html.contains("inline-flex")); } /// The component must never share the marketing site's `data-theme-toggle` /// attribute: pages that load both the site chrome script and `proa.js` /// would toggle twice per click, which reads as a broken button. #[test] fn theme_toggle_uses_a_distinct_hook_attribute() { let mut out = Vec::new(); let mut cx = Ctx::with_buffer(&mut out); ThemeToggle::new().render(&mut cx).unwrap(); let html = String::from_utf8(out).unwrap(); assert!(html.contains("data-theme-toggle-button")); assert!(!html.contains(r#"data-theme-toggle ""#)); assert!(!html.contains(r#"data-theme-toggle=""#)); } #[test] fn runtime_wires_theme_persistence_and_click_dispatch() { // Free component sources may only reference the Free runtime; the // Pro runtime carries the same section via the shared generator. for js in [include_str!("../../../runtime/proa-free.js")] { assert!(js.contains("const ThemeToggle")); assert!(js.contains("[data-theme-toggle-button]")); assert!(js.contains("storageKey: 'theme'")); assert!(js.contains("localStorage.getItem(this.storageKey)")); assert!(js.contains("localStorage.setItem(this.storageKey")); assert!(js.contains("prefers-color-scheme: dark")); assert!(js.contains("classList.toggle('dark'")); assert!(js.contains("ThemeToggle.handleClick(target)")); assert!(js.contains("initializeOnReady(() => ThemeToggle.init())")); } } }