[weaver-app] add HexColourInput component

Orual 88bb15d1 20fb6a00

+184
+61
crates/weaver-app/assets/styling/hex-colour-input.css
··· 1 + /* Hex colour input with preview swatch */ 2 + 3 + .hex-colour-input { 4 + display: flex; 5 + flex-direction: column; 6 + gap: 0.25rem; 7 + } 8 + 9 + .hex-colour-input-label { 10 + font-size: 0.875rem; 11 + font-weight: 500; 12 + color: var(--color-text); 13 + } 14 + 15 + .hex-colour-input-field { 16 + display: flex; 17 + flex-direction: row; 18 + align-items: center; 19 + gap: 0.5rem; 20 + padding: 0.25rem 0.5rem; 21 + background-color: var(--color-surface); 22 + border: 1px solid var(--color-border); 23 + border-radius: 4px; 24 + } 25 + 26 + .hex-colour-input-swatch { 27 + width: 1.5rem; 28 + height: 1.5rem; 29 + border-radius: 4px; 30 + border: 1px solid var(--color-border); 31 + flex-shrink: 0; 32 + } 33 + 34 + .hex-colour-input-hash { 35 + color: var(--color-muted); 36 + font-family: var(--font-mono); 37 + font-size: 0.875rem; 38 + user-select: none; 39 + } 40 + 41 + .hex-colour-input-text { 42 + font-family: var(--font-mono); 43 + font-size: 0.875rem; 44 + text-transform: uppercase; 45 + color: var(--color-text); 46 + background-color: transparent; 47 + border: none; 48 + outline: none; 49 + width: 5rem; 50 + padding: 0; 51 + } 52 + 53 + .hex-colour-input-text::placeholder { 54 + color: var(--color-muted); 55 + text-transform: uppercase; 56 + } 57 + 58 + .hex-colour-input-field:focus-within { 59 + border-color: var(--color-primary); 60 + box-shadow: 0 0 0 1px var(--color-primary); 61 + }
+120
crates/weaver-app/src/components/hex_colour_input.rs
··· 1 + //! Hex colour input with preview swatch. 2 + 3 + use dioxus::prelude::*; 4 + 5 + /// Props for HexColourInput. 6 + #[derive(Props, Clone, PartialEq)] 7 + pub 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 + } 19 + 20 + /// A hex colour input with a colour preview swatch. 21 + #[component] 22 + pub fn HexColourInput(props: HexColourInputProps) -> Element { 23 + // Normalise the value for display (expand 3-char to 6-char). 24 + let display_value = normalise_hex(&props.value); 25 + 26 + // Check if we have a valid hex to display in the swatch. 27 + let swatch_colour = if is_valid_hex(&display_value) { 28 + format!("#{display_value}") 29 + } else { 30 + // Grey for invalid hex. 31 + "var(--color-muted)".to_string() 32 + }; 33 + 34 + rsx! { 35 + document::Link { rel: "stylesheet", href: asset!("/assets/styling/hex-colour-input.css") } 36 + 37 + div { class: "hex-colour-input", 38 + if let Some(label) = &props.label { 39 + label { class: "hex-colour-input-label", "{label}" } 40 + } 41 + 42 + div { class: "hex-colour-input-field", 43 + div { 44 + class: "hex-colour-input-swatch", 45 + style: "background-color: {swatch_colour}", 46 + } 47 + span { class: "hex-colour-input-hash", "#" } 48 + input { 49 + class: "hex-colour-input-text", 50 + r#type: "text", 51 + maxlength: "6", 52 + placeholder: props.placeholder.clone(), 53 + value: display_value.clone(), 54 + oninput: move |e| { 55 + // Filter to hex chars only and uppercase. 56 + let filtered: String = e 57 + .value() 58 + .chars() 59 + .filter(|c| c.is_ascii_hexdigit()) 60 + .take(6) 61 + .collect::<String>() 62 + .to_uppercase(); 63 + props.onchange.call(filtered); 64 + }, 65 + } 66 + } 67 + } 68 + } 69 + } 70 + 71 + /// Normalise 3-char hex to 6-char (e.g., "ABC" -> "AABBCC"). 72 + fn normalise_hex(hex: &str) -> String { 73 + let hex = hex.to_uppercase(); 74 + if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) { 75 + hex.chars().flat_map(|c| [c, c]).collect() 76 + } else { 77 + hex 78 + } 79 + } 80 + 81 + /// Check if valid 6-digit hex. 82 + fn is_valid_hex(hex: &str) -> bool { 83 + hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) 84 + } 85 + 86 + #[cfg(test)] 87 + mod tests { 88 + use super::*; 89 + 90 + #[test] 91 + fn test_normalise_hex_3_char() { 92 + assert_eq!(normalise_hex("ABC"), "AABBCC"); 93 + assert_eq!(normalise_hex("abc"), "AABBCC"); 94 + assert_eq!(normalise_hex("123"), "112233"); 95 + } 96 + 97 + #[test] 98 + fn test_normalise_hex_6_char() { 99 + assert_eq!(normalise_hex("AABBCC"), "AABBCC"); 100 + assert_eq!(normalise_hex("aabbcc"), "AABBCC"); 101 + } 102 + 103 + #[test] 104 + fn test_normalise_hex_invalid() { 105 + // Invalid 3-char (not all hex) stays as-is. 106 + assert_eq!(normalise_hex("GHI"), "GHI"); 107 + // Other lengths stay as-is. 108 + assert_eq!(normalise_hex("AB"), "AB"); 109 + assert_eq!(normalise_hex("ABCDE"), "ABCDE"); 110 + } 111 + 112 + #[test] 113 + fn test_is_valid_hex() { 114 + assert!(is_valid_hex("AABBCC")); 115 + assert!(is_valid_hex("123456")); 116 + assert!(!is_valid_hex("ABC")); // Too short. 117 + assert!(!is_valid_hex("AABBCCDD")); // Too long. 118 + assert!(!is_valid_hex("GHIJKL")); // Not hex. 119 + } 120 + }
+3
crates/weaver-app/src/components/mod.rs
··· 359 359 pub mod checkbox; 360 360 pub mod toggle_group; 361 361 pub mod tooltip; 362 + 363 + mod hex_colour_input; 364 + pub use hex_colour_input::HexColourInput;