leptos_use/
use_color_mode.rs

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