atproto blogging
1//! Hex colour input with preview swatch.
2
3use dioxus::prelude::*;
4
5/// Props for HexColourInput.
6#[derive(Props, Clone, PartialEq)]
7pub struct HexColourInputProps {
8 /// Current hex value (without #).
9 pub value: String,
10 /// Callback when value changes.
11 pub onchange: EventHandler<String>,
12 /// Label for the input.
13 #[props(default)]
14 pub label: Option<String>,
15 /// Placeholder text.
16 #[props(default = "000000".to_string())]
17 pub placeholder: String,
18 /// Callback when input receives focus.
19 #[props(default)]
20 pub onfocus: Option<EventHandler<FocusEvent>>,
21}
22
23/// A hex colour input with a colour preview swatch.
24#[component]
25pub fn HexColourInput(props: HexColourInputProps) -> Element {
26 // Normalise the value for display (expand 3-char to 6-char).
27 let display_value = normalise_hex(&props.value);
28
29 // Check if we have a valid hex to display in the swatch.
30 let swatch_colour = if is_valid_hex(&display_value) {
31 format!("#{display_value}")
32 } else {
33 // Grey for invalid hex.
34 "var(--color-muted)".to_string()
35 };
36
37 rsx! {
38 document::Link { rel: "stylesheet", href: asset!("/assets/styling/hex-colour-input.css") }
39
40 div { class: "hex-colour-input",
41 if let Some(label) = &props.label {
42 label { class: "hex-colour-input-label", "{label}" }
43 }
44
45 div { class: "hex-colour-input-field",
46 div {
47 class: "hex-colour-input-swatch",
48 style: "background-color: {swatch_colour}",
49 }
50 span { class: "hex-colour-input-hash", "#" }
51 input {
52 class: "hex-colour-input-text",
53 r#type: "text",
54 maxlength: "6",
55 placeholder: props.placeholder.clone(),
56 value: display_value.clone(),
57 oninput: move |e| {
58 // Filter to hex chars only and uppercase.
59 let filtered: String = e
60 .value()
61 .chars()
62 .filter(|c| c.is_ascii_hexdigit())
63 .take(6)
64 .collect::<String>()
65 .to_uppercase();
66 props.onchange.call(filtered);
67 },
68 onfocus: move |e| {
69 if let Some(handler) = &props.onfocus {
70 handler.call(e);
71 }
72 },
73 }
74 }
75 }
76 }
77}
78
79/// Normalise 3-char hex to 6-char (e.g., "ABC" -> "AABBCC").
80fn normalise_hex(hex: &str) -> String {
81 let hex = hex.to_uppercase();
82 if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
83 hex.chars().flat_map(|c| [c, c]).collect()
84 } else {
85 hex
86 }
87}
88
89/// Check if valid 6-digit hex.
90fn is_valid_hex(hex: &str) -> bool {
91 hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit())
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn test_normalise_hex_3_char() {
100 assert_eq!(normalise_hex("ABC"), "AABBCC");
101 assert_eq!(normalise_hex("abc"), "AABBCC");
102 assert_eq!(normalise_hex("123"), "112233");
103 }
104
105 #[test]
106 fn test_normalise_hex_6_char() {
107 assert_eq!(normalise_hex("AABBCC"), "AABBCC");
108 assert_eq!(normalise_hex("aabbcc"), "AABBCC");
109 }
110
111 #[test]
112 fn test_normalise_hex_invalid() {
113 // Invalid 3-char (not all hex) stays as-is.
114 assert_eq!(normalise_hex("GHI"), "GHI");
115 // Other lengths stay as-is.
116 assert_eq!(normalise_hex("AB"), "AB");
117 assert_eq!(normalise_hex("ABCDE"), "ABCDE");
118 }
119
120 #[test]
121 fn test_is_valid_hex() {
122 assert!(is_valid_hex("AABBCC"));
123 assert!(is_valid_hex("123456"));
124 assert!(!is_valid_hex("ABC")); // Too short.
125 assert!(!is_valid_hex("AABBCCDD")); // Too long.
126 assert!(!is_valid_hex("GHIJKL")); // Not hex.
127 }
128}