1use dioxus::prelude::*;
2
3#[derive(Clone, Debug, PartialEq, Default)]
16pub struct BootstrapTheme {
17 pub colors: ThemeColors,
18 pub surfaces: SurfaceColors,
19 pub dark: Option<ThemeModeTokens>,
20}
21
22#[derive(Clone, Debug, PartialEq, Default)]
24pub struct ThemeModeTokens {
25 pub colors: ThemeColors,
26 pub surfaces: SurfaceColors,
27}
28
29#[derive(Clone, Debug, PartialEq, Default)]
31pub struct ThemeColors {
32 pub primary: Option<SemanticColorScale>,
33 pub secondary: Option<SemanticColorScale>,
34 pub success: Option<SemanticColorScale>,
35 pub info: Option<SemanticColorScale>,
36 pub warning: Option<SemanticColorScale>,
37 pub danger: Option<SemanticColorScale>,
38 pub light: Option<SemanticColorScale>,
39 pub dark: Option<SemanticColorScale>,
40}
41
42#[derive(Clone, Debug, PartialEq, Default)]
44pub struct SurfaceColors {
45 pub body_bg: Option<String>,
46 pub body_color: Option<String>,
47 pub secondary_bg: Option<String>,
48 pub secondary_color: Option<String>,
49 pub tertiary_bg: Option<String>,
50 pub tertiary_color: Option<String>,
51 pub border_color: Option<String>,
52 pub link_color: Option<String>,
53 pub link_hover_color: Option<String>,
54}
55
56#[derive(Clone, Debug, PartialEq)]
58pub struct SemanticColorScale {
59 pub base: String,
60 pub rgb: Option<(u8, u8, u8)>,
61 pub text_emphasis: Option<String>,
62 pub bg_subtle: Option<String>,
63 pub border_subtle: Option<String>,
64}
65
66impl SemanticColorScale {
67 pub fn new(base: impl Into<String>) -> Self {
73 Self {
74 base: base.into(),
75 rgb: None,
76 text_emphasis: None,
77 bg_subtle: None,
78 border_subtle: None,
79 }
80 }
81
82 pub fn with_rgb(mut self, rgb: (u8, u8, u8)) -> Self {
84 self.rgb = Some(rgb);
85 self
86 }
87
88 pub fn with_text_emphasis(mut self, color: impl Into<String>) -> Self {
90 self.text_emphasis = Some(color.into());
91 self
92 }
93
94 pub fn with_bg_subtle(mut self, color: impl Into<String>) -> Self {
96 self.bg_subtle = Some(color.into());
97 self
98 }
99
100 pub fn with_border_subtle(mut self, color: impl Into<String>) -> Self {
102 self.border_subtle = Some(color.into());
103 self
104 }
105}
106
107impl From<&str> for SemanticColorScale {
108 fn from(value: &str) -> Self {
109 Self::new(value)
110 }
111}
112
113#[derive(Clone, Copy)]
114enum ThemeVariant {
115 Light,
116 Dark,
117}
118
119#[derive(Clone, PartialEq, Props)]
126pub struct BootstrapThemeProviderProps {
127 pub theme: BootstrapTheme,
128}
129
130#[component]
131pub fn BootstrapThemeProvider(props: BootstrapThemeProviderProps) -> Element {
132 let css = build_theme_css(&props.theme);
133
134 if css.is_empty() {
135 return rsx! {};
136 }
137
138 rsx! {
139 style { "{css}" }
140 }
141}
142
143fn build_theme_css(theme: &BootstrapTheme) -> String {
144 let mut out = String::new();
145
146 push_mode_css(
147 &mut out,
148 ":root",
149 &theme.colors,
150 &theme.surfaces,
151 ThemeVariant::Light,
152 );
153
154 if let Some(dark) = &theme.dark {
155 push_mode_css(
156 &mut out,
157 r#"[data-bs-theme="dark"]"#,
158 &dark.colors,
159 &dark.surfaces,
160 ThemeVariant::Dark,
161 );
162 }
163
164 out
165}
166
167fn push_mode_css(
168 out: &mut String,
169 selector: &str,
170 colors: &ThemeColors,
171 surfaces: &SurfaceColors,
172 variant: ThemeVariant,
173) {
174 let mut block = String::new();
175
176 for (name, scale) in [
177 ("primary", colors.primary.as_ref()),
178 ("secondary", colors.secondary.as_ref()),
179 ("success", colors.success.as_ref()),
180 ("info", colors.info.as_ref()),
181 ("warning", colors.warning.as_ref()),
182 ("danger", colors.danger.as_ref()),
183 ("light", colors.light.as_ref()),
184 ("dark", colors.dark.as_ref()),
185 ] {
186 push_color_scale_css(&mut block, name, scale, variant);
187 }
188
189 for (name, value) in [
190 ("--bs-body-bg", surfaces.body_bg.as_deref()),
191 ("--bs-body-color", surfaces.body_color.as_deref()),
192 ("--bs-secondary-bg", surfaces.secondary_bg.as_deref()),
193 ("--bs-secondary-color", surfaces.secondary_color.as_deref()),
194 ("--bs-tertiary-bg", surfaces.tertiary_bg.as_deref()),
195 ("--bs-tertiary-color", surfaces.tertiary_color.as_deref()),
196 ("--bs-border-color", surfaces.border_color.as_deref()),
197 ("--bs-link-color", surfaces.link_color.as_deref()),
198 (
199 "--bs-link-hover-color",
200 surfaces.link_hover_color.as_deref(),
201 ),
202 ] {
203 push_var(&mut block, name, value);
204 }
205
206 if block.is_empty() {
207 return;
208 }
209
210 out.push_str(selector);
211 out.push_str(" {\n");
212 out.push_str(&block);
213 out.push_str("}\n");
214}
215
216fn push_color_scale_css(
217 out: &mut String,
218 name: &str,
219 scale: Option<&SemanticColorScale>,
220 variant: ThemeVariant,
221) {
222 let Some(scale) = scale else {
223 return;
224 };
225
226 push_var(out, &format!("--bs-{name}"), Some(scale.base.as_str()));
227
228 let parsed_rgb = parse_hex_color(&scale.base);
229 let rgb = scale.rgb.or(parsed_rgb);
230 let derived = parsed_rgb.map(|rgb| derive_scale(rgb, variant));
231
232 if let Some((red, green, blue)) = rgb {
233 let rgb_value = format!("{red}, {green}, {blue}");
234 push_var(out, &format!("--bs-{name}-rgb"), Some(&rgb_value));
235 }
236
237 let text_emphasis = scale.text_emphasis.as_deref().or_else(|| {
238 derived
239 .as_ref()
240 .map(|derived| derived.text_emphasis.as_str())
241 });
242 let bg_subtle = scale
243 .bg_subtle
244 .as_deref()
245 .or_else(|| derived.as_ref().map(|derived| derived.bg_subtle.as_str()));
246 let border_subtle = scale.border_subtle.as_deref().or_else(|| {
247 derived
248 .as_ref()
249 .map(|derived| derived.border_subtle.as_str())
250 });
251
252 push_var(out, &format!("--bs-{name}-text-emphasis"), text_emphasis);
253 push_var(out, &format!("--bs-{name}-bg-subtle"), bg_subtle);
254 push_var(out, &format!("--bs-{name}-border-subtle"), border_subtle);
255}
256
257fn push_var(out: &mut String, name: &str, value: Option<&str>) {
258 let Some(value) = value else {
259 return;
260 };
261
262 out.push_str(" ");
263 out.push_str(name);
264 out.push_str(": ");
265 out.push_str(value);
266 out.push_str(";\n");
267}
268
269#[derive(Clone)]
270struct DerivedScale {
271 text_emphasis: String,
272 bg_subtle: String,
273 border_subtle: String,
274}
275
276fn derive_scale((red, green, blue): (u8, u8, u8), variant: ThemeVariant) -> DerivedScale {
277 let base = (red, green, blue);
278 const BLACK: (u8, u8, u8) = (0, 0, 0);
279 const WHITE: (u8, u8, u8) = (255, 255, 255);
280
281 let (text_emphasis, bg_subtle, border_subtle) = match variant {
284 ThemeVariant::Light => (
285 mix_rgb(base, BLACK, 0.60), mix_rgb(base, WHITE, 0.80), mix_rgb(base, WHITE, 0.60), ),
289 ThemeVariant::Dark => (
290 mix_rgb(base, WHITE, 0.40), mix_rgb(base, BLACK, 0.80), mix_rgb(base, BLACK, 0.40), ),
294 };
295
296 DerivedScale {
297 text_emphasis: rgb_to_hex(text_emphasis),
298 bg_subtle: rgb_to_hex(bg_subtle),
299 border_subtle: rgb_to_hex(border_subtle),
300 }
301}
302
303fn parse_hex_color(value: &str) -> Option<(u8, u8, u8)> {
304 let hex = value.strip_prefix('#')?;
305
306 match hex.len() {
307 3 => {
308 let red = parse_hex_byte(&hex[0..1].repeat(2))?;
309 let green = parse_hex_byte(&hex[1..2].repeat(2))?;
310 let blue = parse_hex_byte(&hex[2..3].repeat(2))?;
311 Some((red, green, blue))
312 }
313 6 => Some((
314 parse_hex_byte(&hex[0..2])?,
315 parse_hex_byte(&hex[2..4])?,
316 parse_hex_byte(&hex[4..6])?,
317 )),
318 _ => None,
319 }
320}
321
322fn parse_hex_byte(value: &str) -> Option<u8> {
323 u8::from_str_radix(value, 16).ok()
324}
325
326fn mix_rgb(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
327 (
328 mix_channel(from.0, to.0, amount),
329 mix_channel(from.1, to.1, amount),
330 mix_channel(from.2, to.2, amount),
331 )
332}
333
334fn mix_channel(from: u8, to: u8, amount: f32) -> u8 {
335 let blended = (from as f32 * (1.0 - amount)) + (to as f32 * amount);
336 blended.round().clamp(0.0, 255.0) as u8
337}
338
339fn rgb_to_hex((red, green, blue): (u8, u8, u8)) -> String {
340 format!("#{red:02x}{green:02x}{blue:02x}")
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn parses_short_and_long_hex_colors() {
349 assert_eq!(parse_hex_color("#165317"), Some((22, 83, 23)));
350 assert_eq!(parse_hex_color("#abc"), Some((170, 187, 204)));
351 assert_eq!(parse_hex_color("165317"), None);
352 assert_eq!(parse_hex_color("#abcd"), None);
353 }
354
355 #[test]
356 fn generates_light_and_dark_theme_css() {
357 let theme = BootstrapTheme {
358 colors: ThemeColors {
359 primary: Some(SemanticColorScale::new("#165317")),
360 dark: Some(SemanticColorScale::new("#092817")),
361 ..ThemeColors::default()
362 },
363 surfaces: SurfaceColors {
364 body_bg: Some("#f7fbf7".into()),
365 body_color: Some("#1d2b1f".into()),
366 ..SurfaceColors::default()
367 },
368 dark: Some(ThemeModeTokens {
369 colors: ThemeColors {
370 primary: Some(SemanticColorScale::new("#4e9f53")),
371 ..ThemeColors::default()
372 },
373 surfaces: SurfaceColors {
374 body_bg: Some("#0b120c".into()),
375 body_color: Some("#e6efe7".into()),
376 ..SurfaceColors::default()
377 },
378 }),
379 };
380
381 let css = build_theme_css(&theme);
382
383 assert!(css.contains(":root {\n"));
384 assert!(css.contains(r#"[data-bs-theme="dark"] {"#));
385 assert!(css.contains(" --bs-primary: #165317;"));
386 assert!(css.contains(" --bs-primary-rgb: 22, 83, 23;"));
387 assert!(css.contains(" --bs-dark: #092817;"));
388 assert!(css.contains(" --bs-body-bg: #f7fbf7;"));
389 assert!(css.contains(" --bs-body-color: #1d2b1f;"));
390 assert!(css.contains(" --bs-primary: #4e9f53;"));
391 assert!(css.contains(" --bs-primary-rgb: 78, 159, 83;"));
392 assert!(css.contains(" --bs-body-bg: #0b120c;"));
393 assert!(css.contains(" --bs-body-color: #e6efe7;"));
394 }
395
396 #[test]
397 fn preserves_raw_values_and_explicit_overrides() {
398 let theme = BootstrapTheme {
399 colors: ThemeColors {
400 primary: Some(SemanticColorScale {
401 base: "var(--brand-primary)".into(),
402 rgb: None,
403 text_emphasis: Some("#112233".into()),
404 bg_subtle: None,
405 border_subtle: Some("#ddeeff".into()),
406 }),
407 ..ThemeColors::default()
408 },
409 ..BootstrapTheme::default()
410 };
411
412 let css = build_theme_css(&theme);
413
414 assert!(css.contains(" --bs-primary: var(--brand-primary);"));
415 assert!(css.contains(" --bs-primary-text-emphasis: #112233;"));
416 assert!(css.contains(" --bs-primary-border-subtle: #ddeeff;"));
417 assert!(!css.contains("--bs-primary-rgb"));
418 assert!(!css.contains("--bs-primary-bg-subtle"));
419 }
420
421 #[test]
422 fn builder_sets_optional_tokens() {
423 let scale = SemanticColorScale::new("#0d6efd")
424 .with_rgb((13, 110, 253))
425 .with_text_emphasis("#084298")
426 .with_bg_subtle("#cfe2ff")
427 .with_border_subtle("#9ec5fe");
428 assert_eq!(scale.rgb, Some((13, 110, 253)));
429 assert_eq!(scale.text_emphasis.as_deref(), Some("#084298"));
430 assert_eq!(scale.bg_subtle.as_deref(), Some("#cfe2ff"));
431 assert_eq!(scale.border_subtle.as_deref(), Some("#9ec5fe"));
432 }
433
434 #[test]
435 fn derived_tokens_match_bootstrap_light() {
436 let css = build_theme_css(&BootstrapTheme {
438 colors: ThemeColors {
439 primary: Some(SemanticColorScale::new("#0d6efd")),
440 ..Default::default()
441 },
442 ..Default::default()
443 });
444 assert!(
445 css.contains("--bs-primary-text-emphasis: #052c65;"),
446 "{css}"
447 );
448 assert!(css.contains("--bs-primary-bg-subtle: #cfe2ff;"), "{css}");
449 assert!(
450 css.contains("--bs-primary-border-subtle: #9ec5fe;"),
451 "{css}"
452 );
453 }
454}