diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index e56aafa..116fec9 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -14,6 +14,12 @@ on:
env:
CARGO_TERM_COLOR: always
+ # The browser gets this many seconds to load the test module and report back.
+ # The default 20s is measured against a debug wasm that grows with every
+ # component and a runner slower than any dev machine, so raise it: a suite
+ # that has merely outgrown the budget should show up as a slow run, never as
+ # "Failed to detect test as having been run".
+ WASM_BINDGEN_TEST_TIMEOUT: 120
jobs:
build:
@@ -31,7 +37,7 @@ jobs:
# Optional version of wasm-bindgen to install (eg. "0.2.68", "latest").
# Must match the wasm-bindgen version in Cargo.lock, or the
# wasm test runner refuses the produced wasm artifacts.
- version: 0.2.105
+ version: 0.2.126
- name: rust-toolchain
# You may pin to the exact commit or the version.
# uses: actions-rs/toolchain@b2417cde72dcf67f306c0ae8e0828a81bf0b189f
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8336218..08113fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,83 @@
# dwind changelog
+## dwui 0.10.0 - 2026-07-26
+
+A visual and accessibility overhaul of the whole component suite, plus eight
+new components. Breaking where it needed to be — dwui is 0.x and the gallery
+app migrates in the same commit series.
+
+Ships together with the dwind 0.8.0 entry below, and carries the dwui 0.9.1
+changes from it — 0.9.1 was never published, so 0.10.0 supersedes it.
+
+Requires wasm-bindgen 0.2.126 or newer: 0.2.105 could derive the same JS
+identifier for two closure shims, which left the generated module unparseable
+depending on the exact symbols the compiler emitted.
+
+### Design-language foundation
+
+- The global `*:focus { outline: none }` reset is gone. Interactive elements
+ now carry `dwui-focusable` (an outline-based `:focus-visible` ring) and field
+ wrappers carry `dwui-field-surface` (`:focus-within` ring). Because the ring
+ is an outline, it can no longer be silently defeated by `shadow-*` utilities
+ or inline box-shadows — the box-shadow-based `dwui-ring-*` maps are removed.
+ Text inputs, selects, sliders, the date-picker field, and breadcrumb links
+ are keyboard-visible for the first time.
+- New semantic tokens: `--dwui-on-accent` (color painted on primary-filled
+ controls; `ColorsCssVariables::with_on_accent` to override), tinted
+ `dwui-surface-{info,success,warning,error}` backgrounds via `color-mix`, and
+ a `dwui-scrim` backdrop. Hard-coded whites (switch knob, checkbox check,
+ badge text) and the modal's rgba backdrop now follow the theme.
+- Documented radius / control-height / padding / transition scales and a
+ `theme::layers` z-index scale (tooltip 30, overlay 40, modal 50, toast 60).
+
+### Field system
+
+- `labelled_rect_mixin` (the notched outline measured as `label.len() * 9px`)
+ is replaced by `mixins::field_mixin::{field_surface_mixin, field_error_row}`:
+ a filled surface with a floating label positioned purely by transform, and an
+ always-present error row so validation never shifts layout. The invalid
+ text input no longer shrinks from h-10 to h-6.
+- `text_input`, `select`, and `slider` gain a `disabled` prop; `slider` gains a
+ real `is_valid` prop; `select`'s `render_function=` oddity is now
+ `render_fn=`.
+
+### Control chrome
+
+- Slider track and thumb are properly themed (per-vendor pseudo-element rules,
+ UA-gated so neither engine sees the other's selectors), with the filled
+ portion driven by `--dwui-slider-fill`. Select drops native chrome for a
+ themed chevron. The switch knob animates by transform.
+
+### Surfaces and widgets
+
+- Card: default `p-4` padding (`CardPadding` to adjust), dead
+ `ColorScheme::Secondary` removed, Primary scheme completed for light mode.
+- Modal: scrim token, rem-based sizes, and a Tab/Shift-Tab focus trap.
+- Alert: per-variant tinted surfaces; dismiss button is an icon, not `×`.
+- Heading: renders the bare `h1`-`h6` (no wrapper div, no fixed height) and
+ takes theme colors.
+- Button: **no longer forces `w-full`** (opt back in via `apply`) and uses
+ `rounded-md` instead of `rounded-full`; tab underline is a real border so it
+ coexists with focus rings; spinner sizes moved to the rem scale.
+
+### New components
+
+`text_area!`, `number_input!`, `radio_group!`, `pagination!`, `popover!`,
+`dropdown_menu!`, `drawer!`, and `toasts!` + `Toaster` — all themeable,
+keyboard-accessible, and covered by the headless browser suite. dwui now
+depends on `gloo-timers` and `wasm-bindgen-futures` for toast auto-dismiss and
+menu focus management.
+
+### Overlays portal to the body
+
+`modal!`, `drawer!`, and `toasts!` render their fixed-position layer as a
+direct child of `document.body` (falling back to the document element when an
+app has replaced `
` wholesale). `position: fixed` resolves against the
+nearest transformed ancestor, so overlays rendered in place broke inside
+anything with a transform, filter, or page-transition animation — the gallery's
+own route transition captured them. The component's mount point now only hosts
+an invisible placeholder; unmounting it tears the portal down.
+
## dwind 0.8.0 / dwind-macros 0.5.0 / dwind-base 0.1.2 / dwui 0.9.1 - 2026-07-25
Everything here is additive. The theme: raw CSS in an application was almost
diff --git a/Cargo.lock b/Cargo.lock
index c58571e..de7417b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,23 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "async-trait"
+version = "0.1.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -59,6 +76,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6c0e7b807d60291f42f33f58480c0bfafe28ed08286446f45e463728cf9c1c"
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
[[package]]
name = "cc"
version = "1.2.46"
@@ -75,6 +98,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
[[package]]
name = "const_format"
version = "0.2.35"
@@ -133,7 +166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -195,6 +228,7 @@ dependencies = [
name = "dominator-css-bindgen-ui"
version = "0.1.0"
dependencies = [
+ "console_error_panic_hook",
"const_format",
"dominator",
"dwind",
@@ -273,15 +307,16 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
"wasm-bindgen-test",
]
[[package]]
name = "dwui"
-version = "0.9.1"
+version = "0.10.0"
dependencies = [
"const_format",
+ "discard",
"dominator",
"dominator-css-bindgen",
"dwind",
@@ -289,6 +324,7 @@ dependencies = [
"futures",
"futures-signals",
"futures-signals-component-macro",
+ "gloo-timers",
"once_cell",
"serde",
"wasm-bindgen-futures",
@@ -309,7 +345,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
"syntect",
]
@@ -402,7 +438,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -431,7 +467,7 @@ dependencies = [
"dominator",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -472,7 +508,7 @@ checksum = "913dce4c5f06c2ea40fc178c06f777ac89fc6b1383e90c254fafb1abe4ba3c82"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
"uuid",
]
@@ -534,11 +570,12 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
-version = "0.3.82"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
- "once_cell",
+ "cfg-if",
+ "futures-util",
"wasm-bindgen",
]
@@ -548,6 +585,12 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@@ -574,9 +617,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "minicov"
-version = "0.3.7"
+version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b"
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
@@ -619,18 +662,43 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
[[package]]
name = "phf"
version = "0.11.3"
@@ -661,7 +729,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -690,7 +758,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -731,7 +799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -847,7 +915,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -904,6 +972,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "syntect"
version = "5.3.0"
@@ -951,7 +1030,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -962,7 +1041,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
[[package]]
@@ -1046,9 +1125,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.105"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -1059,22 +1138,19 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.55"
+version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
- "cfg-if",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.105"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1082,50 +1158,65 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.105"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.105"
+version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
-version = "0.3.55"
+version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfc379bfb624eb59050b509c13e77b4eb53150c350db69628141abce842f2373"
+checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4"
dependencies = [
+ "async-trait",
+ "cast",
"js-sys",
+ "libm",
"minicov",
+ "nu-ansi-term",
+ "num-traits",
+ "oorandom",
+ "serde",
+ "serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
+ "wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
-version = "0.3.55"
+version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "085b2df989e1e6f9620c1311df6c996e83fe16f57792b272ce1e024ac16a90f1"
+checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.110",
]
+[[package]]
+name = "wasm-bindgen-test-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920"
+
[[package]]
name = "wasm-log"
version = "0.3.1"
@@ -1139,9 +1230,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.82"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
diff --git a/Cargo.toml b/Cargo.toml
index 80b88bb..d7706d2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,3 +52,14 @@ web-sys = { version = "0.3", features = [
"Window",
"HtmlSelectElement",
] }
+
+# Debug info dominates a wasm test binary: the components suite compiled to
+# 149 MB with full debug info, of which only ~3 MB was code. Line tables keep
+# panic backtraces pointing at real files and lines while taking the binary to
+# 28 MB, so every browser test run fetches and instantiates a fifth of the
+# bytes.
+[profile.dev]
+debug = "line-tables-only"
+
+[profile.test]
+debug = "line-tables-only"
diff --git a/crates/dwui/Cargo.toml b/crates/dwui/Cargo.toml
index 47981f6..2b2d217 100644
--- a/crates/dwui/Cargo.toml
+++ b/crates/dwui/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "dwui"
-version = "0.9.1"
+version = "0.10.0"
edition = "2021"
description = "UI Component library built on the DWIND style crate!"
homepage = "https://github.com/JedimEmO/dwind"
@@ -15,8 +15,11 @@ dwind = { path = "../dwind", version = "0.8.0" }
dwind-macros = { path = "../dwind-macros", version = "0.5.0" }
futures-signals = { workspace = true }
futures-signals-component-macro = { workspace = true }
+gloo-timers = { workspace = true, features = ["futures"] }
+wasm-bindgen-futures = { workspace = true }
once_cell = { workspace = true }
-web-sys = { workspace = true }
+web-sys = { workspace = true, features = ["NodeList"] }
+discard = "1"
[dev-dependencies]
futures = { workspace = true }
@@ -27,10 +30,13 @@ web-sys = { workspace = true, features = [
"CssRule",
"CssRuleList",
"CssStyleDeclaration",
+ "CssStyleRule",
"CssStyleSheet",
"Document",
"HtmlElement",
"HtmlInputElement",
+ "KeyboardEvent",
+ "KeyboardEventInit",
"NodeList",
"StyleSheet",
"StyleSheetList",
diff --git a/crates/dwui/resources/css/base.css b/crates/dwui/resources/css/base.css
index 27f4ef4..7ed5c1d 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/containers/card.rs b/crates/dwui/src/components/containers/card.rs
index 24789f6..3749ce7 100644
--- a/crates/dwui/src/components/containers/card.rs
+++ b/crates/dwui/src/components/containers/card.rs
@@ -8,10 +8,18 @@ use futures_signals_component_macro::component;
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum ColorScheme {
Primary,
- Secondary,
Void,
}
+/// Content padding of a [`card`]
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum CardPadding {
+ None,
+ Small,
+ Medium,
+ Large,
+}
+
#[component(render_fn = card)]
struct Card {
#[signal]
@@ -20,24 +28,33 @@ struct Card {
#[signal]
#[default(ColorScheme::Void)]
scheme: ColorScheme,
+
+ #[signal]
+ #[default(CardPadding::Medium)]
+ padding: CardPadding,
}
pub fn card(props: CardProps) -> Dom {
let CardProps {
content,
scheme,
+ padding,
apply,
} = props;
let scheme = scheme.broadcast();
+ let padding = padding.broadcast();
html!("div", {
- .dwclass!("rounded-lg w-full shadow-lg transition-all")
+ .dwclass!("rounded-lg w-full shadow-lg")
.dwclass!("border dwui-border-void-800 is(.light *):dwui-border-void-300")
.dwclass_signal!("dwui-bg-void-900 dwui-text-on-primary-200", scheme.signal().map(|v| v == ColorScheme::Void))
.dwclass_signal!("is(.light *):dwui-bg-void-200 is(.light *):dwui-text-on-primary-800", scheme.signal().map(|v| v == ColorScheme::Void))
- .dwclass_signal!("dwui-bg-primary-900", scheme.signal().map(|v| v == ColorScheme::Primary))
- //.dwclass_signal!("dwui-bg-secondary-900", scheme.signal().map(|v| v == ColorScheme::Secondary))
+ .dwclass_signal!("dwui-bg-primary-900 dwui-text-on-primary-100 dwui-border-primary-800", scheme.signal().map(|v| v == ColorScheme::Primary))
+ .dwclass_signal!("is(.light *):dwui-bg-primary-100 is(.light *):dwui-text-on-primary-900 is(.light *):dwui-border-primary-300", scheme.signal().map(|v| v == ColorScheme::Primary))
+ .dwclass_signal!("p-2", padding.signal().map(|v| v == CardPadding::Small))
+ .dwclass_signal!("p-4", padding.signal().map(|v| v == CardPadding::Medium))
+ .dwclass_signal!("p-6", padding.signal().map(|v| v == CardPadding::Large))
.apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
.child_signal(option(content))
})
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/heading.rs b/crates/dwui/src/components/content/heading.rs
index 2790723..5d9d7f8 100644
--- a/crates/dwui/src/components/content/heading.rs
+++ b/crates/dwui/src/components/content/heading.rs
@@ -1,7 +1,6 @@
+use crate::theme::prelude::*;
use dominator::{html, Dom};
-use dwind::flexbox_and_grid::*;
-use dwind::sizing::*;
-use dwind::typography::*;
+use dwind::prelude::*;
use futures_signals::signal::{option, SignalExt};
use futures_signals_component_macro::component;
@@ -78,15 +77,14 @@ pub fn heading(props: HeadingProps) -> Dom {
} = props;
let size = size.broadcast();
- html!("div", {
+
+ html!(level.tag_name(), {
+ .dwclass_signal!("text-base", size.signal().eq(TextSize::Base))
+ .dwclass_signal!("text-l", size.signal().eq(TextSize::Large))
+ .dwclass_signal!("text-xl", size.signal().eq(TextSize::ExtraLarge))
+ .dwclass!("w-auto font-semibold m-0")
+ .dwclass!("dwui-text-on-primary-100 is(.light *):dwui-text-on-primary-900")
.apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
- .dwclass!("w-auto font-semibold h-12 align-items-center flex")
- .child(html!(level.tag_name(), {
- .dwclass_signal!("text-base", size.signal().eq(TextSize::Base))
- .dwclass_signal!("text-l", size.signal().eq(TextSize::Large))
- .dwclass_signal!("text-xl", size.signal().eq(TextSize::ExtraLarge))
- .dwclass!("w-auto font-semibold")
- .child_signal(option(content))
- }))
+ .child_signal(option(content))
})
}
diff --git a/crates/dwui/src/components/content/list.rs b/crates/dwui/src/components/content/list.rs
index e961171..01e9d2f 100644
--- a/crates/dwui/src/components/content/list.rs
+++ b/crates/dwui/src/components/content/list.rs
@@ -51,9 +51,7 @@ 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")
- .style("padding-left", "10px")
- .style("outline", "none")
+ .dwclass!("transition-colors dwui-focusable p-l-2")
.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..c64d8b7 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")
@@ -78,7 +77,7 @@ pub fn checkbox(props: CheckboxProps) -> Dom {
.attr("aria-hidden", "true")
.child(svg!("path", {
.attr("d", "M2 6.5 L4.5 9 L10 3")
- .attr("stroke", "white")
+ .attr("style", "stroke: var(--dwui-on-accent)")
.attr("stroke-width", "2")
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
diff --git a/crates/dwui/src/components/input/date_picker.rs b/crates/dwui/src/components/input/date_picker.rs
index c1372bf..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,22 +68,34 @@ 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")
.style("outline", "none")
.child(html!("span", {
@@ -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)
})
@@ -279,8 +285,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 +335,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/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..bfdcbfa
--- /dev/null
+++ b/crates/dwui/src/components/input/number_input.rs
@@ -0,0 +1,234 @@
+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 declaration only — no vendor pseudo-element selectors,
+ // and no vendor-prefixed properties (dominator panics when an
+ // engine rejects a property; `appearance` is universal now).
+ .style("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 5819b72..2b804a1 100644
--- a/crates/dwui/src/components/input/select.rs
+++ b/crates/dwui/src/components/input/select.rs
@@ -1,15 +1,15 @@
-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;
-use dominator::{events, html, with_node, Dom};
+use dominator::{events, html, svg, with_node, Dom};
use dwind::prelude::*;
use futures_signals::signal::{always, Mutable, SignalExt};
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,26 @@ 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")
+ .class("dwui-select")
+ .dwclass!("w-full h-full bg-transparent border-none text-base p-l-3 p-r-8 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")
+ // The themed chevron beside this select replaces the native one
+ .style("appearance", "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 +102,30 @@ pub fn select(props: SelectProps) -> Dom {
value.set(node.value());
})
})
- })
- ])
- .apply(labelled_rect_mixin(label, always(true), is_valid.signal_cloned(), select_id))
+ }))
+ .child(html!("span", {
+ .attr("aria-hidden", "true")
+ .dwclass!("absolute pointer-events-none flex align-items-center")
+ .dwclass!("dwui-text-on-primary-400 is(.light *):dwui-text-on-primary-600")
+ .style("right", "0.75rem")
+ .style("top", "50%")
+ .style("transform", "translateY(-50%)")
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 12 12")
+ .attr("width", "12")
+ .attr("height", "12")
+ .attr("fill", "none")
+ .child(svg!("path", {
+ .attr("d", "M2 4 L6 8 L10 4")
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ .attr("stroke-linejoin", "round")
+ }))
+ }))
+ }))
+ }))
+ .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..ff07136 100644
--- a/crates/dwui/src/components/input/slider.rs
+++ b/crates/dwui/src/components/input/slider.rs
@@ -1,9 +1,10 @@
-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;
use dominator::{clone, events, html, with_node, Dom};
use dwind::prelude::*;
+use futures_signals::map_ref;
use futures_signals::signal::SignalExt;
use futures_signals::signal::{always, Mutable};
use futures_signals_component_macro::component;
@@ -30,6 +31,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 +48,8 @@ pub fn slider(props: SliderProps) -> Dom {
max,
step,
label,
+ disabled,
+ is_valid,
apply,
} = props;
@@ -47,19 +58,40 @@ 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")
+ .class("dwui-slider")
+ .dwclass!("h-6 grow cursor-pointer dwui-focusable")
+ .dwclass_signal!("cursor-not-allowed", disabled.signal())
.attr("type", "range")
- .style("accent-color", "var(--dwui-primary-400)")
+ .style_signal("--dwui-slider-fill", map_ref! {
+ let value = value.value_signal_cloned(),
+ let min = min.signal(),
+ let max = max.signal() => {
+ let value = value.parse::().unwrap_or(*min);
+ let range = (*max - *min).max(f32::EPSILON);
+ let fraction = ((value - *min) / range).clamp(0.0, 1.0);
+
+ format!("{}%", fraction * 100.0)
+ }
+ })
+ .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 +107,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 +117,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 +132,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/switch.rs b/crates/dwui/src/components/input/switch.rs
index 5a91637..80eb6c5 100644
--- a/crates/dwui/src/components/input/switch.rs
+++ b/crates/dwui/src/components/input/switch.rs
@@ -60,17 +60,19 @@ 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")
+ .dwclass!("w-5 h-5 rounded-full dwui-bg-on-accent block shadow-md")
.style("position", "absolute")
.style("top", "2px")
- .style("transition", "left 150ms ease-out")
- .style_signal("left", checked.signal().map(|v| if v { "22px" } else { "2px" }))
+ .style("left", "2px")
+ .style("transition", "transform 150ms ease-out")
+ .style_signal("transform", checked.signal().map(|v| {
+ if v { "translateX(1.25rem)" } else { "translateX(0)" }
+ }))
}))
.event(clone!(checked_state => move |_: events::Click| {
(on_change)(!checked_state.get());
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 284bf48..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!("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/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..a2dc106 100644
--- a/crates/dwui/src/components/widgets/alert.rs
+++ b/crates/dwui/src/components/widgets/alert.rs
@@ -75,13 +75,12 @@ pub fn alert(props: AlertProps) -> Dom {
AlertVariant::Warning | AlertVariant::Error => "alert",
_ => "status",
}))
- .dwclass!("flex flex-row gap-3 p-4 rounded-md border w-full")
- .dwclass_signal!("dwui-border-primary-700 is(.light *):dwui-border-primary-300", variant.signal().map(|v| v == AlertVariant::Info))
- .dwclass_signal!("dwui-border-success-700 is(.light *):dwui-border-success-400", variant.signal().map(|v| v == AlertVariant::Success))
- .dwclass_signal!("dwui-border-warning-600 is(.light *):dwui-border-warning-400", variant.signal().map(|v| v == AlertVariant::Warning))
- .dwclass_signal!("dwui-border-error-700 is(.light *):dwui-border-error-400", variant.signal().map(|v| v == AlertVariant::Error))
- .dwclass_signal!("dwui-border-void-700 is(.light *):dwui-border-void-300", variant.signal().map(|v| v == AlertVariant::Neutral))
- .dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-100")
+ .dwclass!("flex flex-row gap-3 p-4 rounded-lg border w-full")
+ .dwclass_signal!("dwui-border-primary-700 dwui-surface-info is(.light *):dwui-border-primary-300", variant.signal().map(|v| v == AlertVariant::Info))
+ .dwclass_signal!("dwui-border-success-700 dwui-surface-success is(.light *):dwui-border-success-400", variant.signal().map(|v| v == AlertVariant::Success))
+ .dwclass_signal!("dwui-border-warning-600 dwui-surface-warning is(.light *):dwui-border-warning-400", variant.signal().map(|v| v == AlertVariant::Warning))
+ .dwclass_signal!("dwui-border-error-700 dwui-surface-error is(.light *):dwui-border-error-400", variant.signal().map(|v| v == AlertVariant::Error))
+ .dwclass_signal!("dwui-border-void-700 dwui-bg-void-900 is(.light *):dwui-border-void-300 is(.light *):dwui-bg-void-100", variant.signal().map(|v| v == AlertVariant::Neutral))
.dwclass!("dwui-text-on-primary-100 is(.light *):dwui-text-on-primary-900")
// Icon
.child(html!("div", {
@@ -140,9 +139,20 @@ 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")
- .text("×")
+ .dwclass!("dwui-focusable")
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 12 12")
+ .attr("width", "12")
+ .attr("height", "12")
+ .attr("fill", "none")
+ .attr("aria-hidden", "true")
+ .child(svg!("path", {
+ .attr("d", "M2 2 L10 10 M10 2 L2 10")
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ }))
+ }))
.event(clone!(dismissed, on_dismiss => move |_: events::Click| {
dismissed.set(true);
(on_dismiss)();
diff --git a/crates/dwui/src/components/widgets/badge.rs b/crates/dwui/src/components/widgets/badge.rs
index 8ba1c5c..d4a41dd 100644
--- a/crates/dwui/src/components/widgets/badge.rs
+++ b/crates/dwui/src/components/widgets/badge.rs
@@ -40,7 +40,8 @@ pub fn badge(props: BadgeProps) -> Dom {
.dwclass_signal!("dwui-bg-error-700 is(.light *):dwui-bg-error-300", variant.signal().map(|v| v == BadgeVariant::Error))
.dwclass_signal!("dwui-bg-void-700 dwui-text-on-primary-200 is(.light *):dwui-bg-void-300 is(.light *):dwui-text-on-primary-900", variant.signal().map(|v| v == BadgeVariant::Void))
.dwclass_signal!("border dwui-border-primary-500 bg-transparent is(.light *):dwui-border-primary-400", variant.signal().map(|v| v == BadgeVariant::Outline))
- .dwclass_signal!("text-white", variant.signal().map(|v| v == BadgeVariant::Error))
+ .dwclass_signal!("dwui-text-primary-300 is(.light *):dwui-text-primary-700", variant.signal().map(|v| v == BadgeVariant::Outline))
+ .dwclass_signal!("dwui-text-on-accent", variant.signal().map(|v| v == BadgeVariant::Error))
.child_signal(content)
.apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
})
diff --git a/crates/dwui/src/components/widgets/button.rs b/crates/dwui/src/components/widgets/button.rs
index 38a33cb..9dc74f8 100644
--- a/crates/dwui/src/components/widgets/button.rs
+++ b/crates/dwui/src/components/widgets/button.rs
@@ -66,10 +66,9 @@ pub fn button(props: ButtonProps) -> Dom {
.dwclass_signal!("h-12 text-l", size.signal().map(|v| v == ButtonSize::Large))
.dwclass!("disabled:dwui-text-on-primary-500 disabled:hover:dwui-border-primary-800 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:brightness-100")
.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!("font-bold p-l-3 p-r-3 cursor-pointer rounded-md pointer-events-auto")
+ .style("transition", "filter 150ms ease, background-color 150ms ease, border-color 150ms ease, color 150ms ease")
+ .dwclass!("dwui-focusable")
.apply_if(apply.is_some(), move |b| {
b.apply(apply.unwrap())
})
diff --git a/crates/dwui/src/components/widgets/drawer.rs b/crates/dwui/src/components/widgets/drawer.rs
new file mode 100644
index 0000000..0051c55
--- /dev/null
+++ b/crates/dwui/src/components/widgets/drawer.rs
@@ -0,0 +1,196 @@
+use crate::components::widgets::modal::trap_focus;
+use crate::theme::layers;
+use crate::theme::prelude::*;
+use dominator::{clone, events, html, svg, with_node, Dom, EventOptions};
+use dwind::prelude::*;
+use futures_signals::signal::{Mutable, SignalExt};
+use futures_signals_component_macro::component;
+use std::sync::Arc;
+
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum DrawerSide {
+ Left,
+ Right,
+}
+
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum DrawerSize {
+ Small,
+ Medium,
+ Large,
+}
+
+/// A modal side panel sliding in from the left or right viewport edge.
+///
+/// Shares the modal's interaction contract: scrim backdrop (click to close,
+/// configurable), Escape to close, `role="dialog"` with `aria-modal`, and a
+/// Tab/Shift-Tab focus trap. The open state is controlled by the caller.
+#[component(render_fn = drawer)]
+struct Drawer {
+ #[signal]
+ #[default(None)]
+ content: Option,
+
+ #[signal]
+ #[default(false)]
+ open: bool,
+
+ #[default(Box::new(|| {}))]
+ on_close: dyn Fn() -> () + 'static,
+
+ #[signal]
+ #[default(DrawerSide::Right)]
+ side: DrawerSide,
+
+ #[signal]
+ #[default(DrawerSize::Medium)]
+ size: DrawerSize,
+
+ #[signal]
+ #[default(true)]
+ close_on_backdrop_click: bool,
+
+ #[signal]
+ #[default("Drawer".to_string())]
+ aria_label: String,
+}
+
+pub fn drawer(props: DrawerProps) -> Dom {
+ let DrawerProps {
+ content,
+ open,
+ on_close,
+ side,
+ size,
+ close_on_backdrop_click,
+ aria_label,
+ apply,
+ } = props;
+
+ let on_close = Arc::new(on_close);
+ let open = open.broadcast();
+ let side = side.broadcast();
+ let size = size.broadcast();
+ let is_open = Mutable::new(false);
+
+ // Portalled to the body so a transformed ancestor can never capture the
+ // fixed positioning (see utils::body_portal).
+ crate::utils::body_portal(html!("div", {
+ .visible_signal(open.signal())
+ .future(open.signal().for_each(clone!(is_open => move |open_val| {
+ is_open.set(open_val);
+ async {}
+ })))
+ .global_event(clone!(on_close, is_open => move |e: events::KeyDown| {
+ if is_open.get() && e.key() == "Escape" {
+ (on_close)();
+ }
+ }))
+ .style("position", "fixed")
+ .style("inset", "0")
+ .style("z-index", layers::MODAL)
+
+ // Backdrop
+ .child_signal(close_on_backdrop_click.map(clone!(on_close => move |clickable| {
+ Some(html!("div", {
+ .dwclass!("dwui-scrim")
+ .style("position", "absolute")
+ .style("inset", "0")
+ .style("backdrop-filter", "blur(4px)")
+ .style("animation", "dwui-fade-in 150ms ease-out")
+ .style("z-index", "1")
+ .apply_if(clickable, clone!(on_close => move |b| {
+ b.event(clone!(on_close => move |_: events::Click| {
+ (on_close)();
+ }))
+ }))
+ }))
+ })))
+
+ // Panel
+ .child(html!("div", {
+ .attr("role", "dialog")
+ .attr("aria-modal", "true")
+ .attr("tabindex", "-1")
+ .attr_signal("aria-label", aria_label)
+ .focused_signal(open.signal())
+ .dwclass!("dwui-bg-void-900 dwui-text-on-primary-200 shadow-2xl p-6")
+ .dwclass!("is(.light *):dwui-bg-void-100 is(.light *):dwui-text-on-primary-800")
+ .dwclass!("dwui-focusable")
+ .style("position", "absolute")
+ .style("top", "0")
+ .style("bottom", "0")
+ .style("overflow-y", "auto")
+ .style("z-index", "10")
+ .style("max-width", "90vw")
+ .style_signal("width", size.signal().map(|s| match s {
+ DrawerSize::Small => "20rem",
+ DrawerSize::Medium => "28rem",
+ DrawerSize::Large => "36rem",
+ }))
+ .style_signal("left", side.signal().map(|s| match s {
+ DrawerSide::Left => Some("0"),
+ DrawerSide::Right => None,
+ }))
+ .style_signal("right", side.signal().map(|s| match s {
+ DrawerSide::Right => Some("0"),
+ DrawerSide::Left => None,
+ }))
+ .style_signal("border-left", side.signal().map(|s| match s {
+ DrawerSide::Right => Some("1px solid var(--dwui-void-700)"),
+ DrawerSide::Left => None,
+ }))
+ .style_signal("border-right", side.signal().map(|s| match s {
+ DrawerSide::Left => Some("1px solid var(--dwui-void-700)"),
+ DrawerSide::Right => None,
+ }))
+ .style_signal("animation", side.signal().map(|s| match s {
+ DrawerSide::Right => "dwui-slide-in-right 200ms ease-out",
+ DrawerSide::Left => "dwui-slide-in-left 200ms ease-out",
+ }))
+ // Focus trap: Tab and Shift-Tab wrap within the drawer
+ .with_node!(panel_node => {
+ .event_with_options(&EventOptions::preventable(), clone!(panel_node => move |e: events::KeyDown| {
+ trap_focus(&panel_node, &e);
+ }))
+ })
+ .apply_if(apply.is_some(), move |b| {
+ b.apply(apply.unwrap())
+ })
+
+ // Close button
+ .child(html!("button", {
+ .attr("type", "button")
+ .attr("aria-label", "Close drawer")
+ .dwclass!("w-8 h-8 flex justify-center align-items-center rounded-full")
+ .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 bg-transparent border-none p-0 m-0")
+ .dwclass!("dwui-focusable")
+ .style("position", "absolute")
+ .style("top", "1rem")
+ .style("right", "1rem")
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 14 14")
+ .attr("width", "14")
+ .attr("height", "14")
+ .attr("fill", "none")
+ .attr("aria-hidden", "true")
+ .child(svg!("path", {
+ .attr("d", "M2 2 L12 12 M12 2 L2 12")
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ }))
+ }))
+ .event(clone!(on_close => move |e: events::Click| {
+ e.stop_propagation();
+ (on_close)();
+ }))
+ }))
+
+ // Drawer content
+ .child_signal(content)
+ }))
+ }))
+}
diff --git a/crates/dwui/src/components/widgets/dropdown_menu.rs b/crates/dwui/src/components/widgets/dropdown_menu.rs
new file mode 100644
index 0000000..927c7b4
--- /dev/null
+++ b/crates/dwui/src/components/widgets/dropdown_menu.rs
@@ -0,0 +1,264 @@
+use crate::theme::layers;
+use crate::theme::prelude::*;
+use dominator::{clone, events, html, svg, with_node, Dom, EventOptions};
+use dwind::prelude::*;
+use futures_signals::signal::{Mutable, SignalExt};
+use futures_signals::signal_vec::SignalVecExt;
+use futures_signals_component_macro::component;
+use std::rc::Rc;
+use web_sys::wasm_bindgen::JsCast;
+
+/// An accessible dropdown menu following the WAI-ARIA menu-button pattern.
+///
+/// Renders a trigger button (`aria-haspopup="menu"`, `aria-expanded`) that
+/// opens a `role="menu"` panel of `(key, label, disabled)` items. Keyboard
+/// support: Enter/Space/ArrowDown open the menu and focus its first enabled
+/// item; inside, ArrowUp/ArrowDown move focus (wrapping, skipping disabled
+/// items), Home/End jump, Escape closes and refocuses the trigger.
+/// Activating an item invokes `on_select` with its key and closes the menu.
+#[component(render_fn = dropdown_menu)]
+struct DropdownMenu {
+ #[signal_vec]
+ #[default(vec![])]
+ items: (String, String, bool),
+
+ /// Trigger button text
+ #[signal]
+ #[default("Menu".to_string())]
+ label: String,
+
+ #[default(Box::new(|_|{}))]
+ on_select: dyn Fn(String) + 'static,
+
+ #[signal]
+ #[default(false)]
+ disabled: bool,
+}
+
+pub fn dropdown_menu(props: DropdownMenuProps) -> Dom {
+ let DropdownMenuProps {
+ items,
+ label,
+ on_select,
+ disabled,
+ apply,
+ } = props;
+
+ let on_select = Rc::new(on_select);
+ let disabled = disabled.broadcast();
+ let items = items.to_signal_cloned().broadcast();
+
+ let open = Mutable::new(false);
+
+ // focus_first needs the current entries, not a signal
+ let items_state: Mutable> = Mutable::new(vec![]);
+
+ let menu_id = component_id_pair();
+ let (trigger_id, panel_id) = menu_id;
+
+ let focus_element = |id: &str| {
+ if let Some(element) = web_sys::window()
+ .and_then(|w| w.document())
+ .and_then(|d| d.get_element_by_id(id))
+ {
+ if let Ok(element) = element.dyn_into::() {
+ let _ = element.focus();
+ }
+ }
+ };
+
+ let item_dom_id = {
+ let panel_id = panel_id.clone();
+ move |key: &str| format!("{}-item-{}", panel_id, key)
+ };
+
+ // Focus the first enabled item of the currently rendered menu
+ let focus_first = Rc::new({
+ let items_state = items_state.clone();
+ let item_dom_id = item_dom_id.clone();
+
+ move || {
+ let entries = items_state.get_cloned();
+
+ if let Some((key, _, _)) = entries.iter().find(|(_, _, disabled)| !disabled) {
+ focus_element(&item_dom_id(key));
+ }
+ }
+ });
+
+ html!("span", {
+ .dwclass!("inline-block")
+ .style("position", "relative")
+ .future(items.signal_cloned().for_each(clone!(items_state => move |v| {
+ items_state.set(v);
+ async {}
+ })))
+ .global_event(clone!(open, trigger_id => move |e: events::KeyDown| {
+ if open.get() && e.key() == "Escape" {
+ open.set(false);
+ focus_element(&trigger_id);
+ }
+ }))
+ .with_node!(wrapper => {
+ .global_event(clone!(open => move |e: events::Click| {
+ if !open.get() {
+ return;
+ }
+
+ let outside = e
+ .target()
+ .and_then(|t| t.dyn_into::().ok())
+ .map(|node| !wrapper.contains(Some(&node)))
+ .unwrap_or(true);
+
+ if outside {
+ open.set(false);
+ }
+ }))
+ })
+ // Trigger
+ .child(html!("button", {
+ .attr("type", "button")
+ .attr("id", &trigger_id)
+ .attr("aria-haspopup", "menu")
+ .attr("aria-controls", &panel_id)
+ .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!("h-10 p-l-3 p-r-3 rounded-md cursor-pointer font-medium text-base transition-colors dwui-focusable")
+ .dwclass!("flex flex-row align-items-center gap-2")
+ .dwclass!("border dwui-border-void-600 bg-transparent")
+ .dwclass!("dwui-text-on-primary-200 hover:dwui-bg-void-800")
+ .dwclass!("is(.light *):dwui-border-void-300 is(.light *):dwui-text-on-primary-800 is(.light *):hover:dwui-bg-void-200")
+ .dwclass!("disabled:opacity-60 disabled:cursor-not-allowed")
+ .child(html!("span", { .text_signal(label) }))
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 12 12")
+ .attr("width", "12")
+ .attr("height", "12")
+ .attr("fill", "none")
+ .attr("aria-hidden", "true")
+ .child(svg!("path", {
+ .attr("d", "M2 4 L6 8 L10 4")
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ .attr("stroke-linejoin", "round")
+ }))
+ }))
+ .event(clone!(open => move |_: events::Click| {
+ open.set(!open.get());
+ }))
+ .event_with_options(&EventOptions::preventable(), clone!(open, focus_first => move |e: events::KeyDown| {
+ if e.key() != "ArrowDown" {
+ return;
+ }
+
+ e.prevent_default();
+ open.set(true);
+
+ // Let the panel become visible, then move focus into it
+ let focus_first = focus_first.clone();
+
+ wasm_bindgen_futures::spawn_local(async move {
+ gloo_timers::future::TimeoutFuture::new(0).await;
+ (focus_first)();
+ });
+ }))
+ }))
+ // Menu panel
+ .child(html!("div", {
+ .attr("id", &panel_id)
+ .attr("role", "menu")
+ .attr("aria-labelledby", &trigger_id)
+ .visible_signal(open.signal())
+ .dwclass!("rounded-lg border shadow-lg p-1 flex flex-col")
+ .dwclass!("dwui-bg-void-800 dwui-border-void-700")
+ .dwclass!("is(.light *):dwui-bg-void-50 is(.light *):dwui-border-void-200")
+ .style("position", "absolute")
+ .style("top", "calc(100% + 0.375rem)")
+ .style("left", "0")
+ .style("z-index", layers::OVERLAY)
+ .style("min-width", "100%")
+ .style("width", "max-content")
+ .style("animation", "dwui-modal-in 150ms ease-out")
+ .children_signal_vec(items.signal_cloned().map(clone!(open, on_select, item_dom_id, trigger_id => move |entries| {
+ entries
+ .iter()
+ .enumerate()
+ .map(|(index, (key, item_label, item_disabled))| {
+ let key = key.clone();
+ let item_disabled = *item_disabled;
+
+ html!("button", {
+ .attr("type", "button")
+ .attr("role", "menuitem")
+ .attr("id", &item_dom_id(&key))
+ .attr("tabindex", "-1")
+ .apply_if(item_disabled, |b| {
+ b.attr("disabled", "disabled")
+ .attr("aria-disabled", "true")
+ })
+ .dwclass!("h-8 p-l-3 p-r-3 rounded-md cursor-pointer text-sm text-left transition-colors dwui-focusable")
+ .dwclass!("bg-transparent border-none w-full")
+ .dwclass!("dwui-text-on-primary-200 hover:dwui-bg-void-700")
+ .dwclass!("is(.light *):dwui-text-on-primary-800 is(.light *):hover:dwui-bg-void-200")
+ .dwclass!("disabled:opacity-60 disabled:cursor-not-allowed")
+ .text(item_label)
+ .event(clone!(open, on_select, key, trigger_id => move |e: events::Click| {
+ e.stop_propagation();
+
+ if item_disabled {
+ return;
+ }
+
+ (on_select)(key.clone());
+ open.set(false);
+ focus_element(&trigger_id);
+ }))
+ .event_with_options(&EventOptions::preventable(), clone!(entries, item_dom_id => move |e: events::KeyDown| {
+ let count = entries.len();
+
+ if count == 0 {
+ return;
+ }
+
+ let next_enabled = |mut index: usize, step: i32| -> Option {
+ for _ in 0..count {
+ index = (index as i32 + step).rem_euclid(count as i32) as usize;
+
+ if !entries[index].2 {
+ return Some(index);
+ }
+ }
+
+ None
+ };
+
+ let target = match e.key().as_str() {
+ "ArrowDown" => next_enabled(index, 1),
+ "ArrowUp" => next_enabled(index, -1),
+ "Home" => entries.iter().position(|(_, _, d)| !d),
+ "End" => entries.iter().rposition(|(_, _, d)| !d),
+ _ => return,
+ };
+
+ e.prevent_default();
+
+ if let Some(target) = target {
+ focus_element(&item_dom_id(&entries[target].0));
+ }
+ }))
+ })
+ })
+ .collect::>()
+ })).to_signal_vec())
+ }))
+ .apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
+ })
+}
+
+fn component_id_pair() -> (String, String) {
+ let base = crate::utils::component_id("menu");
+
+ (format!("{}-trigger", base), base)
+}
diff --git a/crates/dwui/src/components/widgets/mod.rs b/crates/dwui/src/components/widgets/mod.rs
index 64c5aba..653520c 100644
--- a/crates/dwui/src/components/widgets/mod.rs
+++ b/crates/dwui/src/components/widgets/mod.rs
@@ -2,10 +2,15 @@ pub mod accordion;
pub mod alert;
pub mod badge;
pub mod button;
+pub mod drawer;
+pub mod dropdown_menu;
pub mod modal;
+pub mod pagination;
+pub mod popover;
pub mod progress;
pub mod spinner;
pub mod tab_list;
+pub mod toast;
pub mod tooltip;
pub mod prelude {
@@ -13,9 +18,14 @@ pub mod prelude {
pub use super::alert::*;
pub use super::badge::*;
pub use super::button::*;
+ pub use super::drawer::*;
+ pub use super::dropdown_menu::*;
pub use super::modal::*;
+ pub use super::pagination::*;
+ pub use super::popover::*;
pub use super::progress::*;
pub use super::spinner::*;
pub use super::tab_list::*;
+ pub use super::toast::*;
pub use super::tooltip::*;
}
diff --git a/crates/dwui/src/components/widgets/modal.rs b/crates/dwui/src/components/widgets/modal.rs
index 37f1b48..5ea7e1d 100644
--- a/crates/dwui/src/components/widgets/modal.rs
+++ b/crates/dwui/src/components/widgets/modal.rs
@@ -1,9 +1,65 @@
+use crate::theme::layers;
use crate::theme::prelude::*;
-use dominator::{clone, events, html, Dom};
+use dominator::{clone, events, html, svg, with_node, Dom, EventOptions};
use dwind::prelude::*;
use futures_signals::signal::{Mutable, SignalExt};
use futures_signals_component_macro::component;
use std::sync::Arc;
+use web_sys::wasm_bindgen::JsCast;
+
+/// Keeps Tab/Shift-Tab cycling within `container`'s focusable elements.
+pub(crate) fn trap_focus(container: &web_sys::HtmlElement, e: &events::KeyDown) {
+ const FOCUSABLE: &str = "a[href], button:not([disabled]), input:not([disabled]), \
+ select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
+
+ if e.key() != "Tab" {
+ return;
+ }
+
+ let Ok(focusables) = container.query_selector_all(FOCUSABLE) else {
+ return;
+ };
+
+ let count = focusables.length();
+
+ if count == 0 {
+ e.prevent_default();
+ return;
+ }
+
+ let element_at = |index: u32| -> Option {
+ focusables.item(index)?.dyn_into().ok()
+ };
+
+ let Some(first) = element_at(0) else { return };
+ let Some(last) = element_at(count - 1) else {
+ return;
+ };
+
+ let active = web_sys::window()
+ .and_then(|w| w.document())
+ .and_then(|d| d.active_element());
+
+ let active_inside = active
+ .as_ref()
+ .map(|a| container.contains(Some(a)))
+ .unwrap_or(false);
+
+ let is_active = |element: &web_sys::HtmlElement| {
+ let element: &web_sys::Element = element.as_ref();
+ active.as_ref() == Some(element)
+ };
+
+ if e.shift_key() {
+ if is_active(&first) || !active_inside {
+ e.prevent_default();
+ let _ = last.focus();
+ }
+ } else if is_active(&last) || !active_inside {
+ e.prevent_default();
+ let _ = first.focus();
+ }
+}
#[derive(Copy, Clone, Eq, PartialEq)]
pub enum ModalSize {
@@ -56,7 +112,9 @@ pub fn modal(props: ModalProps) -> Dom {
let open = open.broadcast();
let is_open = Mutable::new(false);
- html!("div", {
+ // Portalled to the body so a transformed ancestor can never capture the
+ // fixed positioning (see utils::body_portal).
+ crate::utils::body_portal(html!("div", {
.visible_signal(open.signal())
// Track open state
.future(open.signal().for_each(clone!(is_open => move |open_val| {
@@ -77,17 +135,17 @@ pub fn modal(props: ModalProps) -> Dom {
.style("display", "flex")
.style("justify-content", "center")
.style("align-items", "center")
- .style("z-index", "50")
+ .style("z-index", layers::MODAL)
// Backdrop
.child_signal(close_on_backdrop_click.map(clone!(on_close => move |clickable| {
Some(html!("div", {
+ .dwclass!("dwui-scrim")
.style("position", "absolute")
.style("top", "0")
.style("left", "0")
.style("width", "100%")
.style("height", "100%")
- .style("background-color", "rgba(0, 0, 0, 0.6)")
.style("backdrop-filter", "blur(4px)")
.style("animation", "dwui-fade-in 150ms ease-out")
.style("z-index", "1")
@@ -115,13 +173,19 @@ pub fn modal(props: ModalProps) -> Dom {
.style("position", "relative")
.style("z-index", "10")
.style("pointer-events", "auto")
- .style("outline", "none")
+ .dwclass!("dwui-focusable")
+ // Focus trap: Tab and Shift-Tab wrap within the dialog
+ .with_node!(dialog_node => {
+ .event_with_options(&EventOptions::preventable(), clone!(dialog_node => move |e: events::KeyDown| {
+ trap_focus(&dialog_node, &e);
+ }))
+ })
// Size classes
.style_signal("width", size.signal().map(|s| match s {
ModalSize::Small => "24rem",
- ModalSize::Medium => "600px",
- ModalSize::Large => "900px",
+ ModalSize::Medium => "37.5rem",
+ ModalSize::Large => "56.25rem",
ModalSize::Full => "90vw",
}))
.style_signal("height", size.signal().map(|s| match s {
@@ -143,17 +207,26 @@ 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")
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 14 14")
+ .attr("width", "14")
+ .attr("height", "14")
+ .attr("fill", "none")
+ .attr("aria-hidden", "true")
+ .child(svg!("path", {
+ .attr("d", "M2 2 L12 12 M12 2 L2 12")
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ }))
+ }))
.style("z-index", "20")
.event(clone!(on_close => move |e: events::Click| {
e.stop_propagation();
@@ -164,5 +237,5 @@ pub fn modal(props: ModalProps) -> Dom {
// Modal content
.child_signal(content)
}))
- })
+ }))
}
diff --git a/crates/dwui/src/components/widgets/pagination.rs b/crates/dwui/src/components/widgets/pagination.rs
new file mode 100644
index 0000000..b0135b7
--- /dev/null
+++ b/crates/dwui/src/components/widgets/pagination.rs
@@ -0,0 +1,252 @@
+use crate::theme::prelude::*;
+use dominator::{clone, events, html, svg, Dom};
+use dwind::prelude::*;
+use futures_signals::map_ref;
+use futures_signals::signal::{Mutable, SignalExt};
+use futures_signals_component_macro::component;
+use std::rc::Rc;
+
+/// One rendered element of a pagination bar.
+#[derive(Clone, Copy, Eq, PartialEq, Debug)]
+pub enum PageItem {
+ Page(usize),
+ Ellipsis,
+}
+
+/// Computes the visible page window: always the first and last page,
+/// `sibling_count` pages around the current one, and ellipses for any gap
+/// wider than a single page.
+pub fn page_window(page: usize, total_pages: usize, sibling_count: usize) -> Vec {
+ let total = total_pages.max(1);
+ let page = page.clamp(1, total);
+
+ let low = page.saturating_sub(sibling_count).max(1);
+ let high = (page + sibling_count).min(total);
+
+ let mut out = vec![];
+
+ if low > 1 {
+ out.push(PageItem::Page(1));
+
+ if low > 2 {
+ out.push(PageItem::Ellipsis);
+ }
+ }
+
+ out.extend((low..=high).map(PageItem::Page));
+
+ if high < total {
+ if high + 1 < total {
+ out.push(PageItem::Ellipsis);
+ }
+
+ out.push(PageItem::Page(total));
+ }
+
+ out
+}
+
+/// An accessible pagination bar.
+///
+/// Renders a `` of page buttons with previous/next arrows. The current
+/// page is controlled through the 1-based `page` signal and marked with
+/// `aria-current="page"`; activating any button invokes `on_page_change` with
+/// the target page. Plain tab order — every button is a tab stop, per the
+/// WAI-ARIA guidance for pagination navigation.
+#[component(render_fn = pagination)]
+struct Pagination {
+ #[signal]
+ #[default(1)]
+ page: usize,
+
+ #[signal]
+ #[default(1)]
+ total_pages: usize,
+
+ #[default(Box::new(|_|{}))]
+ on_page_change: dyn Fn(usize) + 'static,
+
+ /// Pages shown on each side of the current page
+ #[default(1)]
+ sibling_count: usize,
+
+ #[signal]
+ #[default("Pagination".to_string())]
+ aria_label: String,
+}
+
+pub fn pagination(props: PaginationProps) -> Dom {
+ let PaginationProps {
+ page,
+ total_pages,
+ on_page_change,
+ sibling_count,
+ aria_label,
+ apply,
+ } = props;
+
+ let on_page_change = Rc::new(on_page_change);
+ let page = page.broadcast();
+ let total_pages = total_pages.broadcast();
+
+ // Click handlers need current values, not signals
+ let page_state = Mutable::new(1usize);
+ let total_state = Mutable::new(1usize);
+
+ let arrow_button = |label: &'static str, path: &'static str, delta: i32| {
+ html!("button", {
+ .attr("type", "button")
+ .attr("aria-label", label)
+ .dwclass!("w-8 h-8 flex align-items-center justify-center rounded-md cursor-pointer flex-none")
+ .dwclass!("bg-transparent border-none transition-colors dwui-focusable")
+ .dwclass!("dwui-text-on-primary-300 hover:dwui-bg-void-800")
+ .dwclass!("is(.light *):dwui-text-on-primary-700 is(.light *):hover:dwui-bg-void-200")
+ .dwclass!("disabled:opacity-60 disabled:cursor-not-allowed")
+ .attr_signal("disabled", map_ref! {
+ let page = page.signal(),
+ let total = total_pages.signal() => {
+ let at_edge = if delta < 0 { *page <= 1 } else { *page >= (*total).max(1) };
+
+ if at_edge { Some("disabled") } else { None }
+ }
+ })
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 14 14")
+ .attr("width", "14")
+ .attr("height", "14")
+ .attr("fill", "none")
+ .attr("aria-hidden", "true")
+ .child(svg!("path", {
+ .attr("d", path)
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ .attr("stroke-linejoin", "round")
+ }))
+ }))
+ .event(clone!(on_page_change, page_state, total_state => move |_: events::Click| {
+ let current = page_state.get() as i32;
+ let total = total_state.get().max(1) as i32;
+ let target = (current + delta).clamp(1, total);
+
+ if target != current {
+ (on_page_change)(target as usize);
+ }
+ }))
+ })
+ };
+
+ html!("nav", {
+ .attr_signal("aria-label", aria_label)
+ .future(page.signal().for_each(clone!(page_state => move |v| {
+ page_state.set(v);
+ async {}
+ })))
+ .future(total_pages.signal().for_each(clone!(total_state => move |v| {
+ total_state.set(v);
+ async {}
+ })))
+ .child(html!("div", {
+ .dwclass!("flex flex-row align-items-center gap-1")
+ .child(arrow_button("Previous page", "M9 3 L5 7 L9 11", -1))
+ .children_signal_vec(map_ref! {
+ let page = page.signal(),
+ let total = total_pages.signal() =>
+ page_window(*page, *total, sibling_count)
+ .into_iter()
+ .map(|item| (item, *page))
+ .collect::>()
+ }.map(clone!(on_page_change => move |items| {
+ items
+ .into_iter()
+ .map(|(item, current)| match item {
+ PageItem::Ellipsis => html!("span", {
+ .attr("aria-hidden", "true")
+ .dwclass!("w-8 h-8 flex align-items-center justify-center select-none text-sm")
+ .dwclass!("dwui-text-on-primary-500")
+ .text("…")
+ }),
+ PageItem::Page(n) => {
+ let is_current = n == current;
+
+ html!("button", {
+ .attr("type", "button")
+ .attr("aria-label", &format!("Page {}", n))
+ .apply_if(is_current, |b| b.attr("aria-current", "page"))
+ .dwclass!("w-8 h-8 flex align-items-center justify-center rounded-md cursor-pointer text-sm flex-none")
+ .dwclass!("border-none transition-colors dwui-focusable")
+ .apply(move |b| {
+ if is_current {
+ dwclass!(b, "dwui-bg-primary-500 dwui-text-on-primary-950 font-bold is(.light *):dwui-bg-primary-400")
+ } else {
+ dwclass!(b, "bg-transparent dwui-text-on-primary-300 hover:dwui-bg-void-800 is(.light *):dwui-text-on-primary-700 is(.light *):hover:dwui-bg-void-200")
+ }
+ })
+ .text(&n.to_string())
+ .event(clone!(on_page_change => move |_: events::Click| {
+ (on_page_change)(n);
+ }))
+ })
+ }
+ })
+ .collect::>()
+ })).to_signal_vec())
+ .child(arrow_button("Next page", "M5 3 L9 7 L5 11", 1))
+ }))
+ .apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
+ })
+}
+
+#[cfg(test)]
+mod test {
+ use super::{page_window, PageItem};
+
+ fn pages(items: &[PageItem]) -> Vec> {
+ items
+ .iter()
+ .map(|i| match i {
+ PageItem::Page(n) => Some(*n),
+ PageItem::Ellipsis => None,
+ })
+ .collect()
+ }
+
+ #[test]
+ fn small_totals_render_every_page() {
+ assert_eq!(
+ pages(&page_window(2, 3, 1)),
+ vec![Some(1), Some(2), Some(3)]
+ );
+ }
+
+ #[test]
+ fn middle_pages_get_ellipses_on_both_sides() {
+ assert_eq!(
+ pages(&page_window(5, 10, 1)),
+ vec![
+ Some(1),
+ None,
+ Some(4),
+ Some(5),
+ Some(6),
+ None,
+ Some(10)
+ ]
+ );
+ }
+
+ #[test]
+ fn edges_do_not_produce_single_page_gaps() {
+ // low = 2, so page 1 is adjacent: no ellipsis
+ assert_eq!(
+ pages(&page_window(3, 10, 1)),
+ vec![Some(1), Some(2), Some(3), Some(4), None, Some(10)]
+ );
+ }
+
+ #[test]
+ fn out_of_range_input_is_clamped() {
+ assert_eq!(pages(&page_window(99, 3, 1)), vec![Some(1), Some(2), Some(3)]);
+ assert_eq!(pages(&page_window(1, 0, 1)), vec![Some(1)]);
+ }
+}
diff --git a/crates/dwui/src/components/widgets/popover.rs b/crates/dwui/src/components/widgets/popover.rs
new file mode 100644
index 0000000..639679d
--- /dev/null
+++ b/crates/dwui/src/components/widgets/popover.rs
@@ -0,0 +1,127 @@
+use crate::theme::layers;
+use crate::theme::prelude::*;
+use dominator::{clone, events, html, with_node, Dom};
+use dwind::prelude::*;
+use futures_signals::signal::{Mutable, SignalExt};
+use futures_signals_component_macro::component;
+use std::sync::Arc;
+use web_sys::wasm_bindgen::JsCast;
+
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum PopoverPosition {
+ BottomStart,
+ BottomEnd,
+ TopStart,
+ TopEnd,
+}
+
+/// A generic anchored overlay panel.
+///
+/// Wraps the `anchor` content; while `open` is set, the panel renders beside
+/// it (positioned like the tooltip, relative to the anchor's box). Escape and
+/// clicks outside the wrapper invoke `on_close` — the open state itself stays
+/// controlled by the caller.
+#[component(render_fn = popover)]
+struct Popover {
+ #[signal]
+ #[default(None)]
+ anchor: Option,
+
+ #[signal]
+ #[default(None)]
+ content: Option,
+
+ #[signal]
+ #[default(false)]
+ open: bool,
+
+ #[default(Box::new(|| {}))]
+ on_close: dyn Fn() -> () + 'static,
+
+ #[signal]
+ #[default(PopoverPosition::BottomStart)]
+ position: PopoverPosition,
+
+ #[signal]
+ #[default("Popover".to_string())]
+ aria_label: String,
+}
+
+pub fn popover(props: PopoverProps) -> Dom {
+ let PopoverProps {
+ anchor,
+ content,
+ open,
+ on_close,
+ position,
+ aria_label,
+ apply,
+ } = props;
+
+ let on_close = Arc::new(on_close);
+ let open = open.broadcast();
+ let position = position.broadcast();
+ let is_open = Mutable::new(false);
+
+ html!("span", {
+ .dwclass!("inline-block")
+ .style("position", "relative")
+ .future(open.signal().for_each(clone!(is_open => move |v| {
+ is_open.set(v);
+ async {}
+ })))
+ .global_event(clone!(on_close, is_open => move |e: events::KeyDown| {
+ if is_open.get() && e.key() == "Escape" {
+ (on_close)();
+ }
+ }))
+ .with_node!(wrapper => {
+ // A click anywhere outside the wrapper closes the popover
+ .global_event(clone!(on_close, is_open => move |e: events::Click| {
+ if !is_open.get() {
+ return;
+ }
+
+ let outside = e
+ .target()
+ .and_then(|t| t.dyn_into::().ok())
+ .map(|node| !wrapper.contains(Some(&node)))
+ .unwrap_or(true);
+
+ if outside {
+ (on_close)();
+ }
+ }))
+ })
+ .child_signal(anchor)
+ .child(html!("div", {
+ .attr_signal("aria-label", aria_label)
+ .visible_signal(open.signal())
+ .dwclass!("rounded-lg border shadow-lg p-2")
+ .dwclass!("dwui-bg-void-800 dwui-border-void-700 dwui-text-on-primary-200")
+ .dwclass!("is(.light *):dwui-bg-void-50 is(.light *):dwui-border-void-200 is(.light *):dwui-text-on-primary-800")
+ .style("position", "absolute")
+ .style("z-index", layers::OVERLAY)
+ .style("width", "max-content")
+ .style("animation", "dwui-modal-in 150ms ease-out")
+ .style_signal("top", position.signal().map(|p| match p {
+ PopoverPosition::BottomStart | PopoverPosition::BottomEnd => Some("calc(100% + 0.375rem)"),
+ _ => None,
+ }))
+ .style_signal("bottom", position.signal().map(|p| match p {
+ PopoverPosition::TopStart | PopoverPosition::TopEnd => Some("calc(100% + 0.375rem)"),
+ _ => None,
+ }))
+ .style_signal("left", position.signal().map(|p| match p {
+ PopoverPosition::BottomStart | PopoverPosition::TopStart => Some("0"),
+ _ => None,
+ }))
+ .style_signal("right", position.signal().map(|p| match p {
+ PopoverPosition::BottomEnd | PopoverPosition::TopEnd => Some("0"),
+ _ => None,
+ }))
+ .child_signal(content)
+ }))
+ .apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
+ })
+}
diff --git a/crates/dwui/src/components/widgets/spinner.rs b/crates/dwui/src/components/widgets/spinner.rs
index 2417e27..b4ef401 100644
--- a/crates/dwui/src/components/widgets/spinner.rs
+++ b/crates/dwui/src/components/widgets/spinner.rs
@@ -11,15 +11,6 @@ pub enum SpinnerSize {
Large,
}
-impl SpinnerSize {
- fn px(self) -> &'static str {
- match self {
- SpinnerSize::Small => "16",
- SpinnerSize::Medium => "24",
- SpinnerSize::Large => "40",
- }
- }
-}
/// A loading spinner exposed to assistive technology as `role="status"`.
#[component(render_fn = spinner)]
@@ -46,11 +37,14 @@ pub fn spinner(props: SpinnerProps) -> Dom {
.dwclass!("dwui-text-primary-400 is(.light *):dwui-text-primary-600")
.child(html!("span", {
.dwclass!("inline-flex align-items-center justify-center")
+ .dwclass_signal!("w-4 h-4", size.signal().map(|s| s == SpinnerSize::Small))
+ .dwclass_signal!("w-6 h-6", size.signal().map(|s| s == SpinnerSize::Medium))
+ .dwclass_signal!("w-10 h-10", size.signal().map(|s| s == SpinnerSize::Large))
.style("animation", "dwui-spin 0.8s linear infinite")
.child(svg!("svg", {
.attr("viewBox", "0 0 24 24")
- .attr_signal("width", size.signal().map(|s| s.px()))
- .attr_signal("height", size.signal().map(|s| s.px()))
+ .attr("width", "100%")
+ .attr("height", "100%")
.attr("fill", "none")
.attr("aria-hidden", "true")
.child(svg!("circle", {
diff --git a/crates/dwui/src/components/widgets/tab_list.rs b/crates/dwui/src/components/widgets/tab_list.rs
index 949d5c9..a325ad0 100644
--- a/crates/dwui/src/components/widgets/tab_list.rs
+++ b/crates/dwui/src/components/widgets/tab_list.rs
@@ -80,22 +80,16 @@ pub fn tab_list(props: TabListProps) -> Dom {
.attr("id", &tab_dom_id(&key))
.attr_signal("aria-selected", is_selected.signal().map(|v| if v { "true" } else { "false" }))
.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!("h-10 p-l-4 p-r-4 cursor-pointer font-medium text-base transition-colors bg-transparent")
+ .dwclass!("rounded-t-md border-b-2")
+ .dwclass!("dwui-focusable")
.dwclass_signal!("dwui-text-on-primary-50 is(.light *):dwui-text-on-primary-950", is_selected.signal())
+ .dwclass_signal!("dwui-border-primary-400 is(.light *):dwui-border-primary-600", 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)")
- } else {
- None
- }
- }))
+ .dwclass_signal!("border-transparent", is_selected.signal().map(|v| !v))
.text(label)
.event(clone!(on_select, key => move |_: events::Click| {
(on_select)(key.clone());
diff --git a/crates/dwui/src/components/widgets/toast.rs b/crates/dwui/src/components/widgets/toast.rs
new file mode 100644
index 0000000..6b1d2c9
--- /dev/null
+++ b/crates/dwui/src/components/widgets/toast.rs
@@ -0,0 +1,236 @@
+use crate::theme::layers;
+use crate::theme::prelude::*;
+use dominator::{clone, events, html, svg, Dom};
+use dwind::prelude::*;
+use futures_signals::signal_vec::{MutableVec, SignalVecExt};
+use futures_signals_component_macro::component;
+use std::rc::Rc;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum ToastVariant {
+ Info,
+ Success,
+ Warning,
+ Error,
+}
+
+/// Options for a single toast pushed through a [`Toaster`].
+pub struct ToastOptions {
+ pub title: String,
+ pub message: Option,
+ pub variant: ToastVariant,
+ /// Auto-dismiss delay; `None` keeps the toast until dismissed
+ pub duration_ms: Option,
+ pub dismissible: bool,
+}
+
+impl Default for ToastOptions {
+ fn default() -> Self {
+ Self {
+ title: "".to_string(),
+ message: None,
+ variant: ToastVariant::Info,
+ duration_ms: Some(4000),
+ dismissible: true,
+ }
+ }
+}
+
+pub struct ToastEntry {
+ pub id: u64,
+ pub options: ToastOptions,
+}
+
+/// Handle for pushing toasts into a [`toasts`] host.
+///
+/// Clone it freely; all clones share the same queue. Mount exactly one
+/// `toasts!` host per handle.
+#[derive(Clone, Default)]
+pub struct Toaster {
+ entries: Rc>>,
+}
+
+impl Toaster {
+ pub fn push(&self, options: ToastOptions) -> u64 {
+ static TOAST_ID: AtomicU64 = AtomicU64::new(0);
+
+ let id = TOAST_ID.fetch_add(1, Ordering::Relaxed);
+
+ self.entries
+ .lock_mut()
+ .push_cloned(Rc::new(ToastEntry { id, options }));
+
+ id
+ }
+
+ pub fn dismiss(&self, id: u64) {
+ self.entries.lock_mut().retain(|entry| entry.id != id);
+ }
+
+ pub fn clear(&self) {
+ self.entries.lock_mut().clear();
+ }
+
+ pub fn info(&self, title: impl Into) -> u64 {
+ self.push(ToastOptions {
+ title: title.into(),
+ ..Default::default()
+ })
+ }
+
+ pub fn success(&self, title: impl Into) -> u64 {
+ self.push(ToastOptions {
+ title: title.into(),
+ variant: ToastVariant::Success,
+ ..Default::default()
+ })
+ }
+
+ pub fn warning(&self, title: impl Into) -> u64 {
+ self.push(ToastOptions {
+ title: title.into(),
+ variant: ToastVariant::Warning,
+ ..Default::default()
+ })
+ }
+
+ pub fn error(&self, title: impl Into) -> u64 {
+ self.push(ToastOptions {
+ title: title.into(),
+ variant: ToastVariant::Error,
+ ..Default::default()
+ })
+ }
+}
+
+#[derive(Copy, Clone, Eq, PartialEq)]
+pub enum ToastPosition {
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+ BottomCenter,
+}
+
+/// The fixed-position host that renders a [`Toaster`]'s queue.
+///
+/// Mount once (e.g. at the app root). The container is an `aria-live="polite"`
+/// region layered above modals; Error toasts additionally announce assertively
+/// via `role="alert"`. Toasts with a `duration_ms` dismiss themselves — the
+/// timer is cancelled if the host is dropped first.
+#[component(render_fn = toasts)]
+struct Toasts {
+ #[default(Toaster::default())]
+ toaster: Toaster,
+
+ #[default(ToastPosition::BottomRight)]
+ position: ToastPosition,
+}
+
+pub fn toasts(props: ToastsProps) -> Dom {
+ let ToastsProps {
+ toaster,
+ position,
+ apply,
+ } = props;
+
+ // Portalled to the body so a transformed ancestor can never capture the
+ // fixed positioning (see utils::body_portal).
+ crate::utils::body_portal(html!("div", {
+ .attr("aria-live", "polite")
+ .attr("role", "status")
+ .dwclass!("flex flex-col gap-2")
+ .style("position", "fixed")
+ .style("z-index", layers::TOAST)
+ .style("pointer-events", "none")
+ .apply(move |b| {
+ let b = match position {
+ ToastPosition::TopLeft | ToastPosition::TopRight => b.style("top", "1rem"),
+ _ => b.style("bottom", "1rem"),
+ };
+
+ match position {
+ ToastPosition::TopLeft | ToastPosition::BottomLeft => b.style("left", "1rem"),
+ ToastPosition::TopRight | ToastPosition::BottomRight => b.style("right", "1rem"),
+ ToastPosition::BottomCenter => {
+ b.style("left", "50%").style("transform", "translateX(-50%)")
+ }
+ }
+ })
+ .children_signal_vec(toaster.entries.signal_vec_cloned().map(clone!(toaster => move |entry| {
+ render_toast(&toaster, entry)
+ })))
+ .apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
+ }))
+}
+
+fn render_toast(toaster: &Toaster, entry: Rc) -> Dom {
+ let toaster = toaster.clone();
+ let id = entry.id;
+ let options = &entry.options;
+
+ html!("div", {
+ .apply_if(options.variant == ToastVariant::Error, |b| b.attr("role", "alert"))
+ .dwclass!("flex flex-row gap-3 p-3 rounded-lg border-l-2 shadow-lg w-80")
+ .dwclass!("dwui-bg-void-800 dwui-text-on-primary-100")
+ .dwclass!("is(.light *):dwui-bg-void-100 is(.light *):dwui-text-on-primary-900")
+ .apply(|b| match options.variant {
+ ToastVariant::Info => dwclass!(b, "dwui-border-primary-500"),
+ ToastVariant::Success => dwclass!(b, "dwui-border-success-500"),
+ ToastVariant::Warning => dwclass!(b, "dwui-border-warning-500"),
+ ToastVariant::Error => dwclass!(b, "dwui-border-error-500"),
+ })
+ .style("pointer-events", "auto")
+ .style("max-width", "90vw")
+ .style("animation", "dwui-toast-in 200ms ease-out")
+ .apply_if(options.duration_ms.is_some(), |b| {
+ let duration = options.duration_ms.unwrap();
+
+ b.future(clone!(toaster => async move {
+ gloo_timers::future::TimeoutFuture::new(duration).await;
+ toaster.dismiss(id);
+ }))
+ })
+ .child(html!("div", {
+ .dwclass!("flex flex-col gap-1 grow")
+ .child(html!("div", {
+ .dwclass!("font-bold text-sm")
+ .text(&options.title)
+ }))
+ .apply_if(options.message.is_some(), |b| {
+ b.child(html!("div", {
+ .dwclass!("text-sm")
+ .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-700")
+ .text(options.message.as_deref().unwrap_or_default())
+ }))
+ })
+ }))
+ .apply_if(options.dismissible, |b| {
+ b.child(html!("button", {
+ .attr("type", "button")
+ .attr("aria-label", "Dismiss")
+ .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-700 is(.light *):hover:dwui-bg-void-200 transition-colors")
+ .dwclass!("dwui-focusable")
+ .child(svg!("svg", {
+ .attr("viewBox", "0 0 12 12")
+ .attr("width", "12")
+ .attr("height", "12")
+ .attr("fill", "none")
+ .attr("aria-hidden", "true")
+ .child(svg!("path", {
+ .attr("d", "M2 2 L10 10 M10 2 L2 10")
+ .attr("stroke", "currentColor")
+ .attr("stroke-width", "1.5")
+ .attr("stroke-linecap", "round")
+ }))
+ }))
+ .event(clone!(toaster => move |_: events::Click| {
+ toaster.dismiss(id);
+ }))
+ }))
+ })
+ })
+}
diff --git a/crates/dwui/src/components/widgets/tooltip.rs b/crates/dwui/src/components/widgets/tooltip.rs
index 9585abc..766d4ad 100644
--- a/crates/dwui/src/components/widgets/tooltip.rs
+++ b/crates/dwui/src/components/widgets/tooltip.rs
@@ -70,27 +70,27 @@ pub fn tooltip(props: TooltipProps) -> Dom {
.dwclass!("dwui-bg-void-700 dwui-text-on-primary-50")
.dwclass!("is(.light *):dwui-bg-void-800 is(.light *):dwui-text-on-primary-50")
.style("position", "absolute")
- .style("z-index", "40")
+ .style("z-index", crate::theme::layers::TOOLTIP)
.style("width", "max-content")
.style("max-width", "16rem")
.style("transition", "opacity 100ms ease-out")
.style_signal("opacity", visible.signal().map(|v| if v { "1" } else { "0" }))
.style_signal("top", position.signal().map(|p| match p {
TooltipPosition::Top => Some("auto"),
- TooltipPosition::Bottom => Some("calc(100% + 6px)"),
+ TooltipPosition::Bottom => Some("calc(100% + 0.375rem)"),
TooltipPosition::Left | TooltipPosition::Right => Some("50%"),
}))
.style_signal("bottom", position.signal().map(|p| match p {
- TooltipPosition::Top => Some("calc(100% + 6px)"),
+ TooltipPosition::Top => Some("calc(100% + 0.375rem)"),
_ => None,
}))
.style_signal("left", position.signal().map(|p| match p {
TooltipPosition::Top | TooltipPosition::Bottom => Some("50%"),
- TooltipPosition::Right => Some("calc(100% + 6px)"),
+ TooltipPosition::Right => Some("calc(100% + 0.375rem)"),
TooltipPosition::Left => None,
}))
.style_signal("right", position.signal().map(|p| match p {
- TooltipPosition::Left => Some("calc(100% + 6px)"),
+ TooltipPosition::Left => Some("calc(100% + 0.375rem)"),
_ => None,
}))
.style_signal("transform", position.signal().map(|p| match p {
diff --git a/crates/dwui/src/mixins/field_mixin.rs b/crates/dwui/src/mixins/field_mixin.rs
new file mode 100644
index 0000000..10bb366
--- /dev/null
+++ b/crates/dwui/src/mixins/field_mixin.rs
@@ -0,0 +1,116 @@
+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
+/// `` element.
+pub fn field_surface_mixin(
+ label: impl Signal- + 'static,
+ raise_label: impl Signal
- + 'static,
+ validation_signal: impl Signal
- + 'static,
+ input_id: String,
+) -> impl FnOnce(DomBuilder
) -> DomBuilder {
+ move |b| {
+ let raise_label = raise_label.broadcast();
+ let label = label.broadcast();
+ let validation_signal = validation_signal.broadcast();
+
+ let is_valid = validation_signal
+ .signal_ref(|validation| validation.is_valid())
+ .broadcast();
+
+ let b = dwclass!(b, "dwui-field-surface relative w-full rounded-md overflow-hidden");
+ let b = dwclass!(b, "dwui-bg-void-900 is(.light *):dwui-bg-void-200");
+ let b = dwclass!(b, "border-b-2 transition-colors");
+ let b = dwclass_signal!(
+ b,
+ "dwui-border-void-500 is(.light *):dwui-border-void-400",
+ is_valid.signal()
+ );
+ let b = dwclass_signal!(
+ b,
+ "dwui-border-error-500 is(.light *):dwui-border-error-600",
+ not(is_valid.signal())
+ );
+
+ b.child(html!("label", {
+ .attr("for", &input_id)
+ .dwclass!("absolute pointer-events-none select-none p-l-3 text-base")
+ .dwclass_signal!("dwui-text-on-primary-400 is(.light *):dwui-text-on-primary-600", is_valid.signal())
+ .dwclass_signal!("dwui-text-error-500 is(.light *):dwui-text-error-600", not(is_valid.signal()))
+ .style("left", "0")
+ // A tight line box: with the default ~1.5 line-height the raised
+ // label reaches the surface's top edge and collides with the
+ // :focus-within outline drawn just inside it.
+ .style("line-height", "1")
+ // Multi-line surfaces (textarea) override the resting anchor so the
+ // label rests on their first line instead of the surface's center.
+ .style("top", "var(--dwui-field-label-top, 50%)")
+ .style("transform-origin", "left top")
+ .style("transition", "transform 150ms ease, color 150ms ease")
+ .style_signal("transform", raise_label.signal().map(|raised| {
+ if raised {
+ // Float to the top edge of the surface, shrunk. Pure
+ // transform: no measuring, so any label length or script
+ // works.
+ "translateY(-100%) scale(0.75)"
+ } else {
+ // Vertically centered resting position.
+ "translateY(-50%)"
+ }
+ }))
+ .text_signal(label.signal_cloned())
+ }))
+ }
+}
+
+/// The validation message row belonging to a field surface.
+///
+/// Always present in the DOM with a reserved height, so a field switching
+/// between valid and invalid never shifts the surrounding layout. The row only
+/// takes `role="alert"` while it has a message, and carries
+/// `id = {input_id}-error` so the control can point at it via
+/// `aria-describedby`.
+pub fn field_error_row(
+ validation_signal: impl Signal- + 'static,
+ input_id: &str,
+) -> Dom {
+ let message = validation_signal
+ .map(|validation| match validation {
+ ValidationResult::Valid => "".to_string(),
+ ValidationResult::Invalid { message } => message,
+ })
+ .broadcast();
+
+ html!("div", {
+ .attr("id", &format!("{}-error", input_id))
+ .attr_signal("role", message.signal_ref(|m| {
+ if m.is_empty() {
+ None
+ } else {
+ Some("alert")
+ }
+ }))
+ .dwclass!("min-h-5 text-sm p-l-3 pointer-events-none")
+ .dwclass!("dwui-text-error-500 is(.light *):dwui-text-error-700")
+ .text_signal(message.signal_cloned())
+ })
+}
diff --git a/crates/dwui/src/mixins/labelled_rect_mixin.rs b/crates/dwui/src/mixins/labelled_rect_mixin.rs
deleted file mode 100644
index 9d2e7f1..0000000
--- a/crates/dwui/src/mixins/labelled_rect_mixin.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-use crate::prelude::ValidationResult;
-use crate::theme::prelude::*;
-use dominator::{html, DomBuilder};
-use dwind::prelude::*;
-use futures_signals::map_ref;
-use futures_signals::signal::SignalExt;
-use futures_signals::signal::{not, Signal};
-use web_sys::HtmlElement;
-
-/// Draws the floating label, outline rectangle, and validation message for
-/// labelled input components.
-///
-/// `input_id` is the id of the inner form control; it associates the
-/// `
` element and the validation message
-/// (`id = {input_id}-error`) which the control should reference via
-/// `aria-describedby` when invalid.
-pub fn labelled_rect_mixin(
- label: impl Signal- + 'static,
- raise_label: impl Signal
- + 'static,
- validation_signal: impl Signal
- + 'static,
- input_id: String,
-) -> impl FnOnce(DomBuilder
) -> DomBuilder {
- move |b| {
- let raise_label = raise_label.broadcast();
- let label = label.broadcast();
- let validation_signal = validation_signal.broadcast();
-
- let is_valid = validation_signal
- .signal_ref(|validation| match validation {
- ValidationResult::Valid => true,
- ValidationResult::Invalid { .. } => false,
- })
- .broadcast();
-
- let top_border_margin_signal = map_ref! {
- let raise = raise_label.signal(),
- let label = label.signal_cloned() => {
- if !raise {
- "0px".to_string()
- } else {
- format!("{}px", label.len() as f32 * 9.)
- }
- }
- };
-
- let error_id = format!("{}-error", input_id);
-
- b.child(html!("label", {
- .attr("for", &input_id)
- .dwclass!("grid-col-1 grid-row-1 pointer-events-none transition-all m-l-4")
- .dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900")
- .dwclass_signal!("text-sm", raise_label.signal())
- .dwclass_signal!("text-base", not(raise_label.signal()))
- .style_signal("margin-top", raise_label.signal().map(|v| {
- if v {
- "-10px"
- } else {
- "8px"
- }
- }))
- .text_signal(label.signal_cloned())
- }))
- .child(html!("div", {
- .dwclass!("grid-col-1 grid-row-1 pointer-events-none border-l border-r border-b rounded-b-sm transition-colors")
- .dwclass_signal!("dwui-border-void-600 is(.light *):dwui-border-void-200", is_valid.signal())
- .dwclass_signal!("dwui-border-error-600 is(.light *):dwui-border-error-700", not(is_valid.signal()))
- }))
- .child(html!("div", {
- .dwclass!("grid-col-1 grid-row-1 pointer-events-none w-2 border-t transition-colors")
- .dwclass_signal!("dwui-border-void-600 is(.light *):dwui-border-void-200", is_valid.signal())
- .dwclass_signal!("dwui-border-error-500 is(.light *):dwui-border-error-700", not(is_valid.signal()))
- }))
- .child(html!("div", {
- .dwclass!("grid-col-1 grid-row-1 pointer-events-none transition-all border-t transition-colors")
- .dwclass_signal!("dwui-border-void-600 is(.light *):dwui-border-void-200", is_valid.signal())
- .dwclass_signal!("dwui-border-error-500 is(.light *):dwui-border-error-700", not(is_valid.signal()))
- .style_signal("margin-left", top_border_margin_signal)
- }))
- .child_signal(validation_signal.signal_cloned().map(move |validation| {
- match validation {
- ValidationResult::Valid => None,
- ValidationResult::Invalid { message } => {
- Some(html!("div", {
- .attr("id", &error_id)
- .attr("role", "alert")
- .dwclass!("grid-col-1 grid-row-2 pointer-events-none transition-all text-sm h-4")
- .dwclass!("dwui-text-error-500 is(.light *):dwui-text-error-700")
- .text(&message)
- }))
- }
- }
- }))
- }
-}
diff --git a/crates/dwui/src/mixins/mod.rs b/crates/dwui/src/mixins/mod.rs
index f747763..3658107 100644
--- a/crates/dwui/src/mixins/mod.rs
+++ b/crates/dwui/src/mixins/mod.rs
@@ -1 +1 @@
-pub mod labelled_rect_mixin;
+pub mod field_mixin;
diff --git a/crates/dwui/src/theme/mod.rs b/crates/dwui/src/theme/mod.rs
index ded1b9a..ee318d4 100644
--- a/crates/dwui/src/theme/mod.rs
+++ b/crates/dwui/src/theme/mod.rs
@@ -41,8 +41,52 @@ dwkeyframes! {
"0%, 100%" => "opacity: 1;",
"50%" => "opacity: 0.45;",
}
+
+ #[name = "dwui-toast-in"]
+ toast_in {
+ "from" => "opacity: 0; transform: translateY(0.5rem);",
+ "to" => "opacity: 1; transform: translateY(0);",
+ }
+
+ #[name = "dwui-slide-in-right"]
+ slide_in_right {
+ "from" => "transform: translateX(100%);",
+ "to" => "transform: translateX(0);",
+ }
+
+ #[name = "dwui-slide-in-left"]
+ slide_in_left {
+ "from" => "transform: translateX(-100%);",
+ "to" => "transform: translateX(0);",
+ }
}
+/// 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())
@@ -52,6 +96,77 @@ pub fn apply_style_sheet(colors: Option) -> 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 +224,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 +408,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/src/utils.rs b/crates/dwui/src/utils.rs
index f04337f..fc84f01 100644
--- a/crates/dwui/src/utils.rs
+++ b/crates/dwui/src/utils.rs
@@ -1,5 +1,53 @@
use std::sync::atomic::{AtomicU64, Ordering};
+/// Renders `content` as a direct child of `document.body` for as long as the
+/// returned placeholder stays mounted.
+///
+/// `position: fixed` is relative to the nearest *transformed* ancestor, not
+/// the viewport, so overlays rendered in place break as soon as any ancestor
+/// carries a transform, filter, or backdrop-filter (page-transition
+/// animations are a common source). Portalling the overlay to the body keeps
+/// it viewport-anchored no matter where the component is used. Unmounting the
+/// placeholder removes the portalled content.
+pub fn body_portal(content: dominator::Dom) -> dominator::Dom {
+ use discard::Discard;
+ use dominator::{html, DomHandle};
+ use std::cell::RefCell;
+ use std::rc::Rc;
+
+ let handle: Rc>> = Rc::new(RefCell::new(None));
+
+ html!("div", {
+ .style("display", "none")
+ .after_inserted({
+ let handle = handle.clone();
+ move |_| {
+ let document = web_sys::window()
+ .expect("no window")
+ .document()
+ .expect("no document");
+
+ // Apps sometimes replace wholesale (dominator's
+ // replace_dom pattern), so fall back to the document element.
+ let target: web_sys::Element = document
+ .body()
+ .map(Into::into)
+ .or_else(|| document.document_element())
+ .expect("no element to portal into");
+
+ handle
+ .borrow_mut()
+ .replace(dominator::append_dom(target.as_ref(), content));
+ }
+ })
+ .after_removed(move |_| {
+ if let Some(handle) = handle.borrow_mut().take() {
+ handle.discard();
+ }
+ })
+ })
+}
+
/// Generates a document-unique element id with the given prefix.
///
/// Used to associate ``, `aria-describedby`, and similar
diff --git a/crates/dwui/tests/components.rs b/crates/dwui/tests/components.rs
index 7bbdc9a..70635f3 100644
--- a/crates/dwui/tests/components.rs
+++ b/crates/dwui/tests/components.rs
@@ -78,6 +78,16 @@ fn click(element: &web_sys::Element) {
.click();
}
+/// Query the whole document — used for overlays that portal to `body`.
+fn doc_query(selector: &str) -> Option {
+ web_sys::window()
+ .unwrap()
+ .document()
+ .unwrap()
+ .query_selector(selector)
+ .unwrap()
+}
+
#[wasm_bindgen_test]
async fn button_is_a_real_button_and_handles_clicks() {
let tc = TestContainer::new();
@@ -379,10 +389,12 @@ async fn progress_indeterminate_omits_valuenow() {
#[wasm_bindgen_test]
async fn modal_has_dialog_semantics_and_closes() {
+ use discard::Discard;
+
let tc = TestContainer::new();
let open = Mutable::new(true);
- dominator::append_dom(
+ let handle = dominator::append_dom(
&tc.dom_element(),
modal!({
.open_signal(open.signal())
@@ -395,18 +407,23 @@ async fn modal_has_dialog_semantics_and_closes() {
);
wait_frame().await;
- let dialog = tc.query("[role=dialog]").unwrap();
+ // The modal portals to the body, so query the document
+ let dialog = doc_query("[role=dialog]").unwrap();
assert_eq!(dialog.get_attribute("aria-modal").as_deref(), Some("true"));
assert_eq!(
dialog.get_attribute("aria-label").as_deref(),
Some("Example dialog")
);
- let close_button = tc.query("button[aria-label='Close dialog']").unwrap();
+ let close_button = doc_query("button[aria-label='Close dialog']").unwrap();
click(&close_button);
wait_frame().await;
assert!(!open.get());
+
+ handle.discard();
+ wait_frame().await;
+ assert!(doc_query("[role=dialog]").is_none(), "the portal should clean up");
}
#[wasm_bindgen_test]
@@ -1051,3 +1068,808 @@ 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());
+}
+
+// ---------------------------------------------------------------------------
+// Field system
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn field_reserves_error_space_and_keeps_height() {
+ let tc = TestContainer::new();
+ let validity = Mutable::new(ValidationResult::Valid);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ text_input!({
+ .label("Email".to_string())
+ .is_valid_signal(validity.signal_cloned())
+ }),
+ );
+ wait_frames(2).await;
+
+ let input = tc.query("input").unwrap();
+ let input_id = input.get_attribute("id").unwrap();
+
+ // The message row exists (and reserves space) before any error appears.
+ let error_row = tc.query(&format!("[id='{}-error']", input_id)).unwrap();
+ assert_eq!(error_row.text_content().unwrap(), "");
+
+ let surface_height = input.parent_element().unwrap().client_height();
+
+ validity.set(ValidationResult::Invalid {
+ message: "Nope".to_string(),
+ });
+ wait_frames(2).await;
+
+ // Turning invalid must not change the field surface height.
+ assert_eq!(
+ input.parent_element().unwrap().client_height(),
+ surface_height,
+ "field surface changed height when it became invalid"
+ );
+ assert_eq!(error_row.text_content().unwrap(), "Nope");
+}
+
+#[wasm_bindgen_test]
+async fn text_input_and_select_support_disabled() {
+ let tc = TestContainer::new();
+ let disabled = Mutable::new(false);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ html!("div", {
+ .child(text_input!({
+ .label("Name".to_string())
+ .disabled_signal(disabled.signal())
+ }))
+ .child(select!({
+ .label("Fruit".to_string())
+ .disabled_signal(disabled.signal())
+ .options(vec![("a".to_string(), "Apple".to_string())])
+ }))
+ .child(slider!({
+ .label("Volume".to_string())
+ .disabled_signal(disabled.signal())
+ }))
+ }),
+ );
+ wait_frames(2).await;
+
+ assert!(!tc.query("input").unwrap().has_attribute("disabled"));
+ assert!(!tc.query("select").unwrap().has_attribute("disabled"));
+
+ disabled.set(true);
+ wait_frame().await;
+
+ assert!(tc.query("input").unwrap().has_attribute("disabled"));
+ assert!(tc.query("select").unwrap().has_attribute("disabled"));
+ assert!(tc
+ .query("input[type=range]")
+ .unwrap()
+ .has_attribute("disabled"));
+}
+
+// ---------------------------------------------------------------------------
+// text_area!
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn text_area_syncs_value_and_reports_invalid() {
+ let tc = TestContainer::new();
+ let value = Mutable::new("hello".to_string());
+ let validity = Mutable::new(ValidationResult::Valid);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ text_area!({
+ .value(value.clone())
+ .label("Bio".to_string())
+ .rows(6u32)
+ .is_valid_signal(validity.signal_cloned())
+ }),
+ );
+ wait_frames(2).await;
+
+ let textarea: web_sys::HtmlTextAreaElement =
+ tc.query("textarea").unwrap().dyn_into().unwrap();
+
+ assert_eq!(textarea.value(), "hello");
+ assert_eq!(textarea.get_attribute("rows").as_deref(), Some("6"));
+
+ let label = tc.query("label").unwrap();
+ assert_eq!(
+ label.get_attribute("for").unwrap(),
+ textarea.get_attribute("id").unwrap()
+ );
+
+ value.set("world".to_string());
+ wait_frames(2).await;
+ assert_eq!(textarea.value(), "world");
+
+ validity.set(ValidationResult::Invalid {
+ message: "Too short".to_string(),
+ });
+ wait_frames(2).await;
+
+ assert_eq!(
+ textarea.get_attribute("aria-invalid").as_deref(),
+ Some("true")
+ );
+ let alert = tc.query("[role=alert]").unwrap();
+ assert_eq!(alert.text_content().unwrap(), "Too short");
+}
+
+// ---------------------------------------------------------------------------
+// number_input!
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn number_input_steps_and_clamps() {
+ let tc = TestContainer::new();
+ let value = Mutable::new(5.0f64);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ number_input!({
+ .value(value.clone())
+ .label("Amount".to_string())
+ .min(Some(0.0))
+ .max(Some(6.0))
+ .step(2.0)
+ }),
+ );
+ wait_frames(2).await;
+
+ let input = tc.query("input[type=number]").unwrap();
+ assert_eq!(input.get_attribute("min").as_deref(), Some("0"));
+ assert_eq!(input.get_attribute("max").as_deref(), Some("6"));
+ assert_eq!(input.get_attribute("step").as_deref(), Some("2"));
+ assert_eq!(input.get_attribute("inputmode").as_deref(), Some("decimal"));
+
+ // The steppers are decorative: not tabbable, hidden from AT.
+ let steppers = tc.query_all("button[tabindex='-1'][aria-hidden=true]");
+ assert_eq!(steppers.length(), 2);
+
+ let up: web_sys::Element = steppers.item(0).unwrap().dyn_into().unwrap();
+ let down: web_sys::Element = steppers.item(1).unwrap().dyn_into().unwrap();
+
+ click(&up);
+ wait_frame().await;
+ // 5 + 2 clamps to max 6
+ assert_eq!(value.get(), 6.0);
+
+ click(&down);
+ wait_frame().await;
+ assert_eq!(value.get(), 4.0);
+}
+
+// ---------------------------------------------------------------------------
+// radio_group!
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn radio_group_selection_and_roving_tabindex() {
+ let tc = TestContainer::new();
+ let value = Mutable::new("b".to_string());
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ radio_group!({
+ .label("Flavor".to_string())
+ .value_signal(value.signal_cloned())
+ .options(vec![
+ ("a".to_string(), "Almond".to_string()),
+ ("b".to_string(), "Butterscotch".to_string()),
+ ("c".to_string(), "Cinnamon".to_string()),
+ ])
+ .on_change(clone!(value => move |key| {
+ value.set(key);
+ }))
+ }),
+ );
+ wait_frames(2).await;
+
+ let group = tc.query("[role=radiogroup]").unwrap();
+ let label_id = group.get_attribute("aria-labelledby").unwrap();
+ assert_eq!(
+ tc.query(&format!("[id='{}']", label_id))
+ .unwrap()
+ .text_content()
+ .unwrap(),
+ "Flavor"
+ );
+
+ let radios = tc.query_all("[role=radio]");
+ assert_eq!(radios.length(), 3);
+
+ let radio = |i: u32| -> web_sys::Element { radios.item(i).unwrap().dyn_into().unwrap() };
+
+ // Roving tabindex: only the checked option is a tab stop
+ assert_eq!(radio(0).get_attribute("tabindex").as_deref(), Some("-1"));
+ assert_eq!(radio(1).get_attribute("tabindex").as_deref(), Some("0"));
+ assert_eq!(radio(1).get_attribute("aria-checked").as_deref(), Some("true"));
+
+ click(&radio(2));
+ wait_frames(2).await;
+
+ assert_eq!(value.get_cloned(), "c");
+ assert_eq!(radio(2).get_attribute("aria-checked").as_deref(), Some("true"));
+ assert_eq!(radio(2).get_attribute("tabindex").as_deref(), Some("0"));
+ assert_eq!(radio(1).get_attribute("tabindex").as_deref(), Some("-1"));
+
+ // ArrowDown moves selection (selection follows focus)
+ let event = web_sys::KeyboardEvent::new_with_keyboard_event_init_dict("keydown", &{
+ let dict = web_sys::KeyboardEventInit::new();
+ dict.set_key("ArrowDown");
+ dict.set_bubbles(true);
+ dict.set_cancelable(true);
+ dict
+ })
+ .unwrap();
+ radio(2).dispatch_event(&event).unwrap();
+ wait_frames(2).await;
+
+ assert_eq!(value.get_cloned(), "a", "ArrowDown should wrap to the first option");
+}
+
+// ---------------------------------------------------------------------------
+// Control chrome
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn slider_chrome_tracks_fill_percentage() {
+ dwui::theme::apply_style_sheet(None);
+
+ let tc = TestContainer::new();
+ let value = Mutable::new(25.0f32);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ slider!({
+ .value(value.clone())
+ .label("Volume".to_string())
+ .min(0.0f32)
+ .max(100.0f32)
+ }),
+ );
+ wait_frames(2).await;
+
+ let range = tc.query("input[type=range]").unwrap();
+ assert!(range.class_list().contains("dwui-slider"));
+
+ let fill = |el: &web_sys::Element| {
+ el.dyn_ref::()
+ .unwrap()
+ .style()
+ .get_property_value("--dwui-slider-fill")
+ .unwrap()
+ };
+
+ assert_eq!(fill(&range).trim(), "25%");
+
+ value.set(50.0);
+ wait_frames(2).await;
+ assert_eq!(fill(&range).trim(), "50%");
+}
+
+#[wasm_bindgen_test]
+async fn checkbox_and_switch_use_the_on_accent_token() {
+ let tc = TestContainer::new();
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ html!("div", {
+ .child(checkbox!({ .checked(true) .label("Check".to_string()) }))
+ .child(switch!({ .checked(true) .label("Toggle".to_string()) }))
+ }),
+ );
+ wait_frames(2).await;
+
+ // Checkmark stroke is themed, not hard-coded white
+ let check_path = tc.query("[role=checkbox] path").unwrap();
+ assert!(check_path
+ .get_attribute("style")
+ .unwrap()
+ .contains("--dwui-on-accent"));
+ assert!(!check_path.has_attribute("stroke"));
+
+ // The switch knob is themed and moves by transform
+ let knob = tc.query("[role=switch] span").unwrap();
+ assert!(!knob.class_list().contains("bg-white"));
+
+ let transform = knob
+ .dyn_ref::()
+ .unwrap()
+ .style()
+ .get_property_value("transform")
+ .unwrap();
+ assert!(transform.contains("translateX"), "got {transform:?}");
+}
+
+// ---------------------------------------------------------------------------
+// Surfaces
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn heading_renders_without_a_wrapper() {
+ let tc = TestContainer::new();
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ heading!({
+ .content(text("Bare"))
+ .level(HeadingLevel::H3)
+ }),
+ );
+ wait_frame().await;
+
+ // The heading is the component's root element, not nested in a div.
+ let h3 = tc.query("h3").unwrap();
+ assert_eq!(
+ h3.parent_element().unwrap().get_attribute("style").as_deref().map(|s| s.contains("width:800px")),
+ Some(true),
+ "expected the heading to be a direct child of the test container"
+ );
+}
+
+#[wasm_bindgen_test]
+async fn card_defaults_to_padded_content() {
+ dwui::theme::apply_style_sheet(None);
+
+ let tc = TestContainer::new();
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ card!({
+ .content(text("padded"))
+ }),
+ );
+ wait_frames(2).await;
+
+ let card = tc.dom_element().first_element_child().unwrap();
+ let style = web_sys::window()
+ .unwrap()
+ .get_computed_style(&card)
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(style.get_property_value("padding-left").unwrap(), "16px");
+}
+
+#[wasm_bindgen_test]
+async fn modal_traps_tab_focus() {
+ use discard::Discard;
+
+ let tc = TestContainer::new();
+
+ let handle = dominator::append_dom(
+ &tc.dom_element(),
+ modal!({
+ .open(true)
+ .aria_label("Trap test".to_string())
+ .content(Some(html!("div", {
+ .child(html!("button", { .attr("id", "trap-inner") .text("Inner") }))
+ })))
+ }),
+ );
+ wait_frames(2).await;
+
+ let dialog = doc_query("[role=dialog]").unwrap();
+ let inner: web_sys::HtmlElement = doc_query("[id=trap-inner]")
+ .unwrap()
+ .dyn_into()
+ .unwrap();
+
+ // Focus the last focusable element (the inner button), then Tab: the trap
+ // must wrap focus around to the dialog's first focusable (the close
+ // button) instead of leaving the dialog.
+ inner.focus().unwrap();
+
+ let event = web_sys::KeyboardEvent::new_with_keyboard_event_init_dict("keydown", &{
+ let dict = web_sys::KeyboardEventInit::new();
+ dict.set_key("Tab");
+ dict.set_bubbles(true);
+ dict.set_cancelable(true);
+ dict
+ })
+ .unwrap();
+ dialog.dispatch_event(&event).unwrap();
+ wait_frame().await;
+
+ let active = web_sys::window()
+ .unwrap()
+ .document()
+ .unwrap()
+ .active_element()
+ .unwrap();
+
+ assert_eq!(
+ active.get_attribute("aria-label").as_deref(),
+ Some("Close dialog"),
+ "Tab from the last focusable should wrap to the close button"
+ );
+
+ handle.discard();
+}
+
+#[wasm_bindgen_test]
+async fn alert_dismiss_button_is_an_icon_with_a_label() {
+ let tc = TestContainer::new();
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ alert!({
+ .title("Closable".to_string())
+ .dismissible(true)
+ }),
+ );
+ wait_frame().await;
+
+ let dismiss = tc.query("button[aria-label=Dismiss]").unwrap();
+ assert!(dismiss.query_selector("svg").unwrap().is_some());
+ assert!(!dismiss.text_content().unwrap().contains('×'));
+}
+
+// ---------------------------------------------------------------------------
+// pagination!
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn pagination_renders_window_and_aria_current() {
+ let tc = TestContainer::new();
+ let page = Mutable::new(5usize);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ pagination!({
+ .page_signal(page.signal())
+ .total_pages(10usize)
+ .on_page_change(clone!(page => move |p| {
+ page.set(p);
+ }))
+ }),
+ );
+ wait_frames(2).await;
+
+ // 1 … 4 5 6 … 10 plus prev/next arrows
+ assert_eq!(tc.query_all("button").length(), 7);
+ assert_eq!(tc.query_all("span[aria-hidden=true]").length(), 2);
+
+ let current = tc.query("[aria-current=page]").unwrap();
+ assert_eq!(current.text_content().unwrap(), "5");
+
+ click(&tc.query("button[aria-label='Next page']").unwrap());
+ wait_frames(2).await;
+ assert_eq!(page.get(), 6);
+
+ click(&tc.query("button[aria-label='Page 10']").unwrap());
+ wait_frames(2).await;
+ assert_eq!(page.get(), 10);
+
+ // At the last page the next arrow is disabled
+ assert!(tc
+ .query("button[aria-label='Next page']")
+ .unwrap()
+ .has_attribute("disabled"));
+}
+
+// ---------------------------------------------------------------------------
+// dropdown_menu! / popover!
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn dropdown_menu_opens_selects_and_closes() {
+ let tc = TestContainer::new();
+ let selected: Mutable> = Mutable::new(None);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ dropdown_menu!({
+ .label("Actions".to_string())
+ .items(vec![
+ ("copy".to_string(), "Copy".to_string(), false),
+ ("paste".to_string(), "Paste".to_string(), true),
+ ("delete".to_string(), "Delete".to_string(), false),
+ ])
+ .on_select(clone!(selected => move |key| {
+ selected.set(Some(key));
+ }))
+ }),
+ );
+ wait_frames(2).await;
+
+ let trigger = tc.query("button[aria-haspopup=menu]").unwrap();
+ assert_eq!(trigger.get_attribute("aria-expanded").as_deref(), Some("false"));
+
+ let menu = tc.query("[role=menu]").unwrap();
+
+ click(&trigger);
+ wait_frames(2).await;
+
+ assert_eq!(trigger.get_attribute("aria-expanded").as_deref(), Some("true"));
+ assert_eq!(tc.query_all("[role=menuitem]").length(), 3);
+
+ let disabled_item = tc.query("[role=menuitem][aria-disabled=true]").unwrap();
+ assert_eq!(disabled_item.text_content().unwrap(), "Paste");
+
+ click(&tc.query(&format!("[id='{}-item-delete']", menu.get_attribute("id").unwrap())).unwrap());
+ wait_frames(2).await;
+
+ assert_eq!(selected.get_cloned().as_deref(), Some("delete"));
+ assert_eq!(trigger.get_attribute("aria-expanded").as_deref(), Some("false"));
+}
+
+#[wasm_bindgen_test]
+async fn popover_opens_and_closes_on_escape() {
+ let tc = TestContainer::new();
+ let open = Mutable::new(false);
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ popover!({
+ .open_signal(open.signal())
+ .anchor(Some(html!("button", { .attr("id", "pop-trigger") .text("Open") })))
+ .content(Some(text("Popover body")))
+ .on_close(clone!(open => move || {
+ open.set(false);
+ }))
+ }),
+ );
+ wait_frames(2).await;
+
+ open.set(true);
+ wait_frames(2).await;
+
+ let event = web_sys::KeyboardEvent::new_with_keyboard_event_init_dict("keydown", &{
+ let dict = web_sys::KeyboardEventInit::new();
+ dict.set_key("Escape");
+ dict.set_bubbles(true);
+ dict
+ })
+ .unwrap();
+ web_sys::window()
+ .unwrap()
+ .document()
+ .unwrap()
+ .body()
+ .unwrap()
+ .dispatch_event(&event)
+ .unwrap();
+ wait_frames(2).await;
+
+ assert!(!open.get(), "Escape should have closed the popover");
+}
+
+// ---------------------------------------------------------------------------
+// drawer!
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn drawer_opens_from_a_side_and_closes() {
+ use discard::Discard;
+
+ let tc = TestContainer::new();
+ let open = Mutable::new(true);
+
+ let handle = dominator::append_dom(
+ &tc.dom_element(),
+ drawer!({
+ .open_signal(open.signal())
+ .side(DrawerSide::Left)
+ .aria_label("Settings".to_string())
+ .content(Some(text("Drawer body")))
+ .on_close(clone!(open => move || {
+ open.set(false);
+ }))
+ }),
+ );
+ wait_frames(2).await;
+
+ let dialog = doc_query("[role=dialog]").unwrap();
+ assert_eq!(dialog.get_attribute("aria-label").as_deref(), Some("Settings"));
+ assert_eq!(dialog.get_attribute("aria-modal").as_deref(), Some("true"));
+
+ let style = dialog.get_attribute("style").unwrap_or_default();
+ assert!(style.contains("left: 0"), "left drawer should pin to the left edge, got {style:?}");
+
+ click(&doc_query("button[aria-label='Close drawer']").unwrap());
+ wait_frames(2).await;
+
+ assert!(!open.get());
+
+ handle.discard();
+}
+
+// ---------------------------------------------------------------------------
+// toasts! / Toaster
+// ---------------------------------------------------------------------------
+
+#[wasm_bindgen_test]
+async fn toaster_pushes_auto_dismisses_and_manually_dismisses() {
+ use discard::Discard;
+
+ let tc = TestContainer::new();
+ let toaster = Toaster::default();
+
+ let handle = dominator::append_dom(
+ &tc.dom_element(),
+ toasts!({
+ .toaster(toaster.clone())
+ }),
+ );
+ wait_frame().await;
+
+ let host = doc_query("[aria-live=polite]").unwrap();
+ assert_eq!(host.child_element_count(), 0);
+
+ // Auto-dismissing toast
+ toaster.push(ToastOptions {
+ title: "Saved".to_string(),
+ variant: ToastVariant::Success,
+ duration_ms: Some(80),
+ ..Default::default()
+ });
+
+ // Sticky error toast
+ let sticky = toaster.push(ToastOptions {
+ title: "Broken".to_string(),
+ variant: ToastVariant::Error,
+ duration_ms: None,
+ ..Default::default()
+ });
+
+ wait_frame().await;
+ assert_eq!(host.child_element_count(), 2);
+ assert!(doc_query("[role=alert]").is_some(), "error toasts announce assertively");
+
+ gloo_timers::future::TimeoutFuture::new(300).await;
+ assert_eq!(host.child_element_count(), 1, "the timed toast should have auto-dismissed");
+
+ // Dismiss the sticky one via its button
+ click(&host.query_selector("button[aria-label=Dismiss]").unwrap().unwrap());
+ wait_frame().await;
+ assert_eq!(host.child_element_count(), 0);
+
+ // And the handle API can clear directly
+ toaster.push(ToastOptions::default());
+ wait_frame().await;
+ assert_eq!(host.child_element_count(), 1);
+ toaster.dismiss(sticky + 1);
+ toaster.clear();
+ wait_frame().await;
+ assert_eq!(host.child_element_count(), 0);
+
+ handle.discard();
+}
+
+#[wasm_bindgen_test]
+async fn select_popup_is_theme_colored() {
+ dwui::theme::apply_style_sheet(None);
+
+ let tc = TestContainer::new();
+
+ dominator::append_dom(
+ &tc.dom_element(),
+ select!({
+ .label("Fruit".to_string())
+ .options(vec![("a".to_string(), "Apple".to_string())])
+ }),
+ );
+ wait_frames(2).await;
+
+ let select = tc.query("select").unwrap();
+ assert!(select.class_list().contains("dwui-select"));
+
+ // The popup follows the element's color-scheme, and engines that paint
+ // options themselves honor the explicit option colors.
+ let style = web_sys::window()
+ .unwrap()
+ .get_computed_style(&select)
+ .unwrap()
+ .unwrap();
+ assert_eq!(style.get_property_value("color-scheme").unwrap(), "dark");
+
+ let option = tc.query("option").unwrap();
+ let option_style = web_sys::window()
+ .unwrap()
+ .get_computed_style(&option)
+ .unwrap()
+ .unwrap();
+ assert!(
+ !option_style
+ .get_property_value("background-color")
+ .unwrap()
+ .is_empty(),
+ "option should have an explicit background"
+ );
+}
diff --git a/examples/webpage/Cargo.toml b/examples/webpage/Cargo.toml
index fdc37ae..d163c97 100644
--- a/examples/webpage/Cargo.toml
+++ b/examples/webpage/Cargo.toml
@@ -12,11 +12,12 @@ crate-type = ["cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
+console_error_panic_hook = "0.1.7"
const_format = { workspace = true }
dominator = { workspace = true }
dwind = { path = "../../crates/dwind", version = "0.8.0" }
dwind-macros = { path = "../../crates/dwind-macros", version = "0.5.0" }
-dwui = { path = "../../crates/dwui", version = "0.9.1" }
+dwui = { path = "../../crates/dwui", version = "0.10.0" }
example-html-highlight-macro = { path = "../../crates/example-html-macro", version = "0.1.1" }
futures = { workspace = true }
futures-signals = { workspace = true }
diff --git a/examples/webpage/src/lib.rs b/examples/webpage/src/lib.rs
index 611d69a..07e284e 100644
--- a/examples/webpage/src/lib.rs
+++ b/examples/webpage/src/lib.rs
@@ -36,6 +36,7 @@ use web_sys::window;
#[cfg(not(test))]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
async fn main() {
+ console_error_panic_hook::set_once();
wasm_log::init(Default::default());
dominator::replace_dom(&body().parent_node().unwrap(), &body(), main_view());
diff --git a/examples/webpage/src/pages/components_page.rs b/examples/webpage/src/pages/components_page.rs
index c4f9eaf..92c2146 100644
--- a/examples/webpage/src/pages/components_page.rs
+++ b/examples/webpage/src/pages/components_page.rs
@@ -62,9 +62,16 @@ pub fn components_page() -> Dom {
section_card("Buttons", "Flat, border, and text variants in three sizes — with focus rings, hover, active, and disabled states", buttons_demo(), code(&BUTTONS_DEMO_EXAMPLE_HTML_MAP)),
section_card("Badges", "Status pills in primary, error, void, and outline variants", badges_demo(), code(&BADGES_DEMO_EXAMPLE_HTML_MAP)),
section_card("Alerts", "Status callouts with severity-aware ARIA roles; the error alert is dismissible", alerts_demo(), code(&ALERTS_DEMO_EXAMPLE_HTML_MAP)),
- section_card("Text inputs", "Floating labels, password masking, and live validation announced to screen readers", inputs_demo(), code(&INPUTS_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Text inputs", "Floating labels, password masking, disabled state, and live validation announced to screen readers", inputs_demo(), code(&INPUTS_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Textarea & number", "Multi-line and numeric fields sharing the same labelled field surface", text_area_number_demo(), code(&TEXT_AREA_NUMBER_DEMO_EXAMPLE_HTML_MAP)),
section_card("Select & slider", "Labelled form controls bound to reactive signals", select_slider_demo(), code(&SELECT_SLIDER_DEMO_EXAMPLE_HTML_MAP)),
section_card("Switch & checkbox", "Toggles with role=switch/checkbox semantics and clickable labels", toggles_demo(), code(&TOGGLES_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Radio group", "WAI-ARIA radios with roving tabindex — selection follows arrow-key focus", radio_group_demo(), code(&RADIO_GROUP_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Dropdown menu", "A menu button with full keyboard navigation; disabled items are skipped", dropdown_menu_demo(), code(&DROPDOWN_MENU_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Pagination", "Windowed page numbers with ellipses and aria-current on the active page", pagination_demo(), code(&PAGINATION_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Toasts", "Push notifications into a fixed aria-live host — timed toasts dismiss themselves", toasts_demo(), code(&TOASTS_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("Cards & headings", "Card schemes and padding scales, and semantic h1-h6 headings", cards_headings_demo(), code(&CARDS_HEADINGS_DEMO_EXAMPLE_HTML_MAP)),
+ section_card("List", "A selectable list with keyboard activation and aria-current", list_demo(), code(&LIST_DEMO_EXAMPLE_HTML_MAP)),
section_card("Progress & spinner", "Determinate, indeterminate, and spinner loading states with aria-valuenow", progress_demo(), code(&PROGRESS_DEMO_EXAMPLE_HTML_MAP)),
section_card("Avatars & skeletons", "Identity with initials fallback, plus pulsing loading placeholders", avatar_skeleton_demo(), code(&AVATAR_SKELETON_DEMO_EXAMPLE_HTML_MAP)),
section_card("Tooltip & divider", "Hover or focus the buttons to reveal tooltips", tooltip_divider_demo(), code(&TOOLTIP_DIVIDER_DEMO_EXAMPLE_HTML_MAP)),
@@ -86,8 +93,9 @@ pub fn components_page() -> Dom {
.child(section_card("Date picker", "Calendar popup with full keyboard support: arrows move by day and week, PageUp/PageDown by month, Escape closes.", date_picker_demo(), code(&DATE_PICKER_DEMO_EXAMPLE_HTML_MAP)))
}))
.child(html!("div", {
- .dwclass!("m-t-4")
- .child(section_card("Modal dialogs", "Focus-managed dialogs with backdrop blur, escape key, and entrance animation", example_card_modal(), code(&EXAMPLE_CARD_MODAL_EXAMPLE_HTML_MAP)))
+ .dwclass!("m-t-4 grid @md:grid-cols-2 @ Dom {
card!({
.scheme(ColorScheme::Void)
+ .padding(CardPadding::Large)
.apply(crate::fx::spotlight)
.apply(move |b| {
- dwclass!(b, "p-6 flex flex-col gap-4")
+ dwclass!(b, "flex flex-col gap-4")
.child(heading!({
.content(text(&title))
.text_size(TextSize::Large)
@@ -227,7 +236,7 @@ fn alerts_demo() -> Dom {
alert!({
.variant(AlertVariant::Error)
.title("Build failed".to_string())
- .content(Some(text("Click × to dismiss this alert.")))
+ .content(Some(text("Use the dismiss button to close this alert.")))
.dismissible(true)
}),
])
@@ -260,10 +269,254 @@ fn inputs_demo() -> Dom {
}))
.label("Accepts bananas".to_string())
}),
+ text_input!({
+ .value(Mutable::new("Read only".to_string()))
+ .disabled(true)
+ .label("Disabled".to_string())
+ }),
+ ])
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn text_area_number_demo() -> Dom {
+ let bio = Mutable::new("".to_string());
+ let amount = Mutable::new(3.0f64);
+
+ html!("div", {
+ .dwclass!("flex flex-col gap-4")
+ .children([
+ text_area!({
+ .value(bio.clone())
+ .label("Short bio".to_string())
+ .rows(3u32)
+ }),
+ number_input!({
+ .value(amount.clone())
+ .label("Quantity".to_string())
+ .min(Some(0.0))
+ .max(Some(10.0))
+ .step(1.0)
+ }),
])
})
}
+#[example_html(themes = ["base16-ocean.dark"])]
+fn radio_group_demo() -> Dom {
+ let flavor = Mutable::new("butterscotch".to_string());
+ let channel = Mutable::new("stable".to_string());
+
+ html!("div", {
+ .dwclass!("flex flex-col gap-6")
+ .child(radio_group!({
+ .label("Flavor".to_string())
+ .value_signal(flavor.signal_cloned())
+ .options(vec![
+ ("almond".to_string(), "Almond".to_string()),
+ ("butterscotch".to_string(), "Butterscotch".to_string()),
+ ("cinnamon".to_string(), "Cinnamon".to_string()),
+ ])
+ .on_change(clone!(flavor => move |key| flavor.set(key)))
+ }))
+ .child(radio_group!({
+ .label("Release channel".to_string())
+ .direction(RadioGroupDirection::Horizontal)
+ .value_signal(channel.signal_cloned())
+ .options(vec![
+ ("stable".to_string(), "Stable".to_string()),
+ ("beta".to_string(), "Beta".to_string()),
+ ("nightly".to_string(), "Nightly".to_string()),
+ ])
+ .on_change(clone!(channel => move |key| channel.set(key)))
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn dropdown_menu_demo() -> Dom {
+ let last_action: Mutable> = Mutable::new(None);
+
+ html!("div", {
+ .dwclass!("flex flex-col gap-4 align-items-start")
+ .child(dropdown_menu!({
+ .label("Actions".to_string())
+ .items(vec![
+ ("copy".to_string(), "Copy link".to_string(), false),
+ ("rename".to_string(), "Rename".to_string(), false),
+ ("archive".to_string(), "Archive".to_string(), true),
+ ("delete".to_string(), "Delete".to_string(), false),
+ ])
+ .on_select(clone!(last_action => move |key| {
+ last_action.set(Some(key));
+ }))
+ }))
+ .child(html!("p", {
+ .dwclass!("text-sm dwui-text-on-primary-400 is(.light *):dwui-text-on-primary-600 m-0")
+ .text_signal(last_action.signal_cloned().map(|action| {
+ match action {
+ Some(key) => format!("Selected: {key}"),
+ None => "Nothing selected yet".to_string(),
+ }
+ }))
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn pagination_demo() -> Dom {
+ let page = Mutable::new(1usize);
+
+ html!("div", {
+ .dwclass!("flex flex-col gap-4 align-items-start")
+ .child(pagination!({
+ .page_signal(page.signal())
+ .total_pages(12usize)
+ .on_page_change(clone!(page => move |p| page.set(p)))
+ }))
+ .child(html!("p", {
+ .dwclass!("text-sm dwui-text-on-primary-400 is(.light *):dwui-text-on-primary-600 m-0")
+ .text_signal(page.signal().map(|p| format!("Viewing page {p} of 12")))
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn toasts_demo() -> Dom {
+ let toaster = Toaster::default();
+
+ html!("div", {
+ .dwclass!("flex flex-row flex-wrap gap-4")
+ // The host is position: fixed, so toasts appear at the viewport corner
+ .child(toasts!({
+ .toaster(toaster.clone())
+ .position(ToastPosition::BottomRight)
+ }))
+ .child(button!({
+ .button_type(ButtonType::Border)
+ .size(ButtonSize::Small)
+ .content(Some(text("Success toast")))
+ .on_click(clone!(toaster => move |_| {
+ toaster.success("Saved to workspace");
+ }))
+ }))
+ .child(button!({
+ .button_type(ButtonType::Border)
+ .size(ButtonSize::Small)
+ .content(Some(text("Sticky error")))
+ .on_click(clone!(toaster => move |_| {
+ toaster.push(ToastOptions {
+ title: "Build failed".to_string(),
+ message: Some("Sticky until dismissed".to_string()),
+ variant: ToastVariant::Error,
+ duration_ms: None,
+ ..Default::default()
+ });
+ }))
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn cards_headings_demo() -> Dom {
+ html!("div", {
+ .dwclass!("flex flex-col gap-4")
+ .child(card!({
+ .scheme(ColorScheme::Primary)
+ .padding(CardPadding::Small)
+ .content(html!("div", {
+ .child(heading!({
+ .content(text("Primary card"))
+ .text_size(TextSize::Base)
+ .level(HeadingLevel::H3)
+ }))
+ .child(html!("p", {
+ .dwclass!("text-sm m-0")
+ .text("Small padding, primary scheme.")
+ }))
+ }))
+ }))
+ .child(card!({
+ .content(html!("div", {
+ .child(heading!({
+ .content(text("Void card"))
+ .text_size(TextSize::Large)
+ .level(HeadingLevel::H3)
+ }))
+ .child(html!("p", {
+ .dwclass!("text-sm m-0")
+ .text("Default medium padding; headings render bare h1-h6 tags.")
+ }))
+ }))
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn list_demo() -> Dom {
+ let selected = Mutable::new(Some(1usize));
+
+ html!("div", {
+ .dwclass!("flex flex-col gap-4")
+ .child(list!({
+ .selected_index_signal(selected.signal())
+ .items(vec![
+ text("Getting started"),
+ text("Theming"),
+ text("Components"),
+ text("Recipes"),
+ ])
+ .item_click_handler(clone!(selected => move |index| {
+ selected.set(Some(index));
+ }))
+ }))
+ })
+}
+
+#[example_html(themes = ["base16-ocean.dark"])]
+fn drawer_demo() -> Dom {
+ let open = Mutable::new(false);
+ let side = Mutable::new(DrawerSide::Right);
+
+ html!("div", {
+ .dwclass!("flex flex-row gap-4")
+ .child(button!({
+ .button_type(ButtonType::Border)
+ .content(Some(text("Open right drawer")))
+ .on_click(clone!(open, side => move |_| {
+ side.set(DrawerSide::Right);
+ open.set(true);
+ }))
+ }))
+ .child(button!({
+ .button_type(ButtonType::Border)
+ .content(Some(text("Open left drawer")))
+ .on_click(clone!(open, side => move |_| {
+ side.set(DrawerSide::Left);
+ open.set(true);
+ }))
+ }))
+ .child(drawer!({
+ .open_signal(open.signal())
+ .side_signal(side.signal())
+ .aria_label("Demo drawer".to_string())
+ .on_close(clone!(open => move || open.set(false)))
+ .content(Some(html!("div", {
+ .dwclass!("flex flex-col gap-3 m-t-8")
+ .child(heading!({
+ .content(text("Drawer"))
+ .text_size(TextSize::Large)
+ .level(HeadingLevel::H2)
+ }))
+ .child(html!("p", {
+ .dwclass!("text-sm m-0")
+ .text("Escape closes it, Tab stays trapped inside, and the scrim click dismisses.")
+ }))
+ })))
+ }))
+ })
+}
+
#[example_html(themes = ["base16-ocean.dark"])]
fn select_slider_demo() -> Dom {
let selected = Mutable::new("a".to_string());
diff --git a/examples/webpage/src/pages/dwui/example_small.rs b/examples/webpage/src/pages/dwui/example_small.rs
index 6f1c290..08e4e72 100644
--- a/examples/webpage/src/pages/dwui/example_small.rs
+++ b/examples/webpage/src/pages/dwui/example_small.rs
@@ -13,7 +13,7 @@ pub fn example_card_modal() -> Dom {
card!({
.scheme(ColorScheme::Void)
.apply(clone!(show_modal, show_modal_small => move |b| {
- dwclass!(b, "p-4 w-64 flex-initial flex flex-col gap-4")
+ dwclass!(b, "w-64 flex-initial flex flex-col gap-4")
.children([
heading!({
.content(text("Modal Example"))
@@ -21,12 +21,14 @@ pub fn example_card_modal() -> Dom {
.level(HeadingLevel::H2)
}),
button!({
+ .apply(|b| dwclass!(b, "w-full"))
.content(Some(text("Open Large Modal")))
.on_click(clone!(show_modal => move |_: events::Click| {
show_modal.set(true);
}))
}),
button!({
+ .apply(|b| dwclass!(b, "w-full"))
.button_type(ButtonType::Border)
.content(Some(text("Open Small Modal")))
.on_click(clone!(show_modal_small => move |_: events::Click| {
diff --git a/examples/webpage/src/pages/home.rs b/examples/webpage/src/pages/home.rs
index 5967555..43b063c 100644
--- a/examples/webpage/src/pages/home.rs
+++ b/examples/webpage/src/pages/home.rs
@@ -71,6 +71,7 @@ fn hero() -> Dom {
.dwclass!("w-44")
.apply(magnetic(7.0))
.child(button!({
+ .apply(|b| dwclass!(b, "w-full"))
.content(Some(text("Get started")))
.on_click(|_: events::Click| {
go_to_url("#/docs/getting-started");
@@ -81,6 +82,7 @@ fn hero() -> Dom {
.dwclass!("w-44")
.apply(magnetic(7.0))
.child(button!({
+ .apply(|b| dwclass!(b, "w-full"))
.button_type(ButtonType::Border)
.content(Some(text("Components")))
.on_click(|_: events::Click| {
@@ -390,7 +392,7 @@ fn components_preview() -> Dom {
.child(html!("p", {
.dwclass!("text-woodsmoke-400 m-t-4 m-b-8")
.style("max-width", "40rem")
- .text("Buttons, forms, tables, dialogs, date pickers, virtualized lists — 24 themeable building blocks, \
+ .text("Buttons, forms, tables, dialogs, drawers, toasts, date pickers, virtualized lists — over 30 themeable building blocks, \
each with ARIA semantics and keyboard support baked in.")
}))
.child(html!("div", {
@@ -433,7 +435,7 @@ fn components_preview() -> Dom {
}))
.child(badge!({
.variant(BadgeVariant::Outline)
- .content(Some(text("v0.9.0")))
+ .content(Some(text("v0.10.0")))
}))
}),
breadcrumbs!({
@@ -452,6 +454,7 @@ fn components_preview() -> Dom {
.dwclass!("w-64")
.apply(magnetic(6.0))
.child(button!({
+ .apply(|b| dwclass!(b, "w-full"))
.button_type(ButtonType::Border)
.content(Some(text("Browse all components →")))
.on_click(|_: events::Click| {
@@ -509,6 +512,7 @@ fn final_cta() -> Dom {
.dwclass!("w-44")
.apply(magnetic(7.0))
.child(button!({
+ .apply(|b| dwclass!(b, "w-full"))
.content(Some(text("Read the docs")))
.on_click(|_: events::Click| {
go_to_url("#/docs/getting-started");
@@ -519,6 +523,7 @@ fn final_cta() -> Dom {
.dwclass!("w-44")
.apply(magnetic(7.0))
.child(button!({
+ .apply(|b| dwclass!(b, "w-full"))
.button_type(ButtonType::Border)
.content(Some(text("Open the gallery")))
.on_click(|_: events::Click| {
diff --git a/examples/webpage/trunk2.log b/examples/webpage/trunk2.log
new file mode 100644
index 0000000..9d26df8
--- /dev/null
+++ b/examples/webpage/trunk2.log
@@ -0,0 +1,2 @@
+2026-07-26T08:43:47.810182Z INFO 🚀 Starting trunk 0.21.14
+2026-07-26T08:43:47.810368Z ERROR Unable to find any Trunk configuration