Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion demo/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// You should have received a copy of the Apache License along with this program.
// If not, see <http://www.apache.org/licenses/>
use leptodon::button::{Button, ButtonAppearance};
use leptodon::darkmode::ThemeSelector;
use leptodon::darkmode::{MetaColorScheme, ThemeSelector, use_color_scheme};
use leptodon::icon;
use leptodon::navbar::NavbarEndChildren;
use leptodon::navbar::NavbarEntries;
Expand Down Expand Up @@ -90,9 +90,11 @@ pub fn RouteShell() -> impl IntoView {

#[component]
pub fn App() -> impl IntoView {
let color_scheme = use_color_scheme();
provide_meta_context();

view! {
<MetaColorScheme color_scheme />
<Router>
<Routes fallback=|| "Page not found.">
<ParentRoute path=StaticSegment("/") view=RouteShell>
Expand Down
17 changes: 16 additions & 1 deletion demo/src/demos/themeselector.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use leptodon::alert::Alert;
use leptodon::alert::AlertTheme;
use leptodon::codeblock::Codeblock;
// Leptodon
//
// Copyright (C) 2025-2026 Open Analytics NV
Expand Down Expand Up @@ -29,7 +32,19 @@ use leptos_meta::Title;
#[component]
pub fn ThemeSelectorDemo() -> impl IntoView {
view! {
<Paragraph>"Using multiple theme-selectors on the same page (as done here) does not work correctly.\nIf you need this behaviour, please open an issue."</Paragraph>
<Paragraph>"If you need mutliple theme-selectors on a single page do the following at the top of your App:"</Paragraph>
<Codeblock code=r#"
let color_scheme = use_color_scheme();

view! {
<MetaColorScheme color_scheme />
// ...
}
"#/>
<Alert theme=AlertTheme::Danger>
"Make sure to NOT put this in an SSR route-shell, the color_scheme context needs to be present both on hydrated client and server rendered side."
</Alert>

<ThemeSelector/>
}
}
Expand Down
143 changes: 96 additions & 47 deletions leptodon/src/darkmode/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
// You should have received a copy of the Apache License along with this program.
// If not, see <http://www.apache.org/licenses/>
use leptodon_proc_macros::generate_docs;
use leptos::context::provide_context;
use leptos::context::use_context;
use leptos::control_flow::Show;
use leptos::logging::debug_log;
use leptos::oco::Oco;
use leptos::prelude::AddAnyAttr;
Expand All @@ -24,6 +27,7 @@ use leptos::prelude::Get;
use leptos::prelude::Memo;
use leptos::prelude::RwSignal;
use leptos::prelude::Signal;
use leptos::reactive::wrappers::read::MaybeProp;
use leptos::server::ServerAction;
use leptos::{prelude::ServerFnError, *};
use leptos_meta::Html;
Expand All @@ -35,6 +39,67 @@ use std::str::FromStr;
use crate::radio::FormValue;
use crate::select::Select;

/// HTML meta-colorscheme context holder, used for nesting theme-selectors as <Meta> creates a new head entry on each location it's used.
#[derive(Clone)]
pub struct ColorScheme {
pub signal: RwSignal<Theme>,
}

impl ColorScheme {
fn register_updater(&self) {
let update_theme_action: ServerAction<UpdateTheme> = ServerAction::new();
let signal = self.signal.clone();
Effect::watch(
move || signal.get(),
move |theme, prev_theme, _| {
if Some(theme) == prev_theme {
return;
}
debug_log!("Updating theme from {prev_theme:?} to {theme}");
let selected_theme = theme.clone();
update_theme_action.dispatch(UpdateTheme {
new_theme: selected_theme,
});
},
false,
);
}

/// Creates and registers self
pub fn init(theme: Theme) -> Self {
let scheme = ColorScheme {
signal: RwSignal::new(theme),
};
scheme.register_updater();
scheme
}
}

#[component]
pub fn MetaColorScheme(color_scheme: ColorScheme) -> impl IntoView {
let browser_prefers_dark = use_preferred_dark();

view! {
<Meta
name="color-scheme"
content=move || match color_scheme.signal.get() {
Theme::Light => "light",
Theme::FollowSystem if browser_prefers_dark.get() => "dark light",
Theme::FollowSystem => "light dark",
Theme::Dark => "dark",
}
/>
}
}

pub fn use_color_scheme() -> ColorScheme {
let cookie_theme = initial_theme_from_cookie();
let color_scheme = ColorScheme::init(cookie_theme);
provide_context(color_scheme.clone());

color_scheme
}

#[derive(Debug, Hash, Clone, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Theme {
Light,
Expand Down Expand Up @@ -107,16 +172,16 @@ pub async fn update_theme(new_theme: Theme) -> Result<Theme, ServerFnError> {
Ok(new_theme)
}

pub fn fetch_ssr_tailwind_class() -> String {
pub fn fetch_ssr_tailwind_class(browser_prefers_dark: Signal<bool>) -> String {
let theme = initial_theme_from_cookie();
if theme == Theme::FollowSystem && !browser_prefers_darkmode().get() {
if theme == Theme::FollowSystem && !browser_prefers_dark.get() {
return "".to_string();
}
debug_log!("Final theme: {theme:?}");
// console_log(format!("Final theme: {theme:?}").as_str());
let resulting_theme = match theme {
Theme::Light => "light",
Theme::FollowSystem if browser_prefers_darkmode().get() => "dark",
Theme::FollowSystem if browser_prefers_dark.get() => "dark",
Theme::FollowSystem => "light",
Theme::Dark => "dark",
};
Expand All @@ -127,25 +192,24 @@ pub fn fetch_ssr_tailwind_class() -> String {

#[generate_docs]
#[component]
pub fn ThemeSelector() -> impl IntoView {
let update_theme_action: ServerAction<UpdateTheme> = ServerAction::new();
let cookie_theme = initial_theme_from_cookie();
let browser_prefers_dark = browser_prefers_darkmode();
// Bound to the html select.
let selected_theme = RwSignal::new(cookie_theme);
let resulting_light_dark = Memo::new(move |_| {
let theme = selected_theme.get();
// console_log(format!("Resulting DL theme: {resulting_theme:?}").as_str());
match theme {
Theme::Light => "light",
Theme::FollowSystem if browser_prefers_dark.get() => "dark light",
Theme::FollowSystem => "light dark",
Theme::Dark => "dark",
}
});
pub fn ThemeSelector(
/// Id for the <select>
#[prop(optional, into)]
id: MaybeProp<String>,
) -> impl IntoView {
let browser_prefers_dark = use_preferred_dark();

let color_scheme_ctx = use_context::<ColorScheme>();
let no_context = color_scheme_ctx.is_none();
let color_scheme = if let Some(scheme) = color_scheme_ctx.clone() {
scheme
} else {
let cookie_theme = initial_theme_from_cookie();
ColorScheme::init(cookie_theme)
};

let resulting_dark = Memo::new(move |_| {
let theme = selected_theme.get();
let theme = color_scheme.signal.get();
debug_log!("Final theme: {theme:?}");
// console_log(format!("Final theme: {theme:?}").as_str());
let resulting_theme = match theme {
Expand All @@ -155,53 +219,38 @@ pub fn ThemeSelector() -> impl IntoView {
Theme::Dark => "dark",
};
debug_log!("Resulting theme: {resulting_theme:?}");

// console_log(format!("Resulting theme: {resulting_theme:?}").as_str());
resulting_theme
});

Effect::watch(
move || selected_theme.get(),
move |theme, prev_theme, _| {
if Some(theme) == prev_theme {
return;
}
debug_log!("Updating theme from {prev_theme:?} to {theme}");
let selected_theme = theme.clone();
update_theme_action.dispatch(UpdateTheme {
new_theme: selected_theme,
});
},
false,
);

view! {
<Html {..} class=move || {
debug_log!("{:?}", resulting_dark.get());
if resulting_dark.get() == "" {
fetch_ssr_tailwind_class().to_string()
fetch_ssr_tailwind_class(browser_prefers_dark).to_string()
} else {
resulting_dark.get().to_string()
}
} />
<Meta
name="color-scheme"
content=move || resulting_light_dark.get()
/>
{
let color_scheme = color_scheme.clone();
view! {
<Show when=move || no_context>
<MetaColorScheme color_scheme=color_scheme.clone() />
</Show>
}
}
<Select<Theme>
id=id.get()
required=true
name="theme"
selected=selected_theme
selected=color_scheme.signal
options=RwSignal::new(vec![Theme::Light, Theme::Dark, Theme::FollowSystem])
/>
}
}

/// Checks whether the user's system prefers dark mode based on media queries.
/// returns None iff the browser is unavailable.
pub fn browser_prefers_darkmode() -> Signal<bool> {
use_preferred_dark()
}

#[cfg(not(feature = "ssr"))]
pub fn initial_theme_from_cookie() -> Theme {
use leptos::prelude::document;
Expand Down
9 changes: 8 additions & 1 deletion leptodon/src/select/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub const SELECT_CLASSES: &str = "bg-gray-50 border border-gray-300 text-gray-90
#[generate_docs]
#[component]
pub fn Select<T>(
/// Id for the <select>
#[prop(optional, into)]
id: MaybeProp<String>,
#[prop(optional, into)] class: MaybeProp<String>,
/// A string specifying a name for the input control.
/// This name is submitted along with the control's value when the form data is submitted.
Expand All @@ -76,6 +79,7 @@ where
move || selected.get(),
move |new, old, _| {
if Some(new) != old {
debug_log!("Should not be spammed");
some_selected.set(Some(new.clone()));
}
},
Expand All @@ -102,6 +106,7 @@ where

view! {
<MaybeSelect
id
class
name
label
Expand All @@ -116,7 +121,9 @@ where
#[generate_docs]
#[component]
pub fn MaybeSelect<T>(
#[prop(optional, into)] id: MaybeProp<String>,
/// Id for the <select>
#[prop(optional, into)]
id: MaybeProp<String>,
#[prop(optional, into)] class: MaybeProp<String>,
/// A string specifying a name for the input control.
/// This name is submitted along with the control's value when the form data is submitted.
Expand Down
14 changes: 7 additions & 7 deletions overview/end2end/flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion overview/end2end/flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# Versions <1.58 has a firefox regression for file uploads, holding of upgrading until nixpkgs-unstable updates to 1.59.
# - https://github.com/microsoft/playwright/issues/39274
nixpkgs-playwright.url = "github:NixOS/nixpkgs/145b67bd0bd4e075f981c1c2b81155d9e2982de2";
nixpkgs-playwright.url = "github:NixOS/nixpkgs/7cc8a6b08a51d3fd23b9c4fd2493e7e888e88507";
flake-utils.url = "github:numtide/flake-utils";
};

Expand Down
Loading