···11+//! Hex colour input with preview swatch.
22+33+use dioxus::prelude::*;
44+55+/// Props for HexColourInput.
66+#[derive(Props, Clone, PartialEq)]
77+pub struct HexColourInputProps {
88+ /// Current hex value (without #).
99+ pub value: String,
1010+ /// Callback when value changes.
1111+ pub onchange: EventHandler<String>,
1212+ /// Label for the input.
1313+ #[props(default)]
1414+ pub label: Option<String>,
1515+ /// Placeholder text.
1616+ #[props(default = "000000".to_string())]
1717+ pub placeholder: String,
1818+}
1919+2020+/// A hex colour input with a colour preview swatch.
2121+#[component]
2222+pub fn HexColourInput(props: HexColourInputProps) -> Element {
2323+ // Normalise the value for display (expand 3-char to 6-char).
2424+ let display_value = normalise_hex(&props.value);
2525+2626+ // Check if we have a valid hex to display in the swatch.
2727+ let swatch_colour = if is_valid_hex(&display_value) {
2828+ format!("#{display_value}")
2929+ } else {
3030+ // Grey for invalid hex.
3131+ "var(--color-muted)".to_string()
3232+ };
3333+3434+ rsx! {
3535+ document::Link { rel: "stylesheet", href: asset!("/assets/styling/hex-colour-input.css") }
3636+3737+ div { class: "hex-colour-input",
3838+ if let Some(label) = &props.label {
3939+ label { class: "hex-colour-input-label", "{label}" }
4040+ }
4141+4242+ div { class: "hex-colour-input-field",
4343+ div {
4444+ class: "hex-colour-input-swatch",
4545+ style: "background-color: {swatch_colour}",
4646+ }
4747+ span { class: "hex-colour-input-hash", "#" }
4848+ input {
4949+ class: "hex-colour-input-text",
5050+ r#type: "text",
5151+ maxlength: "6",
5252+ placeholder: props.placeholder.clone(),
5353+ value: display_value.clone(),
5454+ oninput: move |e| {
5555+ // Filter to hex chars only and uppercase.
5656+ let filtered: String = e
5757+ .value()
5858+ .chars()
5959+ .filter(|c| c.is_ascii_hexdigit())
6060+ .take(6)
6161+ .collect::<String>()
6262+ .to_uppercase();
6363+ props.onchange.call(filtered);
6464+ },
6565+ }
6666+ }
6767+ }
6868+ }
6969+}
7070+7171+/// Normalise 3-char hex to 6-char (e.g., "ABC" -> "AABBCC").
7272+fn normalise_hex(hex: &str) -> String {
7373+ let hex = hex.to_uppercase();
7474+ if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
7575+ hex.chars().flat_map(|c| [c, c]).collect()
7676+ } else {
7777+ hex
7878+ }
7979+}
8080+8181+/// Check if valid 6-digit hex.
8282+fn is_valid_hex(hex: &str) -> bool {
8383+ hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit())
8484+}
8585+8686+#[cfg(test)]
8787+mod tests {
8888+ use super::*;
8989+9090+ #[test]
9191+ fn test_normalise_hex_3_char() {
9292+ assert_eq!(normalise_hex("ABC"), "AABBCC");
9393+ assert_eq!(normalise_hex("abc"), "AABBCC");
9494+ assert_eq!(normalise_hex("123"), "112233");
9595+ }
9696+9797+ #[test]
9898+ fn test_normalise_hex_6_char() {
9999+ assert_eq!(normalise_hex("AABBCC"), "AABBCC");
100100+ assert_eq!(normalise_hex("aabbcc"), "AABBCC");
101101+ }
102102+103103+ #[test]
104104+ fn test_normalise_hex_invalid() {
105105+ // Invalid 3-char (not all hex) stays as-is.
106106+ assert_eq!(normalise_hex("GHI"), "GHI");
107107+ // Other lengths stay as-is.
108108+ assert_eq!(normalise_hex("AB"), "AB");
109109+ assert_eq!(normalise_hex("ABCDE"), "ABCDE");
110110+ }
111111+112112+ #[test]
113113+ fn test_is_valid_hex() {
114114+ assert!(is_valid_hex("AABBCC"));
115115+ assert!(is_valid_hex("123456"));
116116+ assert!(!is_valid_hex("ABC")); // Too short.
117117+ assert!(!is_valid_hex("AABBCCDD")); // Too long.
118118+ assert!(!is_valid_hex("GHIJKL")); // Not hex.
119119+ }
120120+}
+3
crates/weaver-app/src/components/mod.rs
···359359pub mod checkbox;
360360pub mod toggle_group;
361361pub mod tooltip;
362362+363363+mod hex_colour_input;
364364+pub use hex_colour_input::HexColourInput;