From b3d34f809fcc7188a51944ce17837b20b5c0227a Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 19:13:44 +0200 Subject: [PATCH 01/14] Establish the dwui design-language foundation Replace the global *:focus{outline:none} reset with an opt-in focus standard: dwui-focusable (outline-based :focus-visible ring) and dwui-field-surface (:focus-within ring), both driven by the primary palette. Every interactive component swaps its box-shadow ring + inline outline suppression for the marker class; breadcrumb links, text_input, select, and the date-picker field gain keyboard focus for the first time. Add semantic tokens: --dwui-on-accent (color painted on primary-filled controls) with a with_on_accent builder, tinted dwui-surface-* status backgrounds via color-mix, and a dwui-scrim backdrop token. Remove the now-dead dwui-ring-* utility maps. Document the radius/height/padding/transition scales and add a theme::layers z-index scale for upcoming overlay work. Co-Authored-By: Claude Fable 5 --- crates/dwui/Cargo.toml | 1 + crates/dwui/resources/css/base.css | 14 ++- crates/dwui/resources/css/colors.css | 28 ++++++ .../src/components/content/breadcrumbs.rs | 2 +- .../dwui/src/components/content/data_table.rs | 3 +- crates/dwui/src/components/content/list.rs | 3 +- crates/dwui/src/components/input/checkbox.rs | 3 +- .../dwui/src/components/input/date_picker.rs | 8 +- crates/dwui/src/components/input/select.rs | 2 +- crates/dwui/src/components/input/switch.rs | 3 +- .../dwui/src/components/input/text_input.rs | 2 +- .../dwui/src/components/widgets/accordion.rs | 3 +- crates/dwui/src/components/widgets/alert.rs | 3 +- crates/dwui/src/components/widgets/button.rs | 3 +- crates/dwui/src/components/widgets/modal.rs | 5 +- .../dwui/src/components/widgets/tab_list.rs | 3 +- crates/dwui/src/theme/mod.rs | 74 +++++++-------- crates/dwui/tests/components.rs | 90 +++++++++++++++++++ 18 files changed, 184 insertions(+), 66 deletions(-) diff --git a/crates/dwui/Cargo.toml b/crates/dwui/Cargo.toml index 47981f6..432d890 100644 --- a/crates/dwui/Cargo.toml +++ b/crates/dwui/Cargo.toml @@ -27,6 +27,7 @@ web-sys = { workspace = true, features = [ "CssRule", "CssRuleList", "CssStyleDeclaration", + "CssStyleRule", "CssStyleSheet", "Document", "HtmlElement", diff --git a/crates/dwui/resources/css/base.css b/crates/dwui/resources/css/base.css index 27f4ef4..6b0d1ec 100644 --- a/crates/dwui/resources/css/base.css +++ b/crates/dwui/resources/css/base.css @@ -1,3 +1,13 @@ -*:focus { +.dwui-focusable { outline: none; -} \ No newline at end of file +} + +.dwui-focusable:focus-visible { + outline: 2px solid var(--dwui-primary-400); + outline-offset: 2px; +} + +.dwui-field-surface:focus-within { + outline: 2px solid var(--dwui-primary-400); + outline-offset: -2px; +} diff --git a/crates/dwui/resources/css/colors.css b/crates/dwui/resources/css/colors.css index ce1e9e2..e0797f1 100644 --- a/crates/dwui/resources/css/colors.css +++ b/crates/dwui/resources/css/colors.css @@ -260,3 +260,31 @@ .dwui-bg-warning-950 { background-color: var(--dwui-warning-950); } + +.dwui-bg-on-accent { + background-color: var(--dwui-on-accent); +} + +.dwui-text-on-accent { + color: var(--dwui-on-accent); +} + +.dwui-surface-info { + background-color: color-mix(in srgb, var(--dwui-primary-500) 12%, transparent); +} + +.dwui-surface-success { + background-color: color-mix(in srgb, var(--dwui-success-500) 12%, transparent); +} + +.dwui-surface-warning { + background-color: color-mix(in srgb, var(--dwui-warning-500) 12%, transparent); +} + +.dwui-surface-error { + background-color: color-mix(in srgb, var(--dwui-error-500) 12%, transparent); +} + +.dwui-scrim { + background-color: color-mix(in srgb, var(--dwui-void-950) 60%, transparent); +} diff --git a/crates/dwui/src/components/content/breadcrumbs.rs b/crates/dwui/src/components/content/breadcrumbs.rs index cd5eabb..8aa0902 100644 --- a/crates/dwui/src/components/content/breadcrumbs.rs +++ b/crates/dwui/src/components/content/breadcrumbs.rs @@ -56,7 +56,7 @@ pub fn breadcrumbs(props: BreadcrumbsProps) -> Dom { b.child(html!("a", { .attr("href", &href) .dwclass!("dwui-text-on-primary-400 hover:dwui-text-primary-300 is(.light *):dwui-text-on-primary-600 is(.light *):hover:dwui-text-primary-700") - .dwclass!("transition-colors") + .dwclass!("transition-colors dwui-focusable") .style("text-decoration", "none") .text(&label) })) diff --git a/crates/dwui/src/components/content/data_table.rs b/crates/dwui/src/components/content/data_table.rs index 45c9496..8cb36c0 100644 --- a/crates/dwui/src/components/content/data_table.rs +++ b/crates/dwui/src/components/content/data_table.rs @@ -131,8 +131,7 @@ pub fn data_table(props: DataTableProps) -> Dom { .dwclass!("bg-transparent border-none font-bold text-sm") .dwclass!("dwui-text-on-primary-200 hover:dwui-text-primary-300") .dwclass!("is(.light *):dwui-text-on-primary-800 is(.light *):hover:dwui-text-primary-700") - .dwclass!("transition-colors focus-visible:ring-2 focus-visible:dwui-ring-primary-400") - .style("outline", "none") + .dwclass!("transition-colors dwui-focusable") .child(html!("span", { .text(&column.label) })) .child(html!("span", { .dwclass!("inline-flex align-items-center") diff --git a/crates/dwui/src/components/content/list.rs b/crates/dwui/src/components/content/list.rs index e961171..7472e09 100644 --- a/crates/dwui/src/components/content/list.rs +++ b/crates/dwui/src/components/content/list.rs @@ -51,9 +51,8 @@ pub fn pretty_list(props: ListProps) -> Dom { .dwclass!("dwui-border-void-600 is(.light *):dwui-border-void-300") .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-700") .dwclass!("hover:dwui-text-on-primary-50 is(.light *):hover:dwui-text-on-primary-950 hover:dwui-border-void-300") - .dwclass!("transition-colors focus-visible:ring-1") + .dwclass!("transition-colors dwui-focusable") .style("padding-left", "10px") - .style("outline", "none") .child(item) .dwclass_signal!("dwui-text-primary-300 is(.light *):dwui-text-primary-700", selected_signal.signal()) .dwclass_signal!("hover:dwui-text-primary-300 is(.light *):hover:dwui-text-primary-700", selected_signal.signal()) diff --git a/crates/dwui/src/components/input/checkbox.rs b/crates/dwui/src/components/input/checkbox.rs index 6e66b51..3b06f79 100644 --- a/crates/dwui/src/components/input/checkbox.rs +++ b/crates/dwui/src/components/input/checkbox.rs @@ -62,10 +62,9 @@ pub fn checkbox(props: CheckboxProps) -> Dom { .dwclass!("flex align-items-center justify-center") .dwclass!("border dwui-border-void-500 is(.light *):dwui-border-void-300") .dwclass!("disabled:cursor-not-allowed disabled:opacity-60") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400 is(.light *):focus-visible:dwui-ring-primary-600") + .dwclass!("dwui-focusable") .dwclass_signal!("dwui-bg-primary-500 dwui-border-primary-500 is(.light *):dwui-bg-primary-400 is(.light *):dwui-border-primary-400", checked.signal()) .dwclass_signal!("bg-transparent", checked.signal().map(|v| !v)) - .style("outline", "none") .child(html!("span", { .dwclass!("flex align-items-center justify-center") .style("transition", "opacity 100ms ease-out") diff --git a/crates/dwui/src/components/input/date_picker.rs b/crates/dwui/src/components/input/date_picker.rs index c1372bf..40f4742 100644 --- a/crates/dwui/src/components/input/date_picker.rs +++ b/crates/dwui/src/components/input/date_picker.rs @@ -85,7 +85,7 @@ pub fn date_picker(props: DatePickerProps) -> Dom { .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900") .dwclass!("disabled:cursor-not-allowed disabled:opacity-60") .dwclass!("flex flex-row align-items-center justify-between gap-2") - .style("outline", "none") + .dwclass!("dwui-focusable") .child(html!("span", { .text_signal(value.signal().map(|v| { v.map(|v| v.to_string()).unwrap_or_default() @@ -279,8 +279,7 @@ fn month_nav_button(label: &str, path: &str, on_click: impl Fn() + 'static) -> D .dwclass!("bg-transparent border-none transition-colors") .dwclass!("dwui-text-on-primary-300 hover:dwui-bg-void-800 hover:dwui-text-on-primary-50") .dwclass!("is(.light *):dwui-text-on-primary-700 is(.light *):hover:dwui-bg-void-200") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400") - .style("outline", "none") + .dwclass!("dwui-focusable") .child(svg!("svg", { .attr("viewBox", "0 0 14 14") .attr("width", "14") @@ -330,8 +329,7 @@ fn day_cell( .focused_signal(is_focused.signal()) .dwclass!("w-9 h-9 flex align-items-center justify-center rounded-md cursor-pointer text-sm") .dwclass!("bg-transparent border-none transition-colors") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400") - .style("outline", "none") + .dwclass!("dwui-focusable") .apply(move |b| { if in_month { dwclass!(b, "dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-800") diff --git a/crates/dwui/src/components/input/select.rs b/crates/dwui/src/components/input/select.rs index 5819b72..7b483ae 100644 --- a/crates/dwui/src/components/input/select.rs +++ b/crates/dwui/src/components/input/select.rs @@ -54,7 +54,7 @@ pub fn select(props: SelectProps) -> Dom { .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 is(.light *):dwui-text-on-primary-800 text-base h-10 p-l-2") .dwclass!("dwui-text-on-primary-300") .dwclass!("grid-row-1 grid-col-1 cursor-pointer rounded-t-sm transition-all") - .style("outline", "none") + .dwclass!("dwui-focusable") .attr_signal("aria-invalid", is_valid_bool.signal().map(|valid| if valid { None } else { Some("true") })) .attr_signal("aria-describedby", is_valid_bool.signal().map({ let error_id = error_id.clone(); diff --git a/crates/dwui/src/components/input/switch.rs b/crates/dwui/src/components/input/switch.rs index 5a91637..ce52b08 100644 --- a/crates/dwui/src/components/input/switch.rs +++ b/crates/dwui/src/components/input/switch.rs @@ -60,10 +60,9 @@ pub fn switch(props: SwitchProps) -> Dom { .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) .dwclass!("w-11 h-6 rounded-full cursor-pointer transition-colors flex-none border-none p-0") .dwclass!("disabled:cursor-not-allowed disabled:opacity-60") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400 is(.light *):focus-visible:dwui-ring-primary-600") + .dwclass!("dwui-focusable") .dwclass_signal!("dwui-bg-primary-500 is(.light *):dwui-bg-primary-400", checked.signal()) .dwclass_signal!("dwui-bg-void-600 is(.light *):dwui-bg-void-300", checked.signal().map(|v| !v)) - .style("outline", "none") .style("position", "relative") .child(html!("span", { .dwclass!("w-5 h-5 rounded-full bg-white block shadow-md") diff --git a/crates/dwui/src/components/input/text_input.rs b/crates/dwui/src/components/input/text_input.rs index 284bf48..9cd4fa8 100644 --- a/crates/dwui/src/components/input/text_input.rs +++ b/crates/dwui/src/components/input/text_input.rs @@ -97,7 +97,7 @@ pub fn text_input(props: TextInputProps) -> Dom { .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900") .dwclass_signal!("h-10", is_valid.signal()) .dwclass_signal!("h-6", not(is_valid.signal())) - .style("outline", "none") + .dwclass!("dwui-focusable") .attr_signal("aria-invalid", is_valid.signal().map(|valid| if valid { None } else { Some("true") })) .attr_signal("aria-describedby", is_valid.signal().map(clone!(error_id => move |valid| { if valid { diff --git a/crates/dwui/src/components/widgets/accordion.rs b/crates/dwui/src/components/widgets/accordion.rs index 0eb7a75..94edbca 100644 --- a/crates/dwui/src/components/widgets/accordion.rs +++ b/crates/dwui/src/components/widgets/accordion.rs @@ -64,8 +64,7 @@ pub fn accordion(props: AccordionProps) -> Dom { .dwclass!("bg-transparent border-none text-base font-medium text-left") .dwclass!("dwui-text-on-primary-100 is(.light *):dwui-text-on-primary-900") .dwclass!("hover:dwui-bg-void-800 is(.light *):hover:dwui-bg-void-200 transition-colors") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400") - .style("outline", "none") + .dwclass!("dwui-focusable") .child(html!("span", { .text(&title) })) .child(html!("span", { .dwclass!("flex-none inline-flex align-items-center") diff --git a/crates/dwui/src/components/widgets/alert.rs b/crates/dwui/src/components/widgets/alert.rs index 48a7740..4e98169 100644 --- a/crates/dwui/src/components/widgets/alert.rs +++ b/crates/dwui/src/components/widgets/alert.rs @@ -140,8 +140,7 @@ pub fn alert(props: AlertProps) -> Dom { .dwclass!("flex-none w-6 h-6 flex align-items-center justify-center rounded-full cursor-pointer") .dwclass!("bg-transparent border-none dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-700") .dwclass!("hover:dwui-bg-void-800 is(.light *):hover:dwui-bg-void-200 transition-colors") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400") - .style("outline", "none") + .dwclass!("dwui-focusable") .text("×") .event(clone!(dismissed, on_dismiss => move |_: events::Click| { dismissed.set(true); diff --git a/crates/dwui/src/components/widgets/button.rs b/crates/dwui/src/components/widgets/button.rs index 38a33cb..65a4a65 100644 --- a/crates/dwui/src/components/widgets/button.rs +++ b/crates/dwui/src/components/widgets/button.rs @@ -68,8 +68,7 @@ pub fn button(props: ButtonProps) -> Dom { .dwclass!("is(.light *):disabled:dwui-text-on-primary-600 is(.light *):disabled:hover:dwui-border-primary-200") .dwclass!("w-full font-bold p-l-3 p-r-3 cursor-pointer rounded-full pointer-events-auto") .dwclass!("transition-all duration-150") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400 is(.light *):focus-visible:dwui-ring-primary-600") - .style("outline", "none") + .dwclass!("dwui-focusable") .apply_if(apply.is_some(), move |b| { b.apply(apply.unwrap()) }) diff --git a/crates/dwui/src/components/widgets/modal.rs b/crates/dwui/src/components/widgets/modal.rs index 37f1b48..3bc594d 100644 --- a/crates/dwui/src/components/widgets/modal.rs +++ b/crates/dwui/src/components/widgets/modal.rs @@ -115,7 +115,7 @@ pub fn modal(props: ModalProps) -> Dom { .style("position", "relative") .style("z-index", "10") .style("pointer-events", "auto") - .style("outline", "none") + .dwclass!("dwui-focusable") // Size classes .style_signal("width", size.signal().map(|s| match s { @@ -143,14 +143,13 @@ pub fn modal(props: ModalProps) -> Dom { .dwclass!("hover:dwui-bg-void-800 hover:dwui-text-on-primary-100") .dwclass!("is(.light *):hover:dwui-bg-void-200 is(.light *):hover:dwui-text-on-primary-900") .dwclass!("cursor-pointer transition-colors") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400") + .dwclass!("dwui-focusable") .style("position", "absolute") .style("top", "1rem") .style("right", "1rem") .dwclass!("bg-transparent border-none") .style("padding", "0") .style("margin", "0") - .style("outline", "none") .text("×") .style("font-size", "24px") .style("line-height", "1") diff --git a/crates/dwui/src/components/widgets/tab_list.rs b/crates/dwui/src/components/widgets/tab_list.rs index 949d5c9..b73493e 100644 --- a/crates/dwui/src/components/widgets/tab_list.rs +++ b/crates/dwui/src/components/widgets/tab_list.rs @@ -82,13 +82,12 @@ pub fn tab_list(props: TabListProps) -> Dom { .attr_signal("tabindex", is_selected.signal().map(|v| if v { "0" } else { "-1" })) .dwclass!("h-10 p-l-4 p-r-4 cursor-pointer font-medium text-base transition-all bg-transparent border-none") .dwclass!("rounded-t-sm") - .dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400 is(.light *):focus-visible:dwui-ring-primary-600") + .dwclass!("dwui-focusable") .dwclass_signal!("dwui-text-on-primary-50 is(.light *):dwui-text-on-primary-950", is_selected.signal()) .dwclass_signal!( "dwui-text-on-primary-400 hover:dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-600 is(.light *):hover:dwui-text-on-primary-800", is_selected.signal().map(|v| !v) ) - .style("outline", "none") .style_signal("box-shadow", is_selected.signal().map(|v| { if v { Some("inset 0 -2px 0 0 var(--dwui-primary-400)") diff --git a/crates/dwui/src/theme/mod.rs b/crates/dwui/src/theme/mod.rs index ded1b9a..8442da1 100644 --- a/crates/dwui/src/theme/mod.rs +++ b/crates/dwui/src/theme/mod.rs @@ -43,6 +43,32 @@ dwkeyframes! { } } +/// Layering scale for overlaid components. Every dwui z-index comes from here +/// so overlays always stack predictably: tooltips under transient overlays +/// (popovers, dropdowns), overlays under modals/drawers, toasts above all. +pub mod layers { + pub const TOOLTIP: &str = "30"; + pub const OVERLAY: &str = "40"; + pub const MODAL: &str = "50"; + pub const TOAST: &str = "60"; +} + +// Design-language scales +// ---------------------- +// Radius: containers (card, modal, alert, popovers) `rounded-lg`; controls and +// fields (buttons, inputs, tabs, checkbox, list rows) `rounded-md`; pills +// (badge, switch track, avatar) `rounded-full`. +// Control heights: sm/md/lg = `h-8`/`h-10`/`h-12`; field surfaces are always +// `h-10` in every state (validation must never change field height). +// Padding: modal `p-6`; card and alert `p-4`; table cells `p-3`; field +// horizontal padding `p-l-3 p-r-3`. +// Transitions: `transition-colors duration-150` is the standard; transform and +// opacity animations use explicit inline transitions. `transition-all` is +// banned in components. +// Focus: interactive elements take the `dwui-focusable` class (outline-based +// `:focus-visible` ring); field wrappers take `dwui-field-surface` +// (`:focus-within` ring). Never use box-shadow based rings — they collide +// with `shadow-*` utilities. pub fn apply_style_sheet(colors: Option) { stylesheet!(":root", { .raw(colors.unwrap_or_default().to_style_sheet_raw()) @@ -90,6 +116,15 @@ pub mod colors { ) } + /// Overrides the `--dwui-on-accent` color: the color painted *on top + /// of* primary-filled controls (switch knob, checkbox checkmark, + /// filled badge text). Defaults to near-white, which suits most + /// primary palettes; set a dark value when the primary ramp is light. + pub fn with_on_accent(mut self, on_accent: impl Into) -> Self { + self.dwui_on_accent = on_accent.into(); + self + } + /// Like [`Self::new`], but with explicit success and warning palettes. pub fn with_status_colors( primary: &BTreeMap, @@ -100,6 +135,8 @@ pub mod colors { warning: &BTreeMap, ) -> Self { Self { + dwui_on_accent: "#fafafa".to_string(), + dwui_success_50: success.get(&50).unwrap().clone(), dwui_success_100: success.get(&100).unwrap().clone(), dwui_success_200: success.get(&200).unwrap().clone(), @@ -282,45 +319,8 @@ pub mod colors { use dwind::border_color_generator; use dwind::gradient_from_generator; use dwind::gradient_to_generator; - use dwind::ring_generator; use dwind::text_color_generator; - dwgenerate_map!( - "dwui-ring-primary", - "ring-", - [ - ("50", "var(--dwui-primary-50)"), - ("100", "var(--dwui-primary-100)"), - ("200", "var(--dwui-primary-200)"), - ("300", "var(--dwui-primary-300)"), - ("400", "var(--dwui-primary-400)"), - ("500", "var(--dwui-primary-500)"), - ("600", "var(--dwui-primary-600)"), - ("700", "var(--dwui-primary-700)"), - ("800", "var(--dwui-primary-800)"), - ("900", "var(--dwui-primary-900)"), - ("950", "var(--dwui-primary-950)") - ] - ); - - dwgenerate_map!( - "dwui-ring-error", - "ring-", - [ - ("50", "var(--dwui-error-50)"), - ("100", "var(--dwui-error-100)"), - ("200", "var(--dwui-error-200)"), - ("300", "var(--dwui-error-300)"), - ("400", "var(--dwui-error-400)"), - ("500", "var(--dwui-error-500)"), - ("600", "var(--dwui-error-600)"), - ("700", "var(--dwui-error-700)"), - ("800", "var(--dwui-error-800)"), - ("900", "var(--dwui-error-900)"), - ("950", "var(--dwui-error-950)") - ] - ); - dwgenerate_map!( "dwui-border-primary", "border-color-", diff --git a/crates/dwui/tests/components.rs b/crates/dwui/tests/components.rs index 7bbdc9a..fbeeede 100644 --- a/crates/dwui/tests/components.rs +++ b/crates/dwui/tests/components.rs @@ -1051,3 +1051,93 @@ async fn date_picker_navigates_months() { wait_frames(2).await; assert_eq!(month_label.text_content().unwrap(), "January 2026"); } + +// --------------------------------------------------------------------------- +// Focus standard +// --------------------------------------------------------------------------- + +/// Asserts the element carries the `dwui-focusable` marker class, i.e. it +/// participates in the outline-based `:focus-visible` ring standard. +fn assert_focusable(element: &web_sys::Element) { + let class = element.get_attribute("class").unwrap_or_default(); + + assert!( + class + .split_whitespace() + .any(|c| c.starts_with("dwui_focusable_")), + "expected a dwui-focusable marker class, got: {class:?}" + ); +} + +/// Every style-rule selector across all document stylesheets. +fn stylesheet_selectors() -> Vec { + let doc = web_sys::window().unwrap().document().unwrap(); + let sheets = doc.style_sheets(); + let mut out = vec![]; + + for i in 0..sheets.length() { + let Some(sheet) = sheets.item(i) else { continue }; + let Ok(sheet) = sheet.dyn_into::() else { + continue; + }; + let Ok(rules) = sheet.css_rules() else { continue }; + + for r in 0..rules.length() { + let Some(rule) = rules.item(r) else { continue }; + + if let Ok(style_rule) = rule.dyn_into::() { + out.push(style_rule.selector_text()); + } + } + } + + out +} + +#[wasm_bindgen_test] +async fn focus_standard_replaces_the_global_reset() { + dwui::theme::apply_style_sheet(None); + + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + button!({ .content(Some(text("Focus me"))) }), + ); + wait_frame().await; + + assert_focusable(&tc.query("button").unwrap()); + + let selectors = stylesheet_selectors(); + + // The old blanket reset removed every native focus indicator; it must be gone. + assert!( + !selectors.iter().any(|s| s == "*:focus"), + "the global *:focus reset is still injected" + ); + + // The marker class carries the outline-based focus-visible ring. + assert!( + selectors + .iter() + .any(|s| s.contains("dwui_focusable") && s.contains(":focus-visible")), + "no :focus-visible rule found for dwui-focusable" + ); +} + +#[wasm_bindgen_test] +async fn interactive_controls_carry_the_focusable_marker() { + let tc = TestContainer::new(); + + dominator::append_dom( + &tc.dom_element(), + html!("div", { + .child(checkbox!({ .label("Check".to_string()) })) + .child(switch!({ .label("Toggle".to_string()) })) + }), + ); + wait_frame().await; + + assert_focusable(&tc.query("[role=checkbox]").unwrap()); + assert_focusable(&tc.query("[role=switch]").unwrap()); +} From c1337720bd5765e905dda17a891c26b58d0e6d1a Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 25 Jul 2026 19:23:50 +0200 Subject: [PATCH 02/14] Replace the notched label mixin with a filled field system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old labelled_rect_mixin drew its outline notch from four absolutely positioned divs sized by label.len() * 9px, which broke on proportional fonts and multi-byte labels, and its error message appeared and disappeared, shifting layout — text_input even shrank from h-10 to h-6 while invalid. The new mixins/field_mixin.rs provides field_surface_mixin (filled surface, dwui-field-surface :focus-within ring, state-colored underline, floating label positioned purely by transform) and field_error_row (an always-present message row with reserved height, role=alert only while it has a message). text_input, select, slider, and date_picker migrate onto it; select's render_function= oddity becomes render_fn=, the date-picker weekday header stops leaking the app's font-code class, and text_input/select/slider gain a disabled prop. Slider validation is now a real is_valid prop instead of hard-wired Valid. New form components sharing the system: text_area! (rows, resize, disabled), number_input! (native spinbutton semantics, themed non-tabbable steppers, min/max clamping), and radio_group! (WAI-ARIA radio pattern with roving tabindex; selection follows focus). Co-Authored-By: Claude Fable 5 --- crates/dwui/Cargo.toml | 2 + .../dwui/src/components/input/date_picker.rs | 42 +-- crates/dwui/src/components/input/mod.rs | 6 + .../dwui/src/components/input/number_input.rs | 233 +++++++++++++++++ .../dwui/src/components/input/radio_group.rs | 187 ++++++++++++++ crates/dwui/src/components/input/select.rs | 39 ++- crates/dwui/src/components/input/slider.rs | 39 ++- crates/dwui/src/components/input/text_area.rs | 151 +++++++++++ .../dwui/src/components/input/text_input.rs | 42 +-- crates/dwui/src/mixins/field_mixin.rs | 112 ++++++++ crates/dwui/src/mixins/labelled_rect_mixin.rs | 94 ------- crates/dwui/src/mixins/mod.rs | 2 +- crates/dwui/tests/components.rs | 243 ++++++++++++++++++ 13 files changed, 1042 insertions(+), 150 deletions(-) create mode 100644 crates/dwui/src/components/input/number_input.rs create mode 100644 crates/dwui/src/components/input/radio_group.rs create mode 100644 crates/dwui/src/components/input/text_area.rs create mode 100644 crates/dwui/src/mixins/field_mixin.rs delete mode 100644 crates/dwui/src/mixins/labelled_rect_mixin.rs diff --git a/crates/dwui/Cargo.toml b/crates/dwui/Cargo.toml index 432d890..92c4337 100644 --- a/crates/dwui/Cargo.toml +++ b/crates/dwui/Cargo.toml @@ -32,6 +32,8 @@ web-sys = { workspace = true, features = [ "Document", "HtmlElement", "HtmlInputElement", + "KeyboardEvent", + "KeyboardEventInit", "NodeList", "StyleSheet", "StyleSheetList", diff --git a/crates/dwui/src/components/input/date_picker.rs b/crates/dwui/src/components/input/date_picker.rs index 40f4742..5ceb5d1 100644 --- a/crates/dwui/src/components/input/date_picker.rs +++ b/crates/dwui/src/components/input/date_picker.rs @@ -1,5 +1,5 @@ use crate::components::input::calendar_date::CalendarDate; -use crate::mixins::labelled_rect_mixin::labelled_rect_mixin; +use crate::mixins::field_mixin::{field_error_row, field_surface_mixin}; use crate::prelude::ValidationResult; use crate::theme::prelude::*; use crate::utils::component_id; @@ -68,24 +68,36 @@ pub fn date_picker(props: DatePickerProps) -> Dom { let has_value = value.signal_ref(|v| v.is_some()).broadcast(); html!("div", { - .dwclass!("grid") + .dwclass!("flex flex-col w-full") .future(value.signal().for_each(clone!(value_state => move |v| { value_state.set(v); async {} }))) // Field button - .child(html!("button", { + .child(html!("div", { + .dwclass!("h-10 flex-none") + .dwclass_signal!("opacity-60", disabled.signal()) + .apply(field_surface_mixin( + label.signal_cloned(), + map_ref! { + let has_value = has_value.signal(), + let open = open.signal() => *has_value || *open + }, + futures_signals::signal::always(ValidationResult::Valid), + field_id.clone(), + )) + .child(html!("button", { .attr("type", "button") .attr("id", &field_id) .attr("aria-haspopup", "dialog") .attr_signal("aria-expanded", open.signal().map(|v| if v { "true" } else { "false" })) .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) - .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 text-base h-10 p-l-2 p-r-2") - .dwclass!("grid-col-1 grid-row-1 cursor-pointer rounded-t-sm transition-all border-none text-left") - .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900") - .dwclass!("disabled:cursor-not-allowed disabled:opacity-60") + .dwclass!("w-full h-full bg-transparent border-none text-base p-l-3 p-r-3 p-t-3") + .dwclass!("cursor-pointer text-left") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-900") + .dwclass!("disabled:cursor-not-allowed") .dwclass!("flex flex-row align-items-center justify-between gap-2") - .dwclass!("dwui-focusable") + .style("outline", "none") .child(html!("span", { .text_signal(value.signal().map(|v| { v.map(|v| v.to_string()).unwrap_or_default() @@ -120,10 +132,10 @@ pub fn date_picker(props: DatePickerProps) -> Dom { (open_picker)(); } })) + })) })) // Popup .child(html!("div", { - .dwclass!("grid-col-1 grid-row-1") .style("position", "relative") .style("pointer-events", "none") .child_signal(open.signal().map(clone!(view, focused_day, value_state, on_change, open, field_id => move |is_open| { @@ -153,14 +165,9 @@ pub fn date_picker(props: DatePickerProps) -> Dom { })) }))) })) - .apply(labelled_rect_mixin( - label.signal_cloned(), - map_ref! { - let has_value = has_value.signal(), - let open = open.signal() => *has_value || *open - }, + .child(field_error_row( futures_signals::signal::always(ValidationResult::Valid), - field_id.clone(), + &field_id, )) .apply_if(apply.is_some(), |b| b.apply(apply.unwrap())) }) @@ -230,8 +237,7 @@ fn calendar_popup( .children(["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map(|day| { html!("div", { .attr("aria-hidden", "true") - .class("font-code") - .dwclass!("text-xs text-center p-1 select-none") + .dwclass!("text-xs text-center p-1 select-none font-medium") .dwclass!("dwui-text-on-primary-500 is(.light *):dwui-text-on-primary-600") .text(day) }) diff --git a/crates/dwui/src/components/input/mod.rs b/crates/dwui/src/components/input/mod.rs index e1a6d4e..89579d2 100644 --- a/crates/dwui/src/components/input/mod.rs +++ b/crates/dwui/src/components/input/mod.rs @@ -1,9 +1,12 @@ pub mod calendar_date; pub mod checkbox; pub mod date_picker; +pub mod number_input; +pub mod radio_group; pub mod select; pub mod slider; pub mod switch; +pub mod text_area; pub mod text_input; pub mod validation; @@ -11,9 +14,12 @@ pub mod prelude { pub use super::calendar_date::*; pub use super::checkbox::*; pub use super::date_picker::*; + pub use super::number_input::*; + pub use super::radio_group::*; pub use super::select::*; pub use super::slider::*; pub use super::switch::*; + pub use super::text_area::*; pub use super::text_input::*; pub use super::validation::*; } diff --git a/crates/dwui/src/components/input/number_input.rs b/crates/dwui/src/components/input/number_input.rs new file mode 100644 index 0000000..48ab7be --- /dev/null +++ b/crates/dwui/src/components/input/number_input.rs @@ -0,0 +1,233 @@ +use crate::components::input::validation::InputValueWrapper; +use crate::mixins::field_mixin::{field_error_row, field_surface_mixin}; +use crate::prelude::ValidationResult; +use crate::theme::prelude::*; +use crate::utils::component_id; +use dominator::{clone, events, html, svg, with_node, Dom}; +use dwind::prelude::*; +use futures_signals::map_ref; +use futures_signals::signal::{and, not, or, Mutable, SignalExt}; +use futures_signals_component_macro::component; +use std::rc::Rc; +use web_sys::HtmlInputElement; + +/// A numeric field sharing the labelled field surface of +/// [`text_input`](crate::components::input::text_input::text_input). +/// +/// Renders a native `type="number"` input (spinbutton semantics and arrow-key +/// stepping for free) with themed stepper buttons replacing the browser +/// spinners. The steppers are decorative duplicates of the keyboard +/// interaction, so they are not tabbable. +#[component(render_fn = number_input)] +struct NumberInput { + #[default(Box::new(Mutable::new(0f64)))] + value: dyn InputValueWrapper + 'static, + + #[signal] + #[default(ValidationResult::Valid)] + is_valid: ValidationResult, + + #[signal] + #[default("".to_string())] + label: String, + + #[signal] + #[default(None)] + min: Option, + + #[signal] + #[default(None)] + max: Option, + + #[signal] + #[default(1.0)] + step: f64, + + #[signal] + #[default(false)] + disabled: bool, + + #[default(Box::new(|| {}))] + on_submit: dyn (FnMut() -> ()) + 'static, +} + +pub fn number_input(props: NumberInputProps) -> Dom { + let NumberInputProps { + value, + is_valid, + label, + min, + max, + step, + disabled, + mut on_submit, + apply, + } = props; + + let value = Rc::new(value); + let label = label.broadcast(); + let disabled = disabled.broadcast(); + + let has_label = label.signal_ref(|v| v.len() > 0); + let has_value = value.value_signal_cloned().map(|v| v.len() > 0); + + let is_focused = Mutable::new(false); + let parsed_validation_result = Mutable::new(ValidationResult::Valid); + + let validation_signal = map_ref! { + let parse_result = parsed_validation_result.signal_cloned(), + let external_result = is_valid => { + if !parse_result.is_valid() { + parse_result.clone() + } else if !external_result.is_valid() { + external_result.clone() + } else { + ValidationResult::Valid + } + } + } + .broadcast(); + + let is_valid = validation_signal + .signal_ref(|validation| validation.is_valid()) + .broadcast(); + + let raise_label = and( + has_label, + or(or(is_focused.signal(), has_value), not(is_valid.signal())), + ); + + let input_id = component_id("number-input"); + let error_id = format!("{}-error", input_id); + + // Shared handle to the input element so the steppers can drive it + let input_element: Mutable> = Mutable::new(None); + + let stepper = |direction_up: bool, path: &str| { + html!("button", { + .attr("type", "button") + .attr("tabindex", "-1") + .attr("aria-hidden", "true") + .dwclass!("w-5 h-4 flex align-items-center justify-center cursor-pointer") + .dwclass!("bg-transparent border-none p-0") + .dwclass!("dwui-text-on-primary-400 hover:dwui-text-on-primary-200") + .dwclass!("is(.light *):dwui-text-on-primary-600 is(.light *):hover:dwui-text-on-primary-800") + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) + .child(svg!("svg", { + .attr("viewBox", "0 0 10 6") + .attr("width", "10") + .attr("height", "6") + .attr("fill", "none") + .child(svg!("path", { + .attr("d", path) + .attr("stroke", "currentColor") + .attr("stroke-width", "1.5") + .attr("stroke-linecap", "round") + .attr("stroke-linejoin", "round") + })) + })) + .event(clone!(input_element, value => move |_: events::Click| { + let Some(element) = input_element.get_cloned() else { return }; + + let attr_f64 = |name: &str| { + element + .get_attribute(name) + .and_then(|v| v.parse::().ok()) + }; + + let step = attr_f64("step").unwrap_or(1.0); + let current = element.value().parse::().unwrap_or(0.0); + + let mut next = if direction_up { + current + step + } else { + current - step + }; + + if let Some(min) = attr_f64("min") { + next = next.max(min); + } + if let Some(max) = attr_f64("max") { + next = next.min(max); + } + + element.set_value(&next.to_string()); + value.set(element.value()); + })) + }) + }; + + html!("div", { + .dwclass!("flex flex-col w-full") + .child(html!("div", { + .dwclass!("h-10 flex-none flex flex-row align-items-center") + .dwclass_signal!("opacity-60", disabled.signal()) + .apply(field_surface_mixin( + label.signal_cloned(), + raise_label, + validation_signal.signal_cloned(), + input_id.clone(), + )) + .child(html!("input" => HtmlInputElement, { + .attr("id", &input_id) + .attr("type", "number") + .attr("inputmode", "decimal") + .dwclass!("w-full h-full grow bg-transparent border-none text-base p-l-3 p-t-3") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-900") + .dwclass_signal!("cursor-not-allowed", disabled.signal()) + .style("outline", "none") + // Hide the native spinners; the themed steppers replace them. + // Inline declarations only — no vendor pseudo-element selectors. + .style("appearance", "textfield") + .style("-moz-appearance", "textfield") + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) + .attr_signal("min", min.map(|v| v.map(|v| v.to_string()))) + .attr_signal("max", max.map(|v| v.map(|v| v.to_string()))) + .attr_signal("step", step.map(|v| v.to_string())) + .attr_signal("aria-invalid", is_valid.signal().map(|valid| if valid { None } else { Some("true") })) + .attr_signal("aria-describedby", is_valid.signal().map(clone!(error_id => move |valid| { + if valid { + None + } else { + Some(error_id.clone()) + } + }))) + .with_node!(element => { + .apply(clone!(input_element, element => move |b| { + input_element.set(Some(element)); + b + })) + .future(value.value_signal_cloned().for_each(clone!(element => move |v| { + element.set_value(&v); + async move {} + }))) + .event(clone!(parsed_validation_result, value => move |_: events::Input| { + let result = value.set(element.value()); + + if !result.is_valid() { + parsed_validation_result.set(result); + } + })) + }) + .event(clone!(is_focused => move |_: events::FocusOut| { + is_focused.set(false); + })) + .event(clone!(is_focused => move |_: events::Focus| { + is_focused.set(true); + })) + .event(move |event: events::KeyDown| { + if event.key() == "Enter" { + on_submit() + } + }) + })) + .child(html!("div", { + .dwclass!("flex flex-col flex-none justify-center p-r-2 gap-1") + .child(stepper(true, "M1 4.5 L5 1.5 L9 4.5")) + .child(stepper(false, "M1 1.5 L5 4.5 L9 1.5")) + })) + })) + .child(field_error_row(validation_signal.signal_cloned(), &input_id)) + .apply_if(apply.is_some(), |b| b.apply(apply.unwrap())) + }) +} diff --git a/crates/dwui/src/components/input/radio_group.rs b/crates/dwui/src/components/input/radio_group.rs new file mode 100644 index 0000000..bdad157 --- /dev/null +++ b/crates/dwui/src/components/input/radio_group.rs @@ -0,0 +1,187 @@ +use crate::theme::prelude::*; +use crate::utils::component_id; +use dominator::{clone, events, html, Dom, EventOptions}; +use dwind::prelude::*; +use futures_signals::signal::SignalExt; +use futures_signals::signal_vec::SignalVecExt; +use futures_signals_component_macro::component; +use std::rc::Rc; +use web_sys::wasm_bindgen::JsCast; + +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum RadioGroupDirection { + Horizontal, + Vertical, +} + +/// An accessible radio group following the WAI-ARIA radio pattern. +/// +/// Renders a `role="radiogroup"` with one `role="radio"` button per +/// `(key, label)` entry. Selection is controlled by the `value` key signal; +/// picking an option (click, Space, or arrow keys — selection follows focus) +/// invokes `on_change` with its key. Roving tabindex: the checked option (or +/// the first, when nothing is checked) is the group's single tab stop. +#[component(render_fn = radio_group)] +struct RadioGroup { + #[signal_vec] + #[default(vec![])] + options: (String, String), + + #[signal] + #[default("".to_string())] + value: String, + + #[default(Box::new(|_|{}))] + on_change: dyn Fn(String) + 'static, + + /// Visible group label + #[signal] + #[default("".to_string())] + label: String, + + #[signal] + #[default(false)] + disabled: bool, + + #[default(RadioGroupDirection::Vertical)] + direction: RadioGroupDirection, +} + +pub fn radio_group(props: RadioGroupProps) -> Dom { + let RadioGroupProps { + options, + value, + on_change, + label, + disabled, + direction, + apply, + } = props; + + let on_change = Rc::new(on_change); + let value = value.broadcast(); + let disabled = disabled.broadcast(); + let label = label.broadcast(); + let options = options.to_signal_cloned().broadcast(); + + let group_id = component_id("radio-group"); + let label_id = format!("{}-label", group_id); + + let radio_dom_id = { + let group_id = group_id.clone(); + move |key: &str| format!("{}-radio-{}", group_id, key) + }; + + html!("div", { + .attr("role", "radiogroup") + .attr("id", &group_id) + .attr("aria-labelledby", &label_id) + .dwclass!("flex flex-col gap-2") + .child(html!("div", { + .attr("id", &label_id) + .dwclass!("text-sm font-medium") + .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-700") + .visible_signal(label.signal_ref(|v| !v.is_empty())) + .text_signal(label.signal_cloned()) + })) + .child(html!("div", { + .apply(move |b| match direction { + RadioGroupDirection::Horizontal => dwclass!(b, "flex flex-row flex-wrap gap-4"), + RadioGroupDirection::Vertical => dwclass!(b, "flex flex-col gap-2"), + }) + .children_signal_vec(options.signal_cloned().map(clone!(value, disabled, on_change, radio_dom_id => move |options_vec| { + options_vec + .iter() + .enumerate() + .map(|(index, (key, option_label))| { + let key = key.clone(); + + let is_checked = value + .signal_cloned() + .map(clone!(key => move |v| v == key)) + .broadcast(); + + // Roving tabindex: the checked option is the tab stop; + // with nothing checked, the first option is. + let tab_stop = value + .signal_cloned() + .map(clone!(key, options_vec => move |v| { + if options_vec.iter().any(|(k, _)| *k == v) { + v == key + } else { + index == 0 + } + })); + + let radio_id = radio_dom_id(&key); + + html!("div", { + .dwclass!("flex flex-row align-items-center gap-2") + .child(html!("button", { + .attr("type", "button") + .attr("role", "radio") + .attr("id", &radio_id) + .attr_signal("aria-checked", is_checked.signal().map(|v| if v { "true" } else { "false" })) + .attr_signal("tabindex", tab_stop.map(|v| if v { "0" } else { "-1" })) + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) + .dwclass!("w-5 h-5 rounded-full cursor-pointer transition-colors flex-none p-0") + .dwclass!("flex align-items-center justify-center bg-transparent") + .dwclass!("border dwui-border-void-500 is(.light *):dwui-border-void-300") + .dwclass!("disabled:cursor-not-allowed disabled:opacity-60") + .dwclass!("dwui-focusable") + .dwclass_signal!("dwui-border-primary-500 is(.light *):dwui-border-primary-400", is_checked.signal()) + .child(html!("span", { + .dwclass!("w-2 h-2 rounded-full flex-none") + .dwclass!("dwui-bg-primary-500 is(.light *):dwui-bg-primary-400") + .style("transition", "opacity 100ms ease-out") + .style_signal("opacity", is_checked.signal().map(|v| if v { "1" } else { "0" })) + })) + .event(clone!(on_change, key => move |_: events::Click| { + (on_change)(key.clone()); + })) + .event_with_options(&EventOptions::preventable(), clone!(on_change, options_vec, radio_dom_id => move |e: events::KeyDown| { + let count = options_vec.len(); + + if count == 0 { + return; + } + + let target_index = match e.key().as_str() { + "ArrowRight" | "ArrowDown" => (index + 1) % count, + "ArrowLeft" | "ArrowUp" => (index + count - 1) % count, + "Home" => 0, + "End" => count - 1, + _ => return, + }; + + e.prevent_default(); + + let target_key = options_vec[target_index].0.clone(); + + // Selection follows focus (roving tabindex) + if let Some(element) = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id(&radio_dom_id(&target_key))) + { + if let Ok(element) = element.dyn_into::() { + let _ = element.focus(); + } + } + + (on_change)(target_key); + })) + })) + .child(html!("label", { + .attr("for", &radio_id) + .dwclass!("cursor-pointer select-none") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-800") + .text(option_label) + })) + }) + }) + .collect::>() + })).to_signal_vec()) + })) + .apply_if(apply.is_some(), |b| b.apply(apply.unwrap())) + }) +} diff --git a/crates/dwui/src/components/input/select.rs b/crates/dwui/src/components/input/select.rs index 7b483ae..075a16a 100644 --- a/crates/dwui/src/components/input/select.rs +++ b/crates/dwui/src/components/input/select.rs @@ -1,4 +1,4 @@ -use crate::mixins::labelled_rect_mixin::labelled_rect_mixin; +use crate::mixins::field_mixin::{field_error_row, field_surface_mixin}; use crate::prelude::{InputValueWrapper, ValidationResult}; use crate::theme::prelude::*; use crate::utils::component_id; @@ -9,7 +9,7 @@ use futures_signals::signal_vec::SignalVecExt; use futures_signals_component_macro::component; use web_sys::HtmlSelectElement; -#[component(render_function=select)] +#[component(render_fn = select)] struct Select { #[default(Box::new(Mutable::new("".to_string())))] value: dyn InputValueWrapper + 'static, @@ -22,6 +22,10 @@ struct Select { #[default("".to_string())] label: String, + #[signal] + #[default(false)] + disabled: bool, + #[signal] #[default(ValidationResult::Valid)] is_valid: ValidationResult, @@ -32,12 +36,14 @@ pub fn select(props: SelectProps) -> Dom { value, options, label, + disabled, is_valid, apply, } = props; let value_signal = value.value_signal_cloned().broadcast(); let is_valid = is_valid.broadcast(); + let disabled = disabled.broadcast(); let select_id = component_id("select"); let error_id = format!("{}-error", select_id); @@ -47,14 +53,23 @@ pub fn select(props: SelectProps) -> Dom { .broadcast(); html!("div", { - .dwclass!("grid h-10") - .children([ - html!("select" => HtmlSelectElement, { + .dwclass!("flex flex-col w-full") + .child(html!("div", { + .dwclass!("h-10 flex-none") + .dwclass_signal!("opacity-60", disabled.signal()) + .apply(field_surface_mixin( + label, + always(true), + is_valid.signal_cloned(), + select_id.clone(), + )) + .child(html!("select" => HtmlSelectElement, { .attr("id", &select_id) - .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 is(.light *):dwui-text-on-primary-800 text-base h-10 p-l-2") - .dwclass!("dwui-text-on-primary-300") - .dwclass!("grid-row-1 grid-col-1 cursor-pointer rounded-t-sm transition-all") - .dwclass!("dwui-focusable") + .dwclass!("w-full h-full bg-transparent border-none text-base p-l-3 p-t-3 cursor-pointer") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-900") + .dwclass_signal!("cursor-not-allowed", disabled.signal()) + .style("outline", "none") + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) .attr_signal("aria-invalid", is_valid_bool.signal().map(|valid| if valid { None } else { Some("true") })) .attr_signal("aria-describedby", is_valid_bool.signal().map({ let error_id = error_id.clone(); @@ -84,9 +99,9 @@ pub fn select(props: SelectProps) -> Dom { value.set(node.value()); }) }) - }) - ]) - .apply(labelled_rect_mixin(label, always(true), is_valid.signal_cloned(), select_id)) + })) + })) + .child(field_error_row(is_valid.signal_cloned(), &select_id)) .apply_if(apply.is_some(),|b| b.apply(apply.unwrap())) }) } diff --git a/crates/dwui/src/components/input/slider.rs b/crates/dwui/src/components/input/slider.rs index 987c9db..a1ba2ce 100644 --- a/crates/dwui/src/components/input/slider.rs +++ b/crates/dwui/src/components/input/slider.rs @@ -1,4 +1,4 @@ -use crate::mixins::labelled_rect_mixin::labelled_rect_mixin; +use crate::mixins::field_mixin::{field_error_row, field_surface_mixin}; use crate::prelude::{InputValueWrapper, ValidationResult}; use crate::theme::prelude::*; use crate::utils::component_id; @@ -30,6 +30,14 @@ struct Slider { #[signal] #[default("".to_string())] label: String, + + #[signal] + #[default(false)] + disabled: bool, + + #[signal] + #[default(ValidationResult::Valid)] + is_valid: ValidationResult, } pub fn slider(props: SliderProps) -> Dom { @@ -39,6 +47,8 @@ pub fn slider(props: SliderProps) -> Dom { max, step, label, + disabled, + is_valid, apply, } = props; @@ -47,19 +57,29 @@ pub fn slider(props: SliderProps) -> Dom { let min = min.broadcast(); let max = max.broadcast(); let label = label.broadcast(); + let disabled = disabled.broadcast(); + let is_valid = is_valid.broadcast(); let slider_id = component_id("slider"); html!("div", { - .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 text-base") - .dwclass!("grid rounded-t-sm") + .dwclass!("flex flex-col w-full") .child(html!("div", { - .dwclass!("flex flex-row grid-col-1 grid-row-1 w-full align-items-center gap-2 p-r-2") + .dwclass!("h-10 flex-none flex flex-row align-items-end gap-2 p-l-3 p-r-3") + .dwclass_signal!("opacity-60", disabled.signal()) + .apply(field_surface_mixin( + label.signal_cloned(), + always(true), + is_valid.signal_cloned(), + slider_id.clone(), + )) .child(html!("input" => HtmlInputElement, { .attr("id", &slider_id) - .dwclass!("h-10 grow cursor-pointer") + .dwclass!("h-6 grow cursor-pointer") + .dwclass_signal!("cursor-not-allowed", disabled.signal()) .attr("type", "range") .style("accent-color", "var(--dwui-primary-400)") + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) .attr_signal("value", value.value_signal_cloned()) .attr_signal("min", min.signal().map(|v| v.to_string())) .attr_signal("max", max.signal().map(|v| v.to_string())) @@ -75,9 +95,9 @@ pub fn slider(props: SliderProps) -> Dom { }) })) .child(html!("input" => HtmlInputElement, { - .dwclass!("w-16 text-center flex-none rounded-sm") - .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 text-base") - .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900") + .dwclass!("w-16 h-6 text-center flex-none rounded-sm bg-transparent border-none text-base") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-900") + .style("outline", "none") .attr_signal("aria-label", label.signal_ref(|label| { if label.is_empty() { "value".to_string() @@ -85,6 +105,7 @@ pub fn slider(props: SliderProps) -> Dom { format!("{} value", label) } })) + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) .attr_signal("min", min.signal().map(|v| v.to_string())) .attr_signal("max", max.signal().map(|v| v.to_string())) .attr("type", "number") @@ -99,7 +120,7 @@ pub fn slider(props: SliderProps) -> Dom { }) })) })) - .apply(labelled_rect_mixin(label.signal_cloned(), always(true), always(ValidationResult::Valid), slider_id)) + .child(field_error_row(is_valid.signal_cloned(), &slider_id)) .apply_if(apply.is_some(),|b| b.apply(apply.unwrap())) }) } diff --git a/crates/dwui/src/components/input/text_area.rs b/crates/dwui/src/components/input/text_area.rs new file mode 100644 index 0000000..5624121 --- /dev/null +++ b/crates/dwui/src/components/input/text_area.rs @@ -0,0 +1,151 @@ +use crate::components::input::validation::InputValueWrapper; +use crate::mixins::field_mixin::{field_error_row, field_surface_mixin}; +use crate::prelude::ValidationResult; +use crate::theme::prelude::*; +use crate::utils::component_id; +use dominator::{clone, events, html, with_node, Dom}; +use dwind::prelude::*; +use futures_signals::map_ref; +use futures_signals::signal::{and, not, or, Mutable, SignalExt}; +use futures_signals_component_macro::component; +use web_sys::HtmlTextAreaElement; + +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum TextAreaResize { + None, + Vertical, + Both, +} + +/// A multi-line text field sharing the labelled field surface of +/// [`text_input`](crate::components::input::text_input::text_input). +#[component(render_fn = text_area)] +struct TextArea { + #[default(Box::new(Mutable::new("".to_string())))] + value: dyn InputValueWrapper + 'static, + + #[signal] + #[default(ValidationResult::Valid)] + is_valid: ValidationResult, + + #[signal] + #[default("".to_string())] + label: String, + + #[signal] + #[default(4)] + rows: u32, + + #[signal] + #[default(false)] + disabled: bool, + + #[default(TextAreaResize::Vertical)] + resize: TextAreaResize, +} + +pub fn text_area(props: TextAreaProps) -> Dom { + let TextAreaProps { + value, + is_valid, + label, + rows, + disabled, + resize, + apply, + } = props; + + let label = label.broadcast(); + let disabled = disabled.broadcast(); + + let has_label = label.signal_ref(|v| v.len() > 0); + let has_value = value.value_signal_cloned().map(|v| v.len() > 0); + + let is_focused = Mutable::new(false); + let parsed_validation_result = Mutable::new(ValidationResult::Valid); + + let validation_signal = map_ref! { + let parse_result = parsed_validation_result.signal_cloned(), + let external_result = is_valid => { + if !parse_result.is_valid() { + parse_result.clone() + } else if !external_result.is_valid() { + external_result.clone() + } else { + ValidationResult::Valid + } + } + } + .broadcast(); + + let is_valid = validation_signal + .signal_ref(|validation| validation.is_valid()) + .broadcast(); + + let raise_label = and( + has_label, + or(or(is_focused.signal(), has_value), not(is_valid.signal())), + ); + + let input_id = component_id("text-area"); + let error_id = format!("{}-error", input_id); + + html!("div", { + .dwclass!("flex flex-col w-full") + .child(html!("div", { + .dwclass!("flex-none") + .dwclass_signal!("opacity-60", disabled.signal()) + .style("--dwui-field-label-top", "1.25rem") + .apply(field_surface_mixin( + label.signal_cloned(), + raise_label, + validation_signal.signal_cloned(), + input_id.clone(), + )) + .child(html!("textarea" => HtmlTextAreaElement, { + .attr("id", &input_id) + .dwclass!("w-full bg-transparent border-none text-base p-l-3 p-r-3 p-t-5 p-b-2 block") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-900") + .dwclass_signal!("cursor-not-allowed", disabled.signal()) + .style("outline", "none") + .style("font-family", "inherit") + .style("resize", match resize { + TextAreaResize::None => "none", + TextAreaResize::Vertical => "vertical", + TextAreaResize::Both => "both", + }) + .attr_signal("rows", rows.map(|v| v.to_string())) + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) + .attr_signal("aria-invalid", is_valid.signal().map(|valid| if valid { None } else { Some("true") })) + .attr_signal("aria-describedby", is_valid.signal().map(clone!(error_id => move |valid| { + if valid { + None + } else { + Some(error_id.clone()) + } + }))) + .with_node!(element => { + .future(value.value_signal_cloned().for_each(clone!(element => move |v| { + element.set_value(&v); + async move {} + }))) + .event(clone!(parsed_validation_result => move |_: events::Input| { + let result = value.set(element.value()); + + if !result.is_valid() { + parsed_validation_result.set(result); + } + })) + }) + .event(clone!(is_focused => move |_: events::FocusOut| { + is_focused.set(false); + })) + .event(clone!(is_focused => move |_: events::Focus| { + is_focused.set(true); + })) + })) + })) + .child(field_error_row(validation_signal.signal_cloned(), &input_id)) + .apply_if(apply.is_some(), |b| b.apply(apply.unwrap())) + }) +} diff --git a/crates/dwui/src/components/input/text_input.rs b/crates/dwui/src/components/input/text_input.rs index 9cd4fa8..92d582a 100644 --- a/crates/dwui/src/components/input/text_input.rs +++ b/crates/dwui/src/components/input/text_input.rs @@ -1,5 +1,5 @@ use crate::components::input::validation::InputValueWrapper; -use crate::mixins::labelled_rect_mixin::labelled_rect_mixin; +use crate::mixins::field_mixin::{field_error_row, field_surface_mixin}; use crate::prelude::ValidationResult; use crate::theme::prelude::*; use crate::utils::component_id; @@ -28,6 +28,10 @@ struct TextInput { #[default("".to_string())] label: String, + #[signal] + #[default(false)] + disabled: bool, + #[default(Box::new(|| {}))] on_submit: dyn (FnMut() -> ()) + 'static, @@ -44,6 +48,7 @@ pub fn text_input(props: TextInputProps) -> Dom { value, is_valid, label, + disabled, mut on_submit, input_type, claim_focus, @@ -51,6 +56,7 @@ pub fn text_input(props: TextInputProps) -> Dom { } = props; let label = label.broadcast(); + let disabled = disabled.broadcast(); let has_label = label.signal_ref(|v| v.len() > 0); @@ -86,18 +92,23 @@ pub fn text_input(props: TextInputProps) -> Dom { let error_id = format!("{}-error", input_id); html!("div", { - .dwclass!("grid") - .children([ - html!("input" => HtmlInputElement, { + .dwclass!("flex flex-col w-full") + .child(html!("div", { + .dwclass!("h-10 flex-none") + .dwclass_signal!("opacity-60", disabled.signal()) + .apply(field_surface_mixin( + label.signal_cloned(), + raise_label, + validation_signal.signal_cloned(), + input_id.clone(), + )) + .child(html!("input" => HtmlInputElement, { .attr("id", &input_id) - .dwclass!("text-base transition-all") - .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 text-base") - .dwclass!("p-l-2 rounded-t-sm") - .dwclass!("grid-col-1 grid-row-1") - .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900") - .dwclass_signal!("h-10", is_valid.signal()) - .dwclass_signal!("h-6", not(is_valid.signal())) - .dwclass!("dwui-focusable") + .dwclass!("w-full h-full bg-transparent border-none text-base p-l-3 p-r-3 p-t-3") + .dwclass!("dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-900") + .dwclass_signal!("cursor-not-allowed", disabled.signal()) + .style("outline", "none") + .attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None })) .attr_signal("aria-invalid", is_valid.signal().map(|valid| if valid { None } else { Some("true") })) .attr_signal("aria-describedby", is_valid.signal().map(clone!(error_id => move |valid| { if valid { @@ -137,10 +148,9 @@ pub fn text_input(props: TextInputProps) -> Dom { on_submit() } }) - }), - ]) - - .apply(labelled_rect_mixin(label.signal_cloned(), raise_label, validation_signal.signal_cloned(), input_id)) + })) + })) + .child(field_error_row(validation_signal.signal_cloned(), &input_id)) .apply_if(apply.is_some(),|b| b.apply(apply.unwrap())) }) } diff --git a/crates/dwui/src/mixins/field_mixin.rs b/crates/dwui/src/mixins/field_mixin.rs new file mode 100644 index 0000000..28776a3 --- /dev/null +++ b/crates/dwui/src/mixins/field_mixin.rs @@ -0,0 +1,112 @@ +use crate::prelude::ValidationResult; +use crate::theme::prelude::*; +use dominator::{html, Dom, DomBuilder}; +use dwind::prelude::*; +use futures_signals::signal::Signal; +use futures_signals::signal::SignalExt; +use futures_signals::signal::not; +use web_sys::HtmlElement; + +/// Styles a field surface and draws its floating label. +/// +/// Apply this to the element that wraps the form control. The surface gets the +/// filled-field look (`dwui-field-surface`, so `:focus-within` draws the focus +/// ring from the base stylesheet), a state-colored underline, and a label that +/// floats between the field's center and its top edge purely via `transform` — +/// it works for any label length, font, or script. +/// +/// The control inside the surface should be transparent (`bg-transparent`), +/// fill the surface (`w-full h-full`), and leave room for the raised label +/// (`p-t-3`). Pair the surface with [`field_error_row`] so validation messages +/// have reserved space below the field. +/// +/// `input_id` is the id of the inner form control; it associates the +/// `