1use crate::{
9 core::{
10 assets,
11 attrs::{
12 component_attributes, theme_attributes, theme_pairs, FcData, Kind, ThemePickerData,
13 },
14 controlled::use_controllable,
15 platform::{DomBridge, MediaPreference},
16 state::Lifecycle,
17 },
18 prelude::*,
19 theme::use_stylesheets,
20};
21const BEM: Bem = Bem::new("fc-theme-picker");
22const CHECK: Bem = Bem::new("fc-check");
23const INPUT: Bem = Bem::new("fc-input");
24
25#[non_exhaustive]
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
28pub enum Scheme {
29 #[default]
31 System,
32 Light,
34 Dark,
36}
37impl Scheme {
38 pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
40 #[must_use]
42 pub const fn as_str(self) -> &'static str {
43 match self {
44 Self::System => "system",
45 Self::Light => "light",
46 Self::Dark => "dark",
47 }
48 }
49 const fn label(self) -> &'static str {
50 match self {
51 Self::System => "Follow system",
52 Self::Light => "Light",
53 Self::Dark => "Dark",
54 }
55 }
56}
57
58#[non_exhaustive]
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
61pub enum Contrast {
62 #[default]
64 System,
65 High,
67}
68impl Contrast {
69 pub const ALL: [Self; 2] = [Self::System, Self::High];
71 #[must_use]
73 pub const fn as_str(self) -> &'static str {
74 match self {
75 Self::System => "system",
76 Self::High => "high",
77 }
78 }
79 const fn label(self) -> &'static str {
80 match self {
81 Self::System => "Follow system",
82 Self::High => "High contrast",
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct ThemeChoice {
90 pub design: Design,
92 pub palette: Palette,
94 pub scheme: Scheme,
96 pub contrast: Contrast,
98}
99impl Default for ThemeChoice {
100 fn default() -> Self {
101 Self::from_design(Design::default())
102 }
103}
104impl ThemeChoice {
105 #[must_use]
107 pub const fn from_design(design: Design) -> Self {
108 Self {
109 design,
110 palette: design.palette(),
111 scheme: Scheme::System,
112 contrast: Contrast::System,
113 }
114 }
115 #[must_use]
128 pub const fn theme(self, prefers_dark: bool) -> Theme {
129 let dark = match self.scheme {
130 Scheme::System => prefers_dark,
131 Scheme::Light => false,
132 Scheme::Dark => true,
133 };
134 match (self.contrast, self.scheme, dark) {
135 (Contrast::High, _, true) => Theme::HighContrastDark,
136 (Contrast::High, _, false) => Theme::HighContrastLight,
137 (Contrast::System, Scheme::System, _) => Theme::FollowSystem,
139 (Contrast::System, _, true) => Theme::Dark,
140 (Contrast::System, _, false) => Theme::Light,
141 }
142 }
143}
144
145#[component]
148pub fn ThemePicker(
149 #[props(default)] value: Option<ReadSignal<ThemeChoice>>,
150 #[props(default)] default_value: ThemeChoice,
151 #[props(default)] onchange: Option<EventHandler<ThemeChoice>>,
152 #[props(default)] class: Option<String>,
153 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
154) -> Element {
155 let state = use_controllable(value, || default_value, onchange);
156 let group = use_id();
157 let dark = use_signal(|| false);
160 let forced = use_signal(|| false);
161 let more = use_signal(|| false);
162 use_hook(move || {
164 for (preference, mut target) in [
165 (MediaPreference::DarkScheme, dark),
166 (MediaPreference::MoreContrast, more),
167 (MediaPreference::ForcedColors, forced),
168 ] {
169 spawn(async move {
170 let mut watch = DomBridge.watch(preference);
171 while let Some(on) = watch.next().await {
172 target.set(on);
173 }
174 });
175 }
176 });
177 let choice = state.get();
178 let high = choice.contrast == Contrast::High || more() || forced();
179 let shown = if dark() && choice.scheme == Scheme::System || choice.scheme == Scheme::Dark {
180 "dark"
181 } else {
182 "light"
183 };
184 let status = format!(
185 "{} · {shown}{}",
186 choice.design.name(),
187 if forced() {
188 " · forced colors"
189 } else if high {
190 " · high contrast"
191 } else {
192 ""
193 }
194 );
195 let preview_theme = match (high, shown) {
196 (true, "dark") => "high-contrast-dark",
197 (true, _) => "high-contrast-light",
198 (false, scheme) => scheme,
199 };
200
201 let mut attributes = component_attributes(
202 attributes,
203 ClassList::new(BEM.block()).extend(class.as_deref()),
204 &[],
205 );
206 attributes.extend(
207 FcData {
208 kind: Some(Kind::ThemePicker),
209 state: Some(Lifecycle::Complete),
210 ..FcData::default()
211 }
212 .attributes(),
213 );
214 attributes.extend([
215 ThemePickerData::Design.attribute(choice.design.as_str()),
216 ThemePickerData::Palette.attribute(choice.palette.as_str()),
217 ThemePickerData::Scheme.attribute(choice.scheme.as_str()),
218 ThemePickerData::Contrast.attribute(choice.contrast.as_str()),
219 ]);
220 let group = group.to_string();
221
222 rsx! {
223 if use_stylesheets() {
224 document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS }
225 document::Stylesheet { href: assets::COMPONENTS_FIELD_CSS }
226 document::Stylesheet { href: assets::COMPONENTS_THEME_PICKER_CSS }
227 }
228 div { ..attributes,
229 fieldset { class: BEM.element("themes"),
230 legend { class: BEM.element("legend"), "Design theme" }
231 for design in Design::ALL.iter().copied() {
232 label { key: "{design.as_str()}", class: BEM.element("option"),
235 ..theme_attributes(&theme_pairs(Some(preview_theme), None, Some(design.as_str()), Some(design.palette().as_str()))),
236 input { r#type: "radio", class: CHECK.element("input"), name: "{group}", value: design.as_str(),
237 checked: choice.design == design,
238 onchange: move |_| state.set(ThemeChoice { design, palette: design.palette(), ..state.get() }),
239 }
240 span {
241 span { class: BEM.element("preview"), aria_hidden: "true",
242 span { class: BEM.element("window"),
243 span { class: BEM.element("window-bar"), "Workspace" }
244 span { class: BEM.element("window-body"),
245 span { class: BEM.element("window-nav"), span { "Home" } span { "Projects" } }
246 span { class: BEM.element("window-content"),
247 span { class: BEM.element("window-feature"), "Your next idea" }
248 span { class: BEM.element("preview-card"), "Ready to create" }
249 span { class: BEM.element("preview-action"), "Continue" }
250 }
251 }
252 }
253 }
254 strong { class: BEM.element("name"), "{design.name()}" }
255 span { class: BEM.element("description"), "{design.description()}" }
256 }
257 }
258 }
259 }
260 div { class: BEM.element("controls"),
261 label { class: BEM.element("field"), "Color palette"
262 select { class: INPUT.block(), value: choice.palette.as_str(),
263 onchange: move |event| if let Some(palette) = Palette::from_attribute(&event.value()) {
264 state.set(ThemeChoice { palette, ..state.get() });
265 },
266 for palette in Palette::ALL.iter().copied() {
267 option { key: "{palette.as_str()}", value: palette.as_str(), selected: palette == choice.palette, "{palette.name()}" }
268 }
269 }
270 }
271 label { class: BEM.element("field"), "Color scheme"
272 select { class: INPUT.block(), value: choice.scheme.as_str(),
273 onchange: move |event| if let Some(scheme) = Scheme::ALL.into_iter().find(|s| s.as_str() == event.value()) {
274 state.set(ThemeChoice { scheme, ..state.get() });
275 },
276 for scheme in Scheme::ALL {
277 option { key: "{scheme.as_str()}", value: scheme.as_str(), selected: scheme == choice.scheme, "{scheme.label()}" }
278 }
279 }
280 }
281 label { class: BEM.element("field"), "Contrast"
282 select { class: INPUT.block(), value: choice.contrast.as_str(),
283 onchange: move |event| if let Some(contrast) = Contrast::ALL.into_iter().find(|c| c.as_str() == event.value()) {
284 state.set(ThemeChoice { contrast, ..state.get() });
285 },
286 for contrast in Contrast::ALL {
287 option { key: "{contrast.as_str()}", value: contrast.as_str(), selected: contrast == choice.contrast, "{contrast.label()}" }
288 }
289 }
290 }
291 }
292 p { class: BEM.element("status"), role: "status", "{status}" }
293 }
294 }
295}