use crate::{
core::{
assets,
attrs::{
component_attributes, theme_attributes, theme_pairs, FcData, Kind, ThemePickerData,
},
controlled::use_controllable,
platform::{DomBridge, MediaPreference},
state::Lifecycle,
},
prelude::*,
theme::use_stylesheets,
};
const BEM: Bem = Bem::new("fc-theme-picker");
const CHECK: Bem = Bem::new("fc-check");
const INPUT: Bem = Bem::new("fc-input");
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum Scheme {
#[default]
System,
Light,
Dark,
}
impl Scheme {
pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::Light => "light",
Self::Dark => "dark",
}
}
const fn label(self) -> &'static str {
match self {
Self::System => "Follow system",
Self::Light => "Light",
Self::Dark => "Dark",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum Contrast {
#[default]
System,
High,
}
impl Contrast {
pub const ALL: [Self; 2] = [Self::System, Self::High];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::High => "high",
}
}
const fn label(self) -> &'static str {
match self {
Self::System => "Follow system",
Self::High => "High contrast",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ThemeChoice {
pub design: Design,
pub palette: Palette,
pub scheme: Scheme,
pub contrast: Contrast,
}
impl Default for ThemeChoice {
fn default() -> Self {
Self::from_design(Design::default())
}
}
impl ThemeChoice {
#[must_use]
pub const fn from_design(design: Design) -> Self {
Self {
design,
palette: design.palette(),
scheme: Scheme::System,
contrast: Contrast::System,
}
}
#[must_use]
pub const fn theme(self, prefers_dark: bool) -> Theme {
let dark = match self.scheme {
Scheme::System => prefers_dark,
Scheme::Light => false,
Scheme::Dark => true,
};
match (self.contrast, self.scheme, dark) {
(Contrast::High, _, true) => Theme::HighContrastDark,
(Contrast::High, _, false) => Theme::HighContrastLight,
(Contrast::System, Scheme::System, _) => Theme::FollowSystem,
(Contrast::System, _, true) => Theme::Dark,
(Contrast::System, _, false) => Theme::Light,
}
}
}
#[component]
pub fn ThemePicker(
#[props(default)] value: Option<ReadSignal<ThemeChoice>>,
#[props(default)] default_value: ThemeChoice,
#[props(default)] onchange: Option<EventHandler<ThemeChoice>>,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let state = use_controllable(value, || default_value, onchange);
let group = use_id();
let dark = use_signal(|| false);
let forced = use_signal(|| false);
let more = use_signal(|| false);
use_hook(move || {
for (preference, mut target) in [
(MediaPreference::DarkScheme, dark),
(MediaPreference::MoreContrast, more),
(MediaPreference::ForcedColors, forced),
] {
spawn(async move {
let mut watch = DomBridge.watch(preference);
while let Some(on) = watch.next().await {
target.set(on);
}
});
}
});
let choice = state.get();
let high = choice.contrast == Contrast::High || more() || forced();
let shown = if dark() && choice.scheme == Scheme::System || choice.scheme == Scheme::Dark {
"dark"
} else {
"light"
};
let status = format!(
"{} · {shown}{}",
choice.design.name(),
if forced() {
" · forced colors"
} else if high {
" · high contrast"
} else {
""
}
);
let preview_theme = match (high, shown) {
(true, "dark") => "high-contrast-dark",
(true, _) => "high-contrast-light",
(false, scheme) => scheme,
};
let mut attributes = component_attributes(
attributes,
ClassList::new(BEM.block()).extend(class.as_deref()),
&[],
);
attributes.extend(
FcData {
kind: Some(Kind::ThemePicker),
state: Some(Lifecycle::Complete),
..FcData::default()
}
.attributes(),
);
attributes.extend([
ThemePickerData::Design.attribute(choice.design.as_str()),
ThemePickerData::Palette.attribute(choice.palette.as_str()),
ThemePickerData::Scheme.attribute(choice.scheme.as_str()),
ThemePickerData::Contrast.attribute(choice.contrast.as_str()),
]);
let group = group.to_string();
rsx! {
if use_stylesheets() {
document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS }
document::Stylesheet { href: assets::COMPONENTS_FIELD_CSS }
document::Stylesheet { href: assets::COMPONENTS_THEME_PICKER_CSS }
}
div { ..attributes,
fieldset { class: BEM.element("themes"),
legend { class: BEM.element("legend"), "Design theme" }
for design in Design::ALL.iter().copied() {
label { key: "{design.as_str()}", class: BEM.element("option"),
..theme_attributes(&theme_pairs(Some(preview_theme), None, Some(design.as_str()), Some(design.palette().as_str()))),
input { r#type: "radio", class: CHECK.element("input"), name: "{group}", value: design.as_str(),
checked: choice.design == design,
onchange: move |_| state.set(ThemeChoice { design, palette: design.palette(), ..state.get() }),
}
span {
span { class: BEM.element("preview"), aria_hidden: "true",
span { class: BEM.element("window"),
span { class: BEM.element("window-bar"), "Workspace" }
span { class: BEM.element("window-body"),
span { class: BEM.element("window-nav"), span { "Home" } span { "Projects" } }
span { class: BEM.element("window-content"),
span { class: BEM.element("window-feature"), "Your next idea" }
span { class: BEM.element("preview-card"), "Ready to create" }
span { class: BEM.element("preview-action"), "Continue" }
}
}
}
}
strong { class: BEM.element("name"), "{design.name()}" }
span { class: BEM.element("description"), "{design.description()}" }
}
}
}
}
div { class: BEM.element("controls"),
label { class: BEM.element("field"), "Color palette"
select { class: INPUT.block(), value: choice.palette.as_str(),
onchange: move |event| if let Some(palette) = Palette::from_attribute(&event.value()) {
state.set(ThemeChoice { palette, ..state.get() });
},
for palette in Palette::ALL.iter().copied() {
option { key: "{palette.as_str()}", value: palette.as_str(), selected: palette == choice.palette, "{palette.name()}" }
}
}
}
label { class: BEM.element("field"), "Color scheme"
select { class: INPUT.block(), value: choice.scheme.as_str(),
onchange: move |event| if let Some(scheme) = Scheme::ALL.into_iter().find(|s| s.as_str() == event.value()) {
state.set(ThemeChoice { scheme, ..state.get() });
},
for scheme in Scheme::ALL {
option { key: "{scheme.as_str()}", value: scheme.as_str(), selected: scheme == choice.scheme, "{scheme.label()}" }
}
}
}
label { class: BEM.element("field"), "Contrast"
select { class: INPUT.block(), value: choice.contrast.as_str(),
onchange: move |event| if let Some(contrast) = Contrast::ALL.into_iter().find(|c| c.as_str() == event.value()) {
state.set(ThemeChoice { contrast, ..state.get() });
},
for contrast in Contrast::ALL {
option { key: "{contrast.as_str()}", value: contrast.as_str(), selected: contrast == choice.contrast, "{contrast.label()}" }
}
}
}
}
p { class: BEM.element("status"), role: "status", "{status}" }
}
}
}