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 `