leptos_use/
use_color_mode.rs

1use crate::core::{IntoElementMaybeSignal, MaybeRwSignal};
2use crate::core::{StrMarker, url};
3use crate::storage::{StorageType, UseStorageOptions, use_storage_with_options};
4use crate::utils::get_header;
5use crate::{
6    SyncSignalOptions, UseCookieOptions, UsePreferredDarkOptions, sync_signal_with_options,
7    use_cookie_with_options, use_preferred_dark_with_options,
8};
9use cfg_if::cfg_if;
10use codee::string::FromToStringCodec;
11use default_struct_builder::DefaultBuilder;
12use leptos::prelude::*;
13use leptos::reactive::wrappers::read::Signal;
14use std::fmt::{Display, Formatter};
15use std::marker::PhantomData;
16use std::str::FromStr;
17use std::sync::Arc;
18
19cfg_if! { if #[cfg(not(feature = "ssr"))] {
20    use crate::core::ElementMaybeSignal;
21    use wasm_bindgen::JsCast;
22}}
23
24/// Reactive color mode (dark / light / customs) with auto data persistence.
25///
26/// ## Demo
27///
28/// [Link to Demo](https://github.com/Synphonyte/leptos-use/tree/main/examples/use_color_mode)
29///
30/// ## Usage
31///
32/// ```
33/// # use leptos::prelude::*;
34/// # use leptos_use::{use_color_mode, UseColorModeReturn};
35/// #
36/// # #[component]
37/// # fn Demo() -> impl IntoView {
38/// let UseColorModeReturn {
39///     mode, // Signal<ColorMode::dark | ColorMode::light>
40///     set_mode,
41///     ..
42/// } = use_color_mode();
43/// #
44/// # view! { }
45/// # }
46/// ```
47///
48/// By default, it will match with users' browser preference using [`fn@crate::use_preferred_dark`] (a.k.a. `ColorMode::Auto`).
49/// When reading the signal, it will by default return the current color mode (`ColorMode::Dark`, `ColorMode::Light` or
50/// your custom modes `ColorMode::Custom("some-custom")`). The `ColorMode::Auto` variant can
51/// be included in the returned modes by enabling the `emit_auto` option and using [`use_color_mode_with_options`].
52/// When writing to the signal (`set_mode`), it will trigger DOM updates and persist the color mode to local
53/// storage (or your custom storage). You can pass `ColorMode::Auto` to set back to auto mode.
54///
55/// ```
56/// # use leptos::prelude::*;
57/// # use leptos_use::{ColorMode, use_color_mode, UseColorModeReturn};
58/// #
59/// # #[component]
60/// # fn Demo() -> impl IntoView {
61/// # let UseColorModeReturn { mode, set_mode, .. } = use_color_mode();
62/// #
63/// mode.get(); // ColorMode::Dark or ColorMode::Light
64///
65/// set_mode.set(ColorMode::Dark); // change to dark mode and persist
66///
67/// set_mode.set(ColorMode::Auto); // change to auto mode
68/// #
69/// # view! { }
70/// # }
71/// ```
72///
73/// ## Options
74///
75/// ```
76/// # use leptos::prelude::*;
77/// # use leptos_use::{use_color_mode_with_options, UseColorModeOptions, UseColorModeReturn};
78/// #
79/// # #[component]
80/// # fn Demo() -> impl IntoView {
81/// let UseColorModeReturn { mode, set_mode, .. } = use_color_mode_with_options(
82///     UseColorModeOptions::default()
83///         .attribute("theme") // instead of writing to `class`
84///         .custom_modes(vec![
85///             // custom colors in addition to light/dark
86///             "dim".to_string(),
87///             "cafe".to_string(),
88///         ]),
89/// ); // Signal<ColorMode::Dark | ColorMode::Light | ColorMode::Custom("dim") | ColorMode::Custom("cafe")>
90/// #
91/// # view! { }
92/// # }
93/// ```
94///
95/// ### Cookie
96///
97/// To persist color mode in a cookie, use `use_cookie_with_options` and specify `.cookie_enabled(true)`.
98///
99/// > Note: To work with SSR you have to add the `axum` or `actix` feature as described in [`fn@crate::use_cookie`].
100///
101/// ```rust
102/// # use leptos::prelude::*;
103/// # use leptos_meta::*;
104/// # use leptos_use::{use_color_mode_with_options, UseColorModeOptions, UseColorModeReturn};
105/// #
106/// # #[component]
107/// # fn Demo() -> impl IntoView {
108/// let UseColorModeReturn { mode, set_mode, .. } = use_color_mode_with_options(
109///     UseColorModeOptions::default()
110///         .cookie_enabled(true),
111/// );
112///
113/// // This adds the color mode class to the `<html>` element even with SSR
114/// view! {
115///     <Html {..} class=move || mode.get().to_string()/>
116/// }
117/// # }
118/// ```
119///
120/// For a working example please check out the [ssr example](https://github.com/Synphonyte/leptos-use/blob/main/examples/ssr/src/app.rs).
121///
122/// ## Server-Side Rendering
123///
124/// > Make sure you follow the [instructions in Server-Side Rendering](https://leptos-use.rs/server_side_rendering.html).
125///
126/// On the server this will try to read the
127/// [`Sec-CH-Prefers-Color-Scheme` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
128/// to determine the color mode. If the header is not present it will return `ColorMode::Light`.
129/// Please have a look at the linked documentation above for that header to see browser support
130/// as well as potential server requirements.
131///
132/// > If you're using `axum` you have to enable the `"axum"` feature in your Cargo.toml.
133/// > In case it's `actix-web` enable the feature `"actix"`.
134///
135/// ### Bring your own header
136///
137/// In case you're neither using Axum nor Actix or the default implementation is not to your liking,
138/// you can provide your own way of reading the color scheme header value using the option
139/// [`crate::UseColorModeOptions::ssr_color_header_getter`].
140///
141/// ### Cookie
142///
143/// If `cookie_enabled` is set to `true`, a cookie will be used and if present this value will be used
144/// on the server as well as on the client. Please note that you have to add the `axum` or `actix`
145/// feature as described in [`fn@crate::use_cookie`].
146///
147/// ## See also
148///
149/// * [`fn@crate::use_preferred_dark`]
150/// * [`fn@crate::storage::use_storage`]
151/// * [`fn@crate::use_cookie`]
152pub fn use_color_mode() -> UseColorModeReturn {
153    use_color_mode_with_options(UseColorModeOptions::default())
154}
155
156/// Version of [`use_color_mode`] that takes a `UseColorModeOptions`. See [`use_color_mode`] for how to use.
157pub fn use_color_mode_with_options<El, M>(options: UseColorModeOptions<El, M>) -> UseColorModeReturn
158where
159    El: IntoElementMaybeSignal<web_sys::Element, M>,
160{
161    #[allow(unused_variables)]
162    let UseColorModeOptions {
163        target,
164        attribute,
165        initial_value,
166        initial_value_from_url_param,
167        initial_value_from_url_param_to_storage,
168        on_changed,
169        storage_signal,
170        custom_modes,
171        storage_key,
172        storage,
173        storage_enabled,
174        cookie_name,
175        cookie_enabled,
176        emit_auto,
177        transition_enabled,
178        listen_to_storage_changes,
179        ssr_color_header_getter,
180        _marker,
181    } = options;
182
183    let preferred_dark = use_preferred_dark_with_options(UsePreferredDarkOptions {
184        ssr_color_header_getter,
185    });
186
187    let system = Signal::derive(move || {
188        if preferred_dark.get() {
189            ColorMode::Dark
190        } else {
191            ColorMode::Light
192        }
193    });
194
195    let mut initial_value_from_url = None;
196    if let Some(param) = initial_value_from_url_param.as_ref()
197        && let Some(value) = url::params::get(param)
198    {
199        initial_value_from_url = ColorMode::from_str(&value).map(MaybeRwSignal::Static).ok()
200    }
201
202    let (store, set_store) = get_store_signal(
203        initial_value_from_url.clone().unwrap_or(initial_value),
204        storage_signal,
205        &storage_key,
206        storage_enabled,
207        storage,
208        listen_to_storage_changes,
209    );
210
211    let (cookie, set_cookie) = get_cookie_signal(&cookie_name, cookie_enabled);
212
213    if cookie_enabled {
214        let _ = sync_signal_with_options(
215            (cookie, set_cookie),
216            (store, set_store),
217            SyncSignalOptions::with_assigns(
218                move |store: &mut ColorMode, cookie: &Option<ColorMode>| {
219                    if let Some(cookie) = cookie {
220                        *store = cookie.clone();
221                    }
222                },
223                move |cookie: &mut Option<ColorMode>, store: &ColorMode| {
224                    *cookie = Some(store.clone())
225                },
226            ),
227        );
228    }
229
230    if let Some(initial_value_from_url) = initial_value_from_url {
231        let value = initial_value_from_url.into_signal().0.get_untracked();
232        if initial_value_from_url_param_to_storage {
233            set_store.set(value);
234        } else {
235            *set_store.write_untracked() = value;
236        }
237    }
238
239    let state = Signal::derive(move || {
240        let value = store.get();
241        if value == ColorMode::Auto {
242            system.get()
243        } else {
244            value
245        }
246    });
247
248    #[cfg(not(feature = "ssr"))]
249    {
250        let modes: Vec<String> = custom_modes
251            .into_iter()
252            .chain(vec![
253                ColorMode::Dark.to_string(),
254                ColorMode::Light.to_string(),
255            ])
256            .collect();
257
258        let target = target.into_element_maybe_signal();
259
260        let update_html_attrs = {
261            move |target: ElementMaybeSignal<web_sys::Element>,
262                  attribute: String,
263                  value: ColorMode| {
264                let el = target.get_untracked();
265
266                if let Some(el) = el {
267                    let mut style: Option<web_sys::HtmlStyleElement> = None;
268
269                    if !transition_enabled
270                        && let Ok(styl) = document().create_element("style")
271                        && let Some(head) = document().head()
272                    {
273                        let styl: web_sys::HtmlStyleElement = styl.unchecked_into();
274                        let style_string = "*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}";
275                        styl.set_text_content(Some(style_string));
276                        let _ = head.append_child(&styl);
277                        style = Some(styl);
278                    }
279
280                    if attribute == "class" {
281                        for mode in &modes {
282                            if &value.to_string() == mode {
283                                let _ = el.class_list().add_1(mode);
284                            } else {
285                                let _ = el.class_list().remove_1(mode);
286                            }
287                        }
288                    } else {
289                        let _ = el.set_attribute(&attribute, &value.to_string());
290                    }
291
292                    if !transition_enabled
293                        && let Some(style) = style
294                        && let Some(head) = document().head()
295                    {
296                        // Calling getComputedStyle forces the browser to redraw
297                        if let Ok(Some(style)) = window().get_computed_style(&style) {
298                            let _ = style.get_property_value("opacity");
299                        }
300
301                        let _ = head.remove_child(&style);
302                    }
303                }
304            }
305        };
306
307        let default_on_changed = move |mode: ColorMode| {
308            update_html_attrs(target, attribute.clone(), mode);
309        };
310
311        let on_changed = move |mode: ColorMode| {
312            on_changed(mode, Arc::new(default_on_changed.clone()));
313        };
314
315        Effect::new({
316            let on_changed = on_changed.clone();
317
318            move |_| {
319                on_changed.clone()(state.get());
320            }
321        });
322
323        on_cleanup(move || {
324            on_changed(state.get());
325        });
326    }
327
328    let mode = Signal::derive(move || if emit_auto { store.get() } else { state.get() });
329
330    UseColorModeReturn {
331        mode,
332        set_mode: set_store,
333        store,
334        set_store,
335        system,
336        state,
337    }
338}
339
340/// Color modes
341#[derive(Clone, Default, PartialEq, Eq, Hash, Debug)]
342pub enum ColorMode {
343    #[default]
344    Auto,
345    Light,
346    Dark,
347    Custom(String),
348}
349
350fn get_cookie_signal(
351    cookie_name: &str,
352    cookie_enabled: bool,
353) -> (Signal<Option<ColorMode>>, WriteSignal<Option<ColorMode>>) {
354    if cookie_enabled {
355        use_cookie_with_options::<ColorMode, FromToStringCodec>(
356            cookie_name,
357            UseCookieOptions::default().path("/"),
358        )
359    } else {
360        let (value, set_value) = signal(None);
361        (value.into(), set_value)
362    }
363}
364
365fn get_store_signal(
366    initial_value: MaybeRwSignal<ColorMode>,
367    storage_signal: Option<RwSignal<ColorMode>>,
368    storage_key: &str,
369    storage_enabled: bool,
370    storage: StorageType,
371    listen_to_storage_changes: bool,
372) -> (Signal<ColorMode>, WriteSignal<ColorMode>) {
373    if let Some(storage_signal) = storage_signal {
374        let (store, set_store) = storage_signal.split();
375        (store.into(), set_store)
376    } else if storage_enabled {
377        let (store, set_store, _) = use_storage_with_options::<ColorMode, FromToStringCodec>(
378            storage,
379            storage_key,
380            UseStorageOptions::default()
381                .listen_to_storage_changes(listen_to_storage_changes)
382                .initial_value(initial_value),
383        );
384        (store, set_store)
385    } else {
386        initial_value.into_signal()
387    }
388}
389
390impl Display for ColorMode {
391    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
392        use ColorMode::*;
393
394        match self {
395            Auto => write!(f, "auto"),
396            Light => write!(f, "light"),
397            Dark => write!(f, "dark"),
398            Custom(v) => write!(f, "{v}"),
399        }
400    }
401}
402
403impl From<&str> for ColorMode {
404    fn from(s: &str) -> Self {
405        match s {
406            "auto" => ColorMode::Auto,
407            "" => ColorMode::Auto,
408            "light" => ColorMode::Light,
409            "dark" => ColorMode::Dark,
410            _ => ColorMode::Custom(s.to_string()),
411        }
412    }
413}
414
415impl From<String> for ColorMode {
416    fn from(s: String) -> Self {
417        ColorMode::from(s.as_str())
418    }
419}
420
421impl FromStr for ColorMode {
422    type Err = ();
423
424    fn from_str(s: &str) -> Result<Self, Self::Err> {
425        Ok(ColorMode::from(s))
426    }
427}
428
429#[derive(DefaultBuilder)]
430pub struct UseColorModeOptions<El, M>
431where
432    El: IntoElementMaybeSignal<web_sys::Element, M>,
433{
434    /// Element that the color mode will be applied to. Defaults to `"html"`.
435    target: El,
436
437    /// HTML attribute applied to the target element. Defaults to `"class"`.
438    #[builder(into)]
439    attribute: String,
440
441    /// Initial value of the color mode. Defaults to `"Auto"`.
442    #[builder(into)]
443    initial_value: MaybeRwSignal<ColorMode>,
444
445    /// Discover the initial value of the color mode from an URL parameter. Defaults to `None`.
446    #[builder(into)]
447    initial_value_from_url_param: Option<String>,
448
449    /// Write the initial value of the discovered color mode from URL parameter to storage.
450    /// This only has an effect if `initial_value_from_url_param` is specified.
451    /// Defaults to `false`.
452    initial_value_from_url_param_to_storage: bool,
453
454    /// Custom modes that you plan to use as `ColorMode::Custom(x)`. Defaults to `vec![]`.
455    custom_modes: Vec<String>,
456
457    /// Custom handler that is called on updates.
458    /// If specified this will override the default behavior.
459    /// To get the default behaviour back you can call the provided `default_handler` function.
460    /// It takes two parameters:
461    ///     - `mode: ColorMode`: The color mode to change to.
462    ///     -`default_handler: Arc<dyn Fn(ColorMode)>`: The default handler that would have been called if the `on_changed` handler had not been specified.
463    on_changed: OnChangedFn,
464
465    /// When provided, `useStorage` will be skipped.
466    /// Defaults to `None`.
467    #[builder(into)]
468    storage_signal: Option<RwSignal<ColorMode>>,
469
470    /// Key to persist the data into localStorage/sessionStorage.
471    /// Defaults to `"leptos-use-color-scheme"`.
472    #[builder(into)]
473    storage_key: String,
474
475    /// Storage type, can be `Local` or `Session` or custom.
476    /// Defaults to `Local`.
477    storage: StorageType,
478
479    /// If the color mode should be persisted.
480    /// Defaults to `true`.
481    storage_enabled: bool,
482
483    /// Name of the cookie that should be used to persist the color mode.
484    /// Defaults to `"leptos-use-color-scheme"`.
485    #[builder(into)]
486    cookie_name: String,
487
488    /// If the color mode should be persisted through a cookie.
489    /// Defaults to `false`.
490    cookie_enabled: bool,
491
492    /// Emit `auto` mode from state
493    ///
494    /// When set to `true`, preferred mode won't be translated into `light` or `dark`.
495    /// This is useful when the fact that `auto` mode was selected needs to be known.
496    ///
497    /// Defaults to `false`.
498    emit_auto: bool,
499
500    /// If transitions on color mode change are enabled. Defaults to `false`.
501    transition_enabled: bool,
502
503    /// Listen to changes to this storage key from somewhere else.
504    /// Defaults to true.
505    listen_to_storage_changes: bool,
506
507    /// Getter function to return the string value of the
508    /// [`Sec-CH-Prefers-Color-Scheme`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme)
509    /// header.
510    /// When you use one of the features `"axum"` or `"actix"` there's a valid default
511    /// implementation provided.
512    #[allow(dead_code)]
513    ssr_color_header_getter: Arc<dyn Fn() -> Option<String> + Send + Sync>,
514
515    #[builder(skip)]
516    _marker: PhantomData<M>,
517}
518
519type OnChangedFn = Arc<dyn Fn(ColorMode, Arc<dyn Fn(ColorMode) + Send + Sync>) + Send + Sync>;
520
521impl Default for UseColorModeOptions<&'static str, StrMarker> {
522    fn default() -> Self {
523        Self {
524            target: "html",
525            attribute: "class".into(),
526            initial_value: ColorMode::Auto.into(),
527            initial_value_from_url_param: None,
528            initial_value_from_url_param_to_storage: false,
529            custom_modes: vec![],
530            on_changed: Arc::new(move |mode, default_handler| (default_handler)(mode)),
531            storage_signal: None,
532            storage_key: "leptos-use-color-scheme".into(),
533            storage: StorageType::default(),
534            storage_enabled: true,
535            cookie_name: "leptos-use-color-scheme".into(),
536            cookie_enabled: false,
537            emit_auto: false,
538            transition_enabled: false,
539            listen_to_storage_changes: true,
540            ssr_color_header_getter: Arc::new(move || {
541                get_header!(
542                    HeaderName::from_static("sec-ch-prefers-color-scheme"),
543                    use_color_mode,
544                    ssr_color_header_getter
545                )
546            }),
547            _marker: PhantomData,
548        }
549    }
550}
551
552/// Return type of [`use_color_mode`]
553pub struct UseColorModeReturn {
554    /// Main value signal of the color mode
555    pub mode: Signal<ColorMode>,
556    /// Main value setter signal of the color mode
557    pub set_mode: WriteSignal<ColorMode>,
558
559    /// Direct access to the returned signal of [`fn@crate::storage::use_storage`] if enabled or [`UseColorModeOptions::storage_signal`] if provided
560    pub store: Signal<ColorMode>,
561    /// Direct write access to the returned signal of [`fn@crate::storage::use_storage`] if enabled or [`UseColorModeOptions::storage_signal`] if provided
562    pub set_store: WriteSignal<ColorMode>,
563
564    /// Signal of the system's preferred color mode that you would get from a media query
565    pub system: Signal<ColorMode>,
566
567    /// When [`UseColorModeOptions::emit_auto`] is `false` this is the same as `mode`. This will never report `ColorMode::Auto` but always on of the other modes.
568    pub state: Signal<ColorMode>,
569}