diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 63a5e3a9..55a331f5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,7 +62,7 @@ jobs: with: packages: libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev libxdo-dev version: 1.0 - - uses: dtolnay/rust-toolchain@1.86.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 @@ -77,7 +77,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.86.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: components: rustfmt - uses: Swatinem/rust-cache@v2 @@ -116,7 +116,7 @@ jobs: with: packages: libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev libxdo-dev version: 1.0 - - uses: dtolnay/rust-toolchain@1.86.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b778aa92..bb34a8b9 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -47,7 +47,7 @@ jobs: with: node-version: lts/* - name: Install Rust - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@1.88.0 with: targets: x86_64-unknown-linux-gnu,wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 diff --git a/Cargo.lock b/Cargo.lock index 35081760..0dc16eff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,7 +283,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "raw-window-handle 0.6.2", "serde", "serde_repr", @@ -341,9 +341,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" dependencies = [ "async-lock", "blocking", @@ -352,9 +352,9 @@ dependencies = [ [[package]] name = "async-io" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" dependencies = [ "async-lock", "cfg-if", @@ -365,8 +365,7 @@ dependencies = [ "polling", "rustix 1.0.8", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -382,9 +381,9 @@ dependencies = [ [[package]] name = "async-process" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde3f4e40e6021d7acffc90095cbd6dc54cb593903d1de5832f435eb274b85dc" +checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" dependencies = [ "async-channel", "async-io", @@ -396,7 +395,6 @@ dependencies = [ "event-listener", "futures-lite", "rustix 1.0.8", - "tracing", ] [[package]] @@ -412,9 +410,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7605a4e50d4b06df3898d5a70bf5fde51ed9059b0434b73105193bc27acce0d" +checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" dependencies = [ "async-io", "async-lock", @@ -425,7 +423,7 @@ dependencies = [ "rustix 1.0.8", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -827,9 +825,9 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.9.3" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" +checksum = "441473f2b4b0459a68628c744bc61d23e730fb00128b841d30fa4bb3972257e4" dependencies = [ "proc-macro2", "quote", @@ -907,9 +905,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.29" +version = "1.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" dependencies = [ "jobserver", "libc", @@ -1099,9 +1097,9 @@ dependencies = [ [[package]] name = "const-str" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "041fbfcf8e7054df725fb9985297e92422cdc80fcf313665f5ca3d761bb63f4c" +checksum = "451d0640545a0553814b4c646eb549343561618838e9b42495f466131fe3ad49" [[package]] name = "const_format" @@ -1611,7 +1609,7 @@ dependencies = [ "objc_id", "openssl", "percent-encoding", - "rand 0.9.1", + "rand 0.9.2", "rfd", "rustc-hash 2.1.1", "serde", @@ -3336,9 +3334,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64", "bytes", @@ -3623,9 +3621,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ "bitflags 2.9.1", "cfg-if", @@ -3770,11 +3768,12 @@ dependencies = [ [[package]] name = "kurbo" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1077d333efea6170d9ccb96d3c3026f300ca0773da4938cc4c811daa6df68b0c" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" dependencies = [ "arrayvec", + "euclid", "smallvec", ] @@ -3843,7 +3842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -3854,13 +3853,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.9.1", "libc", - "redox_syscall 0.5.13", + "redox_syscall 0.5.17", ] [[package]] @@ -3908,9 +3907,9 @@ checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "litrs" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" @@ -4161,9 +4160,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b89bf91c19bf036347f1ab85a81c560f08c0667c8601bece664d860a600988" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" dependencies = [ "crossbeam-channel", "dpi", @@ -4177,7 +4176,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.12", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4213,7 +4212,7 @@ dependencies = [ "log", "rustc-hash 1.1.0", "spirv", - "strum", + "strum 0.26.3", "termcolor", "thiserror 2.0.12", "unicode-xid", @@ -4829,9 +4828,9 @@ dependencies = [ [[package]] name = "owned_ttf_parser" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ "ttf-parser", ] @@ -4885,7 +4884,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.13", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -5143,17 +5142,16 @@ dependencies = [ [[package]] name = "polling" -version = "3.8.0" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50" +checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", "rustix 1.0.8", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5196,6 +5194,7 @@ dependencies = [ "dioxus", "dioxus-primitives", "pulldown-cmark", + "strum 0.27.2", "syntect", "tracing", ] @@ -5380,9 +5379,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -5522,18 +5521,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags 2.9.1", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", @@ -5680,9 +5679,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -5733,9 +5732,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "once_cell", "rustls-pki-types", @@ -5947,9 +5946,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "itoa", "memchr", @@ -6054,9 +6053,9 @@ dependencies = [ [[package]] name = "server_fn_macro" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d530c872590473016016679c94e3bddf47372941fc924f687ffd11a1778a71" +checksum = "b381389c2307b4b83ce70516c0408d99727ab9f73645e065bb51e6be09cb2f6b" dependencies = [ "const_format", "convert_case 0.8.0", @@ -6069,9 +6068,9 @@ dependencies = [ [[package]] name = "server_fn_macro_default" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7abc92ed696648275ed9ff171131a83d571af11748593dc2e6eb6a4e22a5b9" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" dependencies = [ "server_fn_macro", "syn 2.0.104", @@ -6283,12 +6282,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6384,7 +6383,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -6400,6 +6408,18 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "stylo" version = "0.4.0" @@ -6523,9 +6543,9 @@ checksum = "500f379645e8a87fd03fe88607a5edcb0d8e4e423baa74ba52db198a06a0c261" [[package]] name = "stylo_taffy" -version = "0.1.0-alpha.5" +version = "0.1.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea77ce9f0efa0e18b375749879bf65b8b432e95e37ca7222a7818b0445ff78c" +checksum = "dce8876a33e0f3b16916b2b4da1cdbb64b9985d5ae61033d23024d95e45569ac" dependencies = [ "stylo", "taffy", @@ -6988,9 +7008,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" dependencies = [ "backtrace", "bytes", @@ -7003,7 +7023,7 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7269,9 +7289,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da75ec677957aa21f6e0b361df0daab972f13a5bee3606de0638fd4ee1c666a" +checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2" dependencies = [ "crossbeam-channel", "dirs", @@ -7314,7 +7334,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.1", + "rand 0.9.2", "sha1", "thiserror 2.0.12", "utf-8", @@ -7332,7 +7352,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.1", + "rand 0.9.2", "rustls", "sha1", "thiserror 2.0.12", @@ -7738,13 +7758,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.0.8", "scoped-tls", "smallvec", "wayland-sys", @@ -7752,12 +7772,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.9.1", - "rustix 0.38.44", + "rustix 1.0.8", "wayland-backend", "wayland-scanner", ] @@ -7775,20 +7795,20 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 0.38.44", + "rustix 1.0.8", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.8" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ "bitflags 2.9.1", "wayland-backend", @@ -7798,9 +7818,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd38cdad69b56ace413c6bcc1fbf5acc5e2ef4af9d5f8f1f9570c0c83eae175" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.9.1", "wayland-backend", @@ -7811,9 +7831,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ "bitflags 2.9.1", "wayland-backend", @@ -7824,9 +7844,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", "quick-xml 0.37.5", @@ -7835,9 +7855,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", @@ -8329,7 +8349,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -8380,10 +8400,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -8594,9 +8615,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winit" -version = "0.30.11" +version = "0.30.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4409c10174df8779dc29a4788cac85ed84024ccbc1743b776b21a520ee1aaf4" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" dependencies = [ "ahash", "android-activity", @@ -9096,9 +9117,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-jpeg" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9e525af0a6a658e031e95f14b7f889976b74a11ba0eca5a5fc9ac8a1c43a6a" +checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089" dependencies = [ "zune-core", ] diff --git a/playwright/select.spec.ts b/playwright/select.spec.ts index 6d48baed..bf0f48fe 100644 --- a/playwright/select.spec.ts +++ b/playwright/select.spec.ts @@ -1,60 +1,128 @@ -import { test, expect } from '@playwright/test'; - -test('test', async ({ page }) => { - await page.goto('http://127.0.0.1:8080/component/?name=select&', { timeout: 20 * 60 * 1000 }); // Increase timeout to 20 minutes - // Find Select a fruit... - let selectTrigger = page.locator(".select-trigger"); - await selectTrigger.click(); - // Assert the select menu is open - const selectMenu = page.locator('.select-list'); - await expect(selectMenu).toHaveAttribute('data-state', 'open'); - - // Assert the menu is focused - await expect(selectMenu).toBeFocused(); - await page.keyboard.press('ArrowDown'); - const firstOption = selectMenu.getByRole('option', { name: 'apple' }); - await expect(firstOption).toBeFocused(); - - // Assert moving down with arrow keys moves focus to the next option - await page.keyboard.press('ArrowDown'); - const secondOption = selectMenu.getByRole('option', { name: 'banana' }); - await expect(secondOption).toBeFocused(); - - // Assert moving up with arrow keys moves focus back to the previous option - await page.keyboard.press('ArrowUp'); - await expect(firstOption).toBeFocused(); - - // Assert pressing Enter selects the focused option - await page.keyboard.press('Enter'); - // Assert the select menu is closed after selection - await expect(selectMenu).toHaveCount(0); - - // Assert the selected value is displayed in the button - await expect(selectTrigger).toHaveText('apple'); - - // Reopen the select menu - await selectTrigger.click(); - - // Assert typeahead functionality works - await page.keyboard.type('Ban'); - // Assert the second option is focused after typing 'Ban' - await expect(secondOption).toBeFocused(); - - // Assert pressing Escape closes the select menu - await page.keyboard.press('Escape'); - // Assert the select menu is closed - await expect(selectMenu).toHaveCount(0); - - // Reopen the select menu - await selectTrigger.click(); - // Assert the select menu is open again - await expect(selectMenu).toHaveAttribute('data-state', 'open'); - - // Click the second option to select it - let bananaOption = selectMenu.getByRole('option', { name: 'banana' }); - await bananaOption.click(); - // Assert the select menu is closed after clicking an option - await expect(selectMenu).toHaveCount(0); - // Assert the selected value is now 'banana' - await expect(selectTrigger).toHaveText('banana'); +import { test, expect } from "@playwright/test"; + +test("test", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&", { + timeout: 20 * 60 * 1000, + }); // Increase timeout to 20 minutes + // Find Select a fruit... + let selectTrigger = page.locator(".select-trigger"); + await selectTrigger.click(); + // Assert the select menu is open + const selectMenu = page.locator(".select-list"); + await expect(selectMenu).toHaveAttribute("data-state", "open"); + + // Assert the menu is focused + await expect(selectMenu).toBeFocused(); + await page.keyboard.press("ArrowDown"); + const firstOption = selectMenu.getByRole("option", { name: "apple" }); + await expect(firstOption).toBeFocused(); + + // Assert moving down with arrow keys moves focus to the next option + await page.keyboard.press("ArrowDown"); + const secondOption = selectMenu.getByRole("option", { name: "banana" }); + await expect(secondOption).toBeFocused(); + + // Assert moving up with arrow keys moves focus back to the previous option + await page.keyboard.press("ArrowUp"); + await expect(firstOption).toBeFocused(); + + // Assert pressing Enter selects the focused option + await page.keyboard.press("Enter"); + // Assert the select menu is closed after selection + await expect(selectMenu).toHaveCount(0); + + // Assert the selected value is displayed in the button + await expect(selectTrigger).toHaveText("Apple"); + + // Reopen the select menu + await selectTrigger.click(); + + // Assert typeahead functionality works + await page.keyboard.type("Ban"); + // Assert the second option is focused after typing 'Ban' + await expect(secondOption).toBeFocused(); + + // Assert pressing Escape closes the select menu + await page.keyboard.press("Escape"); + // Assert the select menu is closed + await expect(selectMenu).toHaveCount(0); + + // Reopen the select menu + await selectTrigger.click(); + // Assert the select menu is open again + await expect(selectMenu).toHaveAttribute("data-state", "open"); + + // Click the second option to select it + let bananaOption = selectMenu.getByRole("option", { name: "banana" }); + await bananaOption.click(); + // Assert the select menu is closed after clicking an option + await expect(selectMenu).toHaveCount(0); + // Assert the selected value is now 'banana' + await expect(selectTrigger).toHaveText("Banana"); +}); + +test("tabbing out of menu closes the select menu", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&"); + // Find Select a fruit... + let selectTrigger = page.locator(".select-trigger"); + await selectTrigger.click(); + // Assert the select menu is open + const selectMenu = page.locator(".select-list"); + await expect(selectMenu).toHaveAttribute("data-state", "open"); + + // Assert the menu is focused + await expect(selectMenu).toBeFocused(); + await page.keyboard.press("Tab"); + // Assert the select menu is closed + await expect(selectMenu).toHaveCount(0); +}); + +test("tabbing out of item closes the select menu", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&"); + // Find Select a fruit... + let selectTrigger = page.locator(".select-trigger"); + await selectTrigger.click(); + // Assert the select menu is open + const selectMenu = page.locator(".select-list"); + await expect(selectMenu).toHaveAttribute("data-state", "open"); + + // Assert the menu is focused + await expect(selectMenu).toBeFocused(); + + // Navigate to the first option + await page.keyboard.press("ArrowDown"); + const firstOption = selectMenu.getByRole("option", { name: "apple" }); + await expect(firstOption).toBeFocused(); + await page.keyboard.press("Tab"); + // Assert the select menu is closed + await expect(selectMenu).toHaveCount(0); +}); + +test("options selected", async ({ page }) => { + await page.goto("http://127.0.0.1:8080/component/?name=select&"); + // Find Select a fruit... + let selectTrigger = page.locator(".select-trigger"); + await selectTrigger.click(); + // Assert the select menu is open + const selectMenu = page.locator(".select-list"); + await expect(selectMenu).toHaveAttribute("data-state", "open"); + + // Assert no items have aria-selected + const options = selectMenu.getByRole("option"); + let optionCount = await options.count(); + for (let i = 0; i < optionCount; i++) { + await expect(options.nth(i)).not.toHaveAttribute("aria-selected", "true"); + } + + // Select the first option + await page.keyboard.press("ArrowDown"); + const firstOption = selectMenu.getByRole("option", { name: "apple" }); + await expect(firstOption).toBeFocused(); + await page.keyboard.press("Enter"); + // Assert the select menu is closed after selection + await expect(selectMenu).toHaveCount(0); + // Open the select menu again + await selectTrigger.click(); + // Assert the first option is now selected + await expect(firstOption).toHaveAttribute("aria-selected", "true"); }); diff --git a/preview/Cargo.toml b/preview/Cargo.toml index 4983c6bc..faeecf5a 100644 --- a/preview/Cargo.toml +++ b/preview/Cargo.toml @@ -7,10 +7,11 @@ rust-version = "1.80.0" [dependencies] dioxus = { workspace = true, features = ["router"] } dioxus-primitives.workspace = true +strum = { version = "0.27.2", features = ["derive"] } tracing.workspace = true [build-dependencies] -syntect = "5.0" +syntect = "5.2" pulldown-cmark = "0.13.0" [features] diff --git a/preview/src/components/select/docs.md b/preview/src/components/select/docs.md index 66692a6c..21c56709 100644 --- a/preview/src/components/select/docs.md +++ b/preview/src/components/select/docs.md @@ -4,23 +4,35 @@ The Select component is used to create a dropdown menu that allows users to sele ```rust // The Select component wraps all select items in the dropdown. -Select { +Select:: { // The currently selected value(s) in the dropdown. value: "option1", // Callback function triggered when the selected value changes. on_value_change: |value: String| { // Handle the change in selected value. }, - // An group within the select dropdown which may contain multiple items. - SelectGroup { - // The label for the group, which is displayed as a header in the dropdown. - label: "Group 1", - // Each select option represents an individual option in the dropdown. - SelectOption { - // The value of the item, which will be passed to the on_value_change callback when selected. - value: "option1", - // The content of the select option - {children} + // The select trigger is the button that opens the dropdown. + SelectTrigger { + // The (optional) select value displays the currently selected text value. + SelectValue {} + } + // All groups must be wrapped in the select list. + SelectList { + // An group within the select dropdown which may contain multiple items. + SelectGroup { + // The label for the group + SelectGroupLabel { + "Other" + } + // Each select option represents an individual option in the dropdown. The type must match the type of the select. + SelectOption:: { + // The value of the item, which will be passed to the on_value_change callback when selected. + value: "option1", + // Select item indicator is only rendered if the item is selected. + SelectItemIndicator { + "✔️" + } + } } } } diff --git a/preview/src/components/select/variants/main/mod.rs b/preview/src/components/select/variants/main/mod.rs index 6495bfaf..0ce48caf 100644 --- a/preview/src/components/select/variants/main/mod.rs +++ b/preview/src/components/select/variants/main/mod.rs @@ -1,22 +1,65 @@ use dioxus::prelude::*; use dioxus_primitives::select::{ - Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, - SelectTrigger, + SelectValue, Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, SelectTrigger }; +use strum::{EnumCount, IntoEnumIterator}; + +#[derive(Debug, Clone, Copy, PartialEq, strum::EnumCount, strum::EnumIter, strum::Display)] +enum Fruit { + Apple, + Banana, + Orange, + Strawberry, + Watermelon, +} + +impl Fruit { + const fn emoji(&self) -> &'static str { + match self { + Fruit::Apple => "🍎", + Fruit::Banana => "🍌", + Fruit::Orange => "🍊", + Fruit::Strawberry => "🍓", + Fruit::Watermelon => "🍉", + } + } +} + #[component] pub fn Demo() -> Element { + let fruits = Fruit::iter().enumerate().map(|(i, f)| { + rsx! { + SelectOption::> { + index: i, + class: "select-option", + value: f, + text_value: "{f}", + {format!("{} {f}", f.emoji())} + SelectItemIndicator { + svg { + class: "select-check-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + path { d: "M5 13l4 4L19 7" } + } + } + } + } + }); + rsx! { document::Link { rel: "stylesheet", href: asset!("/src/components/select/variants/main/style.css"), } - Select { + Select::> { class: "select", placeholder: "Select a fruit...", SelectTrigger { class: "select-trigger", aria_label: "Select Trigger", width: "12rem", + SelectValue {} svg { class: "select-expand-icon", view_box: "0 0 24 24", @@ -33,76 +76,7 @@ pub fn Demo() -> Element { class: "select-group-label", "Fruits" } - SelectOption { - index: 0usize, - class: "select-option", - value: "apple".to_string(), - "Apple" - SelectItemIndicator { - svg { - class: "select-check-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - path { d: "M5 13l4 4L19 7" } - } - } - } - SelectOption { - index: 1usize, - class: "select-option", - value: "banana".to_string(), - "Banana" - SelectItemIndicator { - svg { - class: "select-check-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - path { d: "M5 13l4 4L19 7" } - } - } - } - SelectOption { - index: 2usize, - class: "select-option", - value: "orange".to_string(), - "Orange" - SelectItemIndicator { - svg { - class: "select-check-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - path { d: "M5 13l4 4L19 7" } - } - } - } - SelectOption { - index: 3usize, - class: "select-option", - value: "strawberry".to_string(), - "Strawberry" - SelectItemIndicator { - svg { - class: "select-check-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - path { d: "M5 13l4 4L19 7" } - } - } - } - SelectOption { - index: 4usize, - class: "select-option", - value: "watermelon".to_string(), - "Watermelon" - SelectItemIndicator { - svg { - class: "select-check-icon", - view_box: "0 0 24 24", - xmlns: "http://www.w3.org/2000/svg", - path { d: "M5 13l4 4L19 7" } - } - } - } + {fruits} } SelectGroup { class: "select-group", @@ -110,10 +84,11 @@ pub fn Demo() -> Element { class: "select-group-label", "Other" } - SelectOption { - index: 5usize, + SelectOption::> { + index: Fruit::COUNT, class: "select-option", - value: "other".to_string(), + value: None, + text_value: "Other", "Other" SelectItemIndicator { svg { diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index cedc0db3..a425ad7d 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -61,7 +61,10 @@ fn use_unique_id() -> Signal { } // Elements can only have one id so if the user provides their own, we must use it as the aria id. -fn use_id_or(mut gen_id: Signal, user_id: ReadOnlySignal>) -> Memo { +fn use_id_or( + mut gen_id: Signal, + user_id: ReadOnlySignal>, +) -> Memo { // First, check if we have a user-provided ID let has_user_id = use_memo(move || user_id().is_some()); diff --git a/primitives/src/select.rs b/primitives/src/select.rs deleted file mode 100644 index 9ba97acb..00000000 --- a/primitives/src/select.rs +++ /dev/null @@ -1,1297 +0,0 @@ -//! Defines the [`Select`] component and its sub-components, which provide a searchable select input with keyboard navigation. - -use std::collections::HashMap; - -use crate::{ - focus::{use_focus_controlled_item, use_focus_provider, FocusState}, - use_animated_open, use_controlled, use_effect_cleanup, use_id_or, use_unique_id, -}; -use dioxus::html::input_data::MouseButton; -use dioxus::prelude::Code; -use dioxus::prelude::*; - -#[derive(Clone, Copy)] -struct SelectContext { - // The typeahead buffer for searching options - typeahead_buffer: Signal, - // If the select is open - open: Signal, - // The currently selected value - value: Memo>, - // Set the value - set_value: Callback>, - // A list of options with their states - options: Signal>, - // Known key positions for the keyboard layout - known_key_positions: Signal>, - // The ID of the list for ARIA attributes - list_id: Signal>, - // The focus state for the select - focus_state: FocusState, - // Whether the select is disabled - disabled: ReadOnlySignal, - // The placeholder text - placeholder: ReadOnlySignal, -} - -impl SelectContext { - fn select_current_item(&mut self) { - // If the select is open, select the focused item - if self.open.cloned() { - if let Some(focused_index) = self.focus_state.current_focus() { - let options = self.options.read(); - if let Some(option) = options.iter().find(|opt| opt.tab_index == focused_index) { - self.set_value.call(Some(option.value.clone())); - self.open.set(false); - } - } - } - } - - fn add_to_known_key_positions(&mut self, code: &Code, key: &Key) { - if let (Some(code), Key::Character(key)) = (code_to_char(code), &key) { - let chars = key.chars().collect::>(); - if let &[key_as_char] = chars.as_slice() { - self.known_key_positions.write().insert(code, key_as_char); - } - } - } - - fn add_to_typeahead_buffer(&mut self, new_text: &str) { - let mut typeahead_buffer = self.typeahead_buffer.write(); - // Add character to typeahead buffer - typeahead_buffer.push_str(new_text); - // Trim the typeahead buffer to the maximum length of the options - let longest_option_length = self - .options - .read() - .iter() - .map(|opt| opt.value.chars().count()) - .max() - .unwrap_or_default(); - let overflow_length = typeahead_buffer.len().saturating_sub(longest_option_length); - if overflow_length > 0 { - *typeahead_buffer = typeahead_buffer - .chars() - .skip(overflow_length) - .take(longest_option_length) - .collect::(); - } - } -} - -fn best_match( - keyboard_layout: &KeyboardLayout, - typeahead: &str, - options: &[OptionState], -) -> Option { - if typeahead.is_empty() { - return None; - } - - let typeahead_characters: Box<[_]> = typeahead.chars().collect(); - - options - .iter() - .map(|opt| { - let value = &opt.value; - let value_characters: Box<[_]> = value.chars().collect(); - let distance = - normalized_distance(&typeahead_characters, &value_characters, keyboard_layout); - (distance, opt.tab_index) - }) - .min_by(|(d1, _), (d2, _)| f32::total_cmp(d1, d2)) - .map(|(_, value)| value) -} - -fn normalized_distance( - typeahead_characters: &[char], - value_characters: &[char], - keyboard_layout: &KeyboardLayout, -) -> f32 { - // Only use the the start of the value characters - let value_characters = - &value_characters[..value_characters.len().min(typeahead_characters.len())]; - // Only use the end of the typeahead characters - let typeahead_characters = &typeahead_characters[typeahead_characters - .len() - .saturating_sub(value_characters.len())..]; - - levenshtein_distance(typeahead_characters, value_characters, |a, b| { - keyboard_layout.substitution_cost(a, b) - }) -} - -// The recency bias of the levenshtein distance function -fn recency_bias(char_index: usize, total_length: usize) -> f32 { - ((char_index as f32 + 1.5).ln() / (total_length as f32 + 1.5).ln()).powi(4) -} - -// We use a weighted Levenshtein distance to account for the recency of characters -// More recent characters have a higher weight, while older characters have a lower weight -// The first few characters in the value are weighted more heavily -// -// When substitution is required, the substitution is cheaper for characters that are closer together on the keyboard -fn levenshtein_distance( - typeahead: &[char], - value: &[char], - substitution_cost: impl Fn(char, char) -> f32, -) -> f32 { - let mut dp = vec![vec![0.0; value.len() + 1]; typeahead.len() + 1]; - - let mut prev = 0.0; - for j in 0..=value.len() { - let new = prev + (1.0 - recency_bias(j, value.len())) * 0.5; - prev = new; - dp[0][j] = new; - } - let mut prev = 0.0; - for (i, row) in dp.iter_mut().enumerate().take(typeahead.len() + 1) { - let new = prev + recency_bias(i, typeahead.len()) * 0.5; - prev = new; - row[0] = new; - } - - for i in 1..=typeahead.len() { - for j in 1..=value.len() { - let cost = if typeahead[i - 1] == value[j - 1] { - 0.0 - } else { - substitution_cost(typeahead[i - 1], value[j - 1]) - }; - - dp[i][j] = f32::min( - f32::min( - // Insertion is cheaper for old characters in the typeahead - dp[i - 1][j] + recency_bias(i, typeahead.len()), - // Deletion is cheaper for untyped characters in the value - dp[i][j - 1] + (1.0 - recency_bias(j, value.len())), - ), - // Substitution - dp[i - 1][j - 1] + cost * 2.0 * recency_bias(i, typeahead.len()), - ); - } - } - - let result = dp[typeahead.len()][value.len()]; - - let max_possible = dp[typeahead.len()][0].max(dp[0][value.len()]); - - // Normalize the result to a range of 0.0 to 1.0 - result / max_possible -} - -#[test] -fn test_levenshtein_distance() { - let s1: Vec = "kitten".chars().collect(); - let s2: Vec = "sitting".chars().collect(); - - let distance = levenshtein_distance(&s1, &s2, |a, b| if a == b { 0.0 } else { 1.0 }); - assert_eq!(distance, 0.5158963); // kitten -> sitting requires 3 edits, but the distance is scaled by recency bias and normalized - - let s1: Vec = "kitten".chars().collect(); - let s2: Vec = "litten".chars().collect(); - let keyboard_layout = KeyboardLayout::Qwerty; - let qwerty_distance = - levenshtein_distance(&s1, &s2, |a, b| keyboard_layout.substitution_cost(a, b)); - - let keyboard_layout = KeyboardLayout::ColemakDH; - let colemack_distance = - levenshtein_distance(&s1, &s2, |a, b| keyboard_layout.substitution_cost(a, b)); - println!("QWERTY distance: {}", qwerty_distance); - println!("ColemakDH distance: {}", colemack_distance); - assert!( - qwerty_distance < colemack_distance, - "ColemakDH should have a higher distance than QWERTY for the same characters" - ); -} - -#[test] -fn test_normalized_distance() { - let typeahead: Vec = "goodhe".chars().collect(); - let string1: Vec = "hello".chars().collect(); - let string2: Vec = "goodbye".chars().collect(); - let distance1 = normalized_distance(&typeahead, &string1, &KeyboardLayout::ColemakDH); - println!("Distance from 'goodhe' to 'hello': {}", distance1); - let distance2 = normalized_distance(&typeahead, &string2, &KeyboardLayout::ColemakDH); - println!("Distance from 'goodhe' to 'goodbye': {}", distance2); - assert!( - distance1 < distance2, - "Distance to 'hello' should be less than distance to 'goodbye'" - ); - - let typeahead: Vec = "orangwat".chars().collect(); - let string1: Vec = "watermelon".chars().collect(); - let string2: Vec = "orange".chars().collect(); - let distance1 = normalized_distance(&typeahead, &string1, &KeyboardLayout::ColemakDH); - println!("Distance from 'orangwat' to 'watermelon': {}", distance1); - let distance2 = normalized_distance(&typeahead, &string2, &KeyboardLayout::ColemakDH); - println!("Distance from 'orangwat' to 'orange': {}", distance2); - assert!( - distance1 < distance2, - "Distance to 'watermelon' should be less than distance to 'orange'" - ); -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum KeyboardLayout { - Qwerty, - ColemakDH, - Colemak, - Dvorak, - Workman, - Azerty, - Qwertz, - Unknown, -} - -impl KeyboardLayout { - const KNOWN_KEYBOARD_LAYOUTS: &[(KeyboardLayout, [[char; 10]; 4])] = &[ - (KeyboardLayout::Qwerty, QWERTY_KEYBOARD_LAYOUT), - (KeyboardLayout::ColemakDH, COLEMACK_DH_KEYBOARD_LAYOUT), - (KeyboardLayout::Colemak, COLEMAK_KEYBOARD_LAYOUT), - (KeyboardLayout::Dvorak, DVORAK_KEYBOARD_LAYOUT), - (KeyboardLayout::Workman, WORKMAN_KEYBOARD_LAYOUT), - (KeyboardLayout::Azerty, AZERTY_KEYBOARD_LAYOUT), - (KeyboardLayout::Qwertz, QWERTZ_KEYBOARD_LAYOUT), - ]; - - fn guess(known_key_positions: &HashMap) -> Self { - if known_key_positions.is_empty() { - return Self::Unknown; - } - - let mut matching = Self::KNOWN_KEYBOARD_LAYOUTS.to_vec(); - for (physical_position, virtual_position) in known_key_positions { - let position_in_qwerty = Self::Qwerty.char_position(*physical_position); - - let Some(position_in_qwerty) = position_in_qwerty else { - return Self::Unknown; - }; - - matching.retain(|(_, layout_keys)| { - if let Some(&virtual_char) = - layout_keys[position_in_qwerty.0].get(position_in_qwerty.1) - { - virtual_char == *virtual_position - } else { - false - } - }); - } - - if let Some((layout, _)) = matching.first() { - *layout - } else { - Self::Unknown - } - } - - fn substitution_cost(&self, a: char, b: char) -> f32 { - if a == b { - return 0.0; - } - - let position_a = self.char_position(a); - let position_b = self.char_position(b); - - match (position_a, position_b) { - (Some((row_a, col_a)), Some((row_b, col_b))) => { - let row_diff = (row_a as f32 - row_b as f32).abs(); - let col_diff = (col_a as f32 - col_b as f32).abs(); - // Use Manhattan distance for simplicity and scale to a max of 1.0 - 0.5 + (row_diff + col_diff) / 28.0 - } - _ => 1.0, - } - } - - fn char_position(&self, character: char) -> Option<(usize, usize)> { - let matrix = match self { - KeyboardLayout::Qwerty => &QWERTY_KEYBOARD_LAYOUT, - KeyboardLayout::ColemakDH => &COLEMACK_DH_KEYBOARD_LAYOUT, - KeyboardLayout::Colemak => &COLEMAK_KEYBOARD_LAYOUT, - KeyboardLayout::Dvorak => &DVORAK_KEYBOARD_LAYOUT, - KeyboardLayout::Workman => &WORKMAN_KEYBOARD_LAYOUT, - KeyboardLayout::Azerty => &AZERTY_KEYBOARD_LAYOUT, - KeyboardLayout::Qwertz => &QWERTZ_KEYBOARD_LAYOUT, - KeyboardLayout::Unknown => return None, - }; - - matrix.iter().enumerate().find_map(|(row, row_values)| { - row_values - .iter() - .position(|&c| c == character) - .map(|col| (row, col)) - }) - } -} - -fn code_to_char(code: &Code) -> Option { - match code { - Code::Digit1 => Some('1'), - Code::Digit2 => Some('2'), - Code::Digit3 => Some('3'), - Code::Digit4 => Some('4'), - Code::Digit5 => Some('5'), - Code::Digit6 => Some('6'), - Code::Digit7 => Some('7'), - Code::Digit8 => Some('8'), - Code::Digit9 => Some('9'), - Code::Digit0 => Some('0'), - Code::KeyA => Some('a'), - Code::KeyB => Some('b'), - Code::KeyC => Some('c'), - Code::KeyD => Some('d'), - Code::KeyE => Some('e'), - Code::KeyF => Some('f'), - Code::KeyG => Some('g'), - Code::KeyH => Some('h'), - Code::KeyI => Some('i'), - Code::KeyJ => Some('j'), - Code::KeyK => Some('k'), - Code::KeyL => Some('l'), - Code::KeyM => Some('m'), - Code::KeyN => Some('n'), - Code::KeyO => Some('o'), - Code::KeyP => Some('p'), - Code::KeyQ => Some('q'), - Code::KeyR => Some('r'), - Code::KeyS => Some('s'), - Code::KeyT => Some('t'), - Code::KeyU => Some('u'), - Code::KeyV => Some('v'), - Code::KeyW => Some('w'), - Code::KeyX => Some('x'), - Code::KeyY => Some('y'), - Code::KeyZ => Some('z'), - Code::Period => Some('.'), - Code::Comma => Some(','), - Code::Slash => Some('/'), - Code::Semicolon => Some(';'), - _ => None, - } -} - -// QWERTY -static QWERTY_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], - ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';'], - ['z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'], -]; - -// Colemak-DH -static COLEMACK_DH_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['q', 'w', 'f', 'p', 'b', 'j', 'l', 'u', 'y', ';'], - ['a', 'r', 's', 't', 'g', 'm', 'n', 'e', 'i', 'o'], - ['x', 'c', 'd', 'v', 'z', 'k', 'h', ',', '.', '/'], -]; - -// Colemak (mod-dhm standard) -static COLEMAK_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y', ';'], - ['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'], - ['z', 'x', 'c', 'v', 'b', 'k', 'm', ',', '.', '/'], -]; - -// Dvorak -static DVORAK_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'], - ['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'], - [';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'], -]; - -// Workman -static WORKMAN_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['q', 'd', 'r', 'w', 'b', 'j', 'f', 'u', 'p', ';'], - ['a', 's', 'h', 't', 'g', 'y', 'n', 'e', 'o', 'i'], - ['z', 'x', 'm', 'c', 'v', 'k', 'l', ',', '.', '/'], -]; - -// AZERTY (France) -static AZERTY_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], - ['q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm'], - ['w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!'], -]; - -// QWERTZ (Germany/Switzerland) -static QWERTZ_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ - ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], - ['q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p'], - ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';'], - ['y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'], -]; - -#[test] -fn test_detect_keyboard_layout() { - // Default to an unknown layout - let layout = KeyboardLayout::guess(&[].into()); - assert_eq!(layout, KeyboardLayout::Unknown); - - // If any keys match, use the query layout - let layout = KeyboardLayout::guess(&[('q', 'q')].into()); - assert_eq!(layout, KeyboardLayout::Qwerty); - - // Otherwise, guess a good layout - let layout = KeyboardLayout::guess(&[('d', 's')].into()); - assert_eq!(layout, KeyboardLayout::ColemakDH); - - let layout = KeyboardLayout::guess(&[('d', 's'), ('g', 'd')].into()); - assert_eq!(layout, KeyboardLayout::Colemak); -} - -#[derive(Clone, Debug)] -struct OptionState { - /// The tab index of the option - tab_index: usize, - /// The value of the option - value: String, - /// The id of the option - id: String, -} - -/// The props for the [`Select`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectProps { - /// The controlled value of the select - #[props(default)] - pub value: ReadOnlySignal>>, - - /// The default value of the select - #[props(default)] - pub default_value: Option, - - /// Callback when the value changes - #[props(default)] - pub on_value_change: Callback>, - - /// Whether the select is disabled - #[props(default)] - pub disabled: ReadOnlySignal, - - /// Whether the select is required - #[props(default)] - pub required: ReadOnlySignal, - - /// Name of the select for form submission - #[props(default)] - pub name: ReadOnlySignal, - - /// Optional placeholder text - #[props(default = ReadOnlySignal::new(Signal::new(String::from("Select an option"))))] - pub placeholder: ReadOnlySignal, - - /// Whether focus should loop around when reaching the end. - #[props(default = ReadOnlySignal::new(Signal::new(true)))] - pub roving_loop: ReadOnlySignal, - - #[props(extends = GlobalAttributes)] - attributes: Vec, - - children: Element, -} - -/// # Select -/// -/// The `Select` component is a searchable dropdown that allows users to choose from a list of options with keyboard navigation and typeahead search functionality. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Styling -/// -/// The [`Select`] component defines the following data attributes you can use to control styling: -/// - `data-state`: Indicates the current state of the select. Values are `open` or `closed`. -#[component] -pub fn Select(props: SelectProps) -> Element { - let (value, set_value) = - use_controlled(props.value, props.default_value, props.on_value_change); - - let open = use_signal(|| false); - - let mut typeahead_buffer = use_signal(String::new); - let options = use_signal(Vec::default); - let known_key_positions = use_signal(Default::default); - let list_id = use_signal(|| None); - - let keyboard_layout = use_memo(move || { - let known_key_positions = known_key_positions.read(); - - KeyboardLayout::guess(&known_key_positions) - }); - - let best_match = use_memo(move || { - let typeahead = typeahead_buffer.read(); - let options = options.read(); - let keyboard_layout = keyboard_layout.read(); - - best_match(&keyboard_layout, &typeahead, &options) - }); - - let mut focus_state = use_focus_provider(props.roving_loop); - - // Set the focused item to the best match if it exists - use_effect(move || { - if let Some(focused_value) = &*best_match.read() { - focus_state.set_focus(Some(*focused_value)); - } - }); - - // Clear the typeahead buffer when the select is closed - use_effect(move || { - if !open() { - typeahead_buffer.take(); - } - }); - - use_context_provider(|| SelectContext { - typeahead_buffer, - open, - value, - set_value, - options, - known_key_positions, - list_id, - focus_state, - disabled: props.disabled, - placeholder: props.placeholder, - }); - - rsx! { - div { - // Data attributes - "data-state": if open() { "open" } else { "closed" }, - ..props.attributes, - {props.children} - } - } -} - -/// The props for the [`SelectTrigger`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectTriggerProps { - /// Additional attributes for the trigger button - #[props(extends = GlobalAttributes)] - attributes: Vec, - - /// The children to render inside the trigger - children: Element, -} - -/// # SelectTrigger -/// -/// The trigger button for the [`Select`] component which controls if the [`SelectList`] is rendered. -/// -/// This must be used inside a [`Select`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -/// -/// ## Styling -/// -/// The [`SelectTrigger`] component defines a span with a `data-placeholder` attribute if a placeholder is set. -#[component] -pub fn SelectTrigger(props: SelectTriggerProps) -> Element { - let mut ctx: SelectContext = use_context(); - - let mut open = ctx.open; - - rsx! { - button { - // Standard HTML attributes - disabled: (ctx.disabled)(), - - onclick: move |_| { - open.toggle(); - }, - onkeydown: move |event| { - match event.key() { - Key::ArrowUp => { - open.set(true); - ctx.focus_state.focus_last(); - event.prevent_default(); - event.stop_propagation(); - } - Key::ArrowDown => { - open.set(true); - ctx.focus_state.focus_first(); - event.prevent_default(); - event.stop_propagation(); - } - _ => {} - } - }, - - // ARIA attributes - aria_haspopup: "listbox", - aria_expanded: open(), - aria_controls: ctx.list_id, - - // Pass through other attributes - ..props.attributes, - - // Add placeholder option if needed - span { - "data-placeholder": ctx.value.read().is_none(), - {ctx.value.cloned().unwrap_or_else(|| ctx.placeholder.cloned())} - } - - // Render children (options) - {props.children} - } - } -} - -/// The props for the [`SelectList`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectListProps { - /// The ID of the list for ARIA attributes - #[props(default)] - pub id: ReadOnlySignal>, - - /// Additional attributes for the list - #[props(extends = GlobalAttributes)] - attributes: Vec, - - /// The children to render inside the list - children: Element, -} - -/// # SelectList -/// -/// The dropdown list container for the [`Select`] component that contains the -/// [`SelectOption`]s. The list will only be rendered when the select is open. -/// -/// This must be used inside a [`Select`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -#[component] -pub fn SelectList(props: SelectListProps) -> Element { - let mut ctx: SelectContext = use_context(); - - let id = use_unique_id(); - let id = use_id_or(id, props.id); - use_effect(move || { - ctx.list_id.set(Some(id())); - }); - - let mut open = ctx.open; - let mut listbox_ref: Signal>> = use_signal(|| None); - let focused = move || open() && !ctx.focus_state.any_focused(); - - use_effect(move || { - let Some(listbox_ref) = listbox_ref() else { - return; - }; - if focused() { - spawn(async move { - _ = listbox_ref.set_focus(true); - }); - } - }); - - let onkeydown = move |event: KeyboardEvent| { - let key = event.key(); - let code = event.code(); - ctx.add_to_known_key_positions(&code, &key); - - let mut arrow_key_navigation = |event: KeyboardEvent| { - // Clear the typeahead buffer - ctx.typeahead_buffer.take(); - event.prevent_default(); - event.stop_propagation(); - }; - - match key { - Key::Character(new_text) => { - if new_text == " " { - ctx.select_current_item(); - event.prevent_default(); - event.stop_propagation(); - return; - } - - ctx.add_to_typeahead_buffer(&new_text); - } - Key::ArrowUp => { - arrow_key_navigation(event); - - ctx.focus_state.focus_prev(); - } - Key::End => { - arrow_key_navigation(event); - - ctx.focus_state.focus_last(); - } - Key::ArrowDown => { - arrow_key_navigation(event); - - ctx.focus_state.focus_next(); - } - Key::Home => { - arrow_key_navigation(event); - - ctx.focus_state.focus_first(); - } - Key::Enter => { - ctx.select_current_item(); - open.set(false); - event.prevent_default(); - event.stop_propagation(); - } - Key::Escape => { - open.set(false); - event.prevent_default(); - event.stop_propagation(); - } - _ => {} - } - }; - - let render = use_animated_open(id, open); - - rsx! { - if render() { - div { - id, - role: "listbox", - tabindex: if focused() { "0" } else { "-1" }, - - // Data attributes - "data-state": if open() { "open" } else { "closed" }, - - onmounted: move |evt| listbox_ref.set(Some(evt.data())), - onkeydown, - onblur: move |_| { - if focused() { - open.set(false); - } - }, - - ..props.attributes, - {props.children} - } - } - } -} - -#[derive(Clone, Copy)] -struct SelectOptionContext { - /// If the option is selected - selected: Memo, -} - -/// The props for the [`SelectOption`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectOptionProps { - /// The value of the option. This will be used both to pass to the [`SelectProps::on_value_change`] callback - /// and for typeahead search. - pub value: ReadOnlySignal, - - /// Whether the option is disabled - #[props(default)] - pub disabled: ReadOnlySignal, - - /// Optional ID for the option - #[props(default)] - pub id: ReadOnlySignal>, - - /// The index of the option in the list. This is used to define the focus order for keyboard navigation. - pub index: ReadOnlySignal, - - /// Optional label for the option (for accessibility) - #[props(default)] - pub aria_label: Option, - - /// Optional description role for the option (for accessibility) - #[props(default)] - pub aria_roledescription: Option, - - #[props(extends = GlobalAttributes)] - attributes: Vec, - - children: Element, -} - -/// # SelectOption -/// -/// An individual selectable option within a [`SelectList`] component. Each option represents -/// a value that can be selected. -/// -/// This must be used inside a [`SelectList`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -#[component] -pub fn SelectOption(props: SelectOptionProps) -> Element { - // Generate a unique ID for this option for accessibility - let option_id = use_unique_id(); - - // Use use_id_or to handle the ID - let id = use_id_or(option_id, props.id); - - let index = props.index; - let value = props.value; - - // Push this option to the context - let mut ctx: SelectContext = use_context(); - use_effect(move || { - let option_state = OptionState { - tab_index: index(), - value: value.cloned(), - id: id(), - }; - - // Add the option to the context's options - ctx.options.write().push(option_state); - }); - - use_effect_cleanup(move || { - ctx.options.write().retain(|opt| *opt.id != *id.read()); - }); - - let onmounted = use_focus_controlled_item(props.index); - let focused = move || ctx.focus_state.is_focused(index()); - let disabled = ctx.disabled.cloned() || props.disabled.cloned(); - let selected = use_memo(move || ctx.value.read().as_ref() == Some(&props.value.read())); - - use_context_provider(|| SelectOptionContext { selected }); - - rsx! { - div { - role: "option", - id, - tabindex: if focused() { "0" } else { "-1" }, - onmounted, - - // ARIA attributes - aria_selected: selected(), - aria_disabled: disabled, - aria_label: props.aria_label.clone(), - aria_roledescription: props.aria_roledescription.clone(), - - onpointerdown: move |event| { - if !disabled && event.trigger_button() == Some(MouseButton::Primary) { - ctx.set_value.call(Some(props.value.read().clone())); - ctx.open.set(false); - } - }, - onblur: move |_| { - if focused() { - ctx.focus_state.blur(); - ctx.open.set(false); - } - }, - - ..props.attributes, - {props.children} - } - } -} - -/// The props for the [`SelectItemIndicator`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectItemIndicatorProps { - /// The children to render inside the indicator - children: Element, -} - -/// # SelectItemIndicator -/// -/// The `SelectItemIndicator` component is used to render an indicator for a selected item within a [`SelectList`]. The -/// children will only be rendered if the option is selected. -/// -/// This must be used inside a [`SelectOption`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -#[component] -pub fn SelectItemIndicator(props: SelectItemIndicatorProps) -> Element { - let ctx: SelectOptionContext = use_context(); - if !(ctx.selected)() { - return rsx! {}; - } - rsx! { - {props.children} - } -} - -#[derive(Clone, Copy)] -struct SelectGroupContext { - labeled_by: Signal>, -} - -/// The props for the [`SelectGroup`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectGroupProps { - /// Whether the group is disabled - #[props(default)] - pub disabled: ReadOnlySignal, - - /// Optional ID for the group - #[props(default)] - pub id: ReadOnlySignal>, - - /// Additional attributes for the group - #[props(extends = GlobalAttributes)] - attributes: Vec, - - /// The children to render inside the group - children: Element, -} - -/// # SelectGroup -/// -/// The `SelectGroup` component is used to group related options within a [`SelectList`]. It provides a way to organize options into logical sections. -/// -/// This must be used inside a [`SelectList`] component. -/// -/// ## Example -/// -/// ```rust -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -#[component] -pub fn SelectGroup(props: SelectGroupProps) -> Element { - let ctx: SelectContext = use_context(); - let disabled = ctx.disabled.cloned() || props.disabled.cloned(); - - let labeled_by = use_signal(|| None); - - use_context_provider(|| SelectGroupContext { labeled_by }); - - rsx! { - div { - role: "group", - - // ARIA attributes - aria_disabled: disabled, - aria_labelledby: labeled_by, - - ..props.attributes, - {props.children} - } - } -} - -/// The props for the [`SelectGroupLabel`] component -#[derive(Props, Clone, PartialEq)] -pub struct SelectGroupLabelProps { - /// Optional ID for the label - pub id: ReadOnlySignal>, - - /// Additional attributes for the label - #[props(extends = GlobalAttributes)] - attributes: Vec, - - /// The children to render inside the label - children: Element, -} - -/// # SelectGroupLabel -/// -/// The `SelectGroupLabel` component is used to render a label for a group of options within a [`SelectList`]. -/// -/// This must be used inside a [`SelectGroup`] component. -/// -/// ## Example -/// -/// ```rust -/// -/// use dioxus::prelude::*; -/// use dioxus_primitives::select::{ -/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, -/// SelectTrigger, -/// }; -/// #[component] -/// fn Demo() -> Element { -/// rsx! { -/// Select { -/// placeholder: "Select a fruit...", -/// SelectTrigger { -/// aria_label: "Select Trigger", -/// width: "12rem", -/// } -/// SelectList { -/// aria_label: "Select Demo", -/// SelectGroup { -/// SelectGroupLabel { -/// "Fruits" -/// } -/// SelectOption { -/// index: 0usize, -/// value: "apple".to_string(), -/// "Apple" -/// SelectItemIndicator { "✔️" } -/// } -/// SelectOption { -/// index: 1usize, -/// value: "banana".to_string(), -/// "Banana" -/// SelectItemIndicator { "✔️" } -/// } -/// } -/// } -/// } -/// } -/// } -/// ``` -#[component] -pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element { - let mut ctx: SelectGroupContext = use_context(); - - let id = use_unique_id(); - let id = use_id_or(id, props.id); - - use_effect(move || { - ctx.labeled_by.set(Some(id())); - }); - - rsx! { - div { - // Set the ID for the label - id, - ..props.attributes, - {props.children} - } - } -} diff --git a/primitives/src/select/components/group.rs b/primitives/src/select/components/group.rs new file mode 100644 index 00000000..61190cf1 --- /dev/null +++ b/primitives/src/select/components/group.rs @@ -0,0 +1,175 @@ +//! SelectGroup and SelectGroupLabel component implementations. + +use crate::{use_effect, use_id_or, use_unique_id}; +use dioxus::prelude::*; + +use super::super::context::{SelectContext, SelectGroupContext}; + +/// The props for the [`SelectGroup`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectGroupProps { + /// Whether the group is disabled + #[props(default)] + pub disabled: ReadOnlySignal, + + /// Optional ID for the group + #[props(default)] + pub id: ReadOnlySignal>, + + /// Additional attributes for the group + #[props(extends = GlobalAttributes)] + attributes: Vec, + + /// The children to render inside the group + children: Element, +} + +/// # SelectGroup +/// +/// The `SelectGroup` component is used to group related options within a [`SelectList`](super::list::SelectList). It provides a way to organize options into logical sections. +/// +/// This must be used inside a [`SelectList`](super::list::SelectList) component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn SelectGroup(props: SelectGroupProps) -> Element { + let ctx = use_context::(); + let disabled = ctx.disabled.cloned() || props.disabled.cloned(); + + let labeled_by = use_signal(|| None); + + use_context_provider(|| SelectGroupContext { labeled_by }); + + rsx! { + div { + role: "group", + + // ARIA attributes + aria_disabled: disabled, + aria_labelledby: labeled_by, + + ..props.attributes, + {props.children} + } + } +} + +/// The props for the [`SelectGroupLabel`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectGroupLabelProps { + /// Optional ID for the label + pub id: ReadOnlySignal>, + + /// Additional attributes for the label + #[props(extends = GlobalAttributes)] + attributes: Vec, + + /// The children to render inside the label + children: Element, +} + +/// # SelectGroupLabel +/// +/// The `SelectGroupLabel` component is used to render a label for a group of options within a [`SelectList`](super::list::SelectList). +/// +/// This must be used inside a [`SelectGroup`](SelectGroup) component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn SelectGroupLabel(props: SelectGroupLabelProps) -> Element { + let mut ctx: SelectGroupContext = use_context(); + + let id = use_unique_id(); + let id = use_id_or(id, props.id); + + use_effect(move || { + ctx.labeled_by.set(Some(id())); + }); + + rsx! { + div { + // Set the ID for the label + id, + ..props.attributes, + {props.children} + } + } +} diff --git a/primitives/src/select/components/list.rs b/primitives/src/select/components/list.rs new file mode 100644 index 00000000..45f09191 --- /dev/null +++ b/primitives/src/select/components/list.rs @@ -0,0 +1,180 @@ +//! SelectList component implementation. + +use crate::{use_animated_open, use_effect, use_id_or, use_unique_id}; +use dioxus::prelude::*; + +use super::super::context::SelectContext; + +/// The props for the [`SelectList`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectListProps { + /// The ID of the list for ARIA attributes + #[props(default)] + pub id: ReadOnlySignal>, + + /// Additional attributes for the list + #[props(extends = GlobalAttributes)] + attributes: Vec, + + /// The children to render inside the list + children: Element, +} + +/// # SelectList +/// +/// The dropdown list container for the [`Select`](super::select::Select) component that contains the +/// [`SelectOption`](super::option::SelectOption)s. The list will only be rendered when the select is open. +/// +/// This must be used inside a [`Select`](super::select::Select) component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn SelectList(props: SelectListProps) -> Element { + let mut ctx = use_context::(); + + let id = use_unique_id(); + let id = use_id_or(id, props.id); + use_effect(move || { + ctx.list_id.set(Some(id())); + }); + + let mut open = ctx.open; + let mut listbox_ref: Signal>> = use_signal(|| None); + let focused = move || open() && !ctx.focus_state.any_focused(); + + use_effect(move || { + let Some(listbox_ref) = listbox_ref() else { + return; + }; + if focused() { + spawn(async move { + _ = listbox_ref.set_focus(true); + }); + } + }); + + let onkeydown = move |event: KeyboardEvent| { + let key = event.key(); + let code = event.code(); + + // Learn from keyboard events for adaptive matching + if let Key::Character(actual_char) = &key { + if let Some(actual_char) = actual_char.chars().next() { + ctx.learn_from_keyboard_event(&code.to_string(), actual_char); + } + } + + let mut arrow_key_navigation = |event: KeyboardEvent| { + // Clear the typeahead buffer + ctx.typeahead_buffer.take(); + event.prevent_default(); + event.stop_propagation(); + }; + + match key { + Key::Character(new_text) => { + if new_text == " " { + ctx.select_current_item(); + event.prevent_default(); + event.stop_propagation(); + return; + } + + ctx.add_to_typeahead_buffer(&new_text); + } + Key::ArrowUp => { + arrow_key_navigation(event); + ctx.focus_state.focus_prev(); + } + Key::End => { + arrow_key_navigation(event); + ctx.focus_state.focus_last(); + } + Key::ArrowDown => { + arrow_key_navigation(event); + ctx.focus_state.focus_next(); + } + Key::Home => { + arrow_key_navigation(event); + ctx.focus_state.focus_first(); + } + Key::Enter => { + ctx.select_current_item(); + open.set(false); + event.prevent_default(); + event.stop_propagation(); + } + Key::Escape => { + open.set(false); + event.prevent_default(); + event.stop_propagation(); + } + _ => {} + } + }; + + let render = use_animated_open(id, open); + + rsx! { + if render() { + div { + id, + role: "listbox", + tabindex: if focused() { "0" } else { "-1" }, + + // Data attributes + "data-state": if open() { "open" } else { "closed" }, + + onmounted: move |evt| listbox_ref.set(Some(evt.data())), + onkeydown, + onblur: move |_| { + if focused() { + open.set(false); + } + }, + + ..props.attributes, + {props.children} + } + } + } +} diff --git a/primitives/src/select/components/mod.rs b/primitives/src/select/components/mod.rs new file mode 100644 index 00000000..94bfe1c5 --- /dev/null +++ b/primitives/src/select/components/mod.rs @@ -0,0 +1,15 @@ +//! Component definitions for the select primitive. + +pub mod group; +pub mod list; +pub mod option; +pub mod select; +pub mod trigger; +pub mod value; + +pub use group::{SelectGroup, SelectGroupLabel, SelectGroupLabelProps, SelectGroupProps}; +pub use list::{SelectList, SelectListProps}; +pub use option::{SelectItemIndicator, SelectItemIndicatorProps, SelectOption, SelectOptionProps}; +pub use select::{Select, SelectProps}; +pub use trigger::{SelectTrigger, SelectTriggerProps}; +pub use value::{SelectValue, SelectValueProps}; diff --git a/primitives/src/select/components/option.rs b/primitives/src/select/components/option.rs new file mode 100644 index 00000000..a326c2ae --- /dev/null +++ b/primitives/src/select/components/option.rs @@ -0,0 +1,251 @@ +//! SelectOption and SelectItemIndicator component implementations. + +use crate::{ + focus::use_focus_controlled_item, select::context::RcPartialEqValue, use_effect, + use_effect_cleanup, use_id_or, use_unique_id, +}; +use dioxus::html::input_data::MouseButton; +use dioxus::prelude::*; + +use super::super::context::{OptionState, SelectContext, SelectOptionContext}; + +/// The props for the [`SelectOption`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectOptionProps { + /// The value of the option + pub value: ReadOnlySignal, + + /// The text value of the option used for typeahead search + #[props(default)] + pub text_value: ReadOnlySignal>, + + /// Whether the option is disabled + #[props(default)] + pub disabled: ReadOnlySignal, + + /// Optional ID for the option + #[props(default)] + pub id: ReadOnlySignal>, + + /// The index of the option in the list. This is used to define the focus order for keyboard navigation. + pub index: ReadOnlySignal, + + /// Optional label for the option (for accessibility) + #[props(default)] + pub aria_label: Option, + + /// Optional description role for the option (for accessibility) + #[props(default)] + pub aria_roledescription: Option, + + #[props(extends = GlobalAttributes)] + attributes: Vec, + + children: Element, +} + +/// # SelectOption +/// +/// An individual selectable option within a [`SelectList`](super::list::SelectList) component. Each option represents +/// a value that can be selected. +/// +/// ## Value vs Text Value +/// +/// - **`value`**: The programmatic value (e.g., `"apple"`, `"user_123"`) used internally +/// - **`text_value`**: The text value (e.g., `"Apple"`, `"John Doe"`) used for typeahead search and displayed in the [`SelectValue`](super::value::SelectValue) +/// +/// This must be used inside a [`SelectList`](super::list::SelectList) component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn SelectOption(props: SelectOptionProps) -> Element { + // Generate a unique ID for this option for accessibility + let option_id = use_unique_id(); + + // Use use_id_or to handle the ID + let id = use_id_or(option_id, props.id); + + let index = props.index; + let value = props.value; + let text_value = use_memo(move || match (props.text_value)() { + Some(text) => text, + None => { + let value = value.read(); + let as_any: &dyn std::any::Any = &*value; + as_any + .downcast_ref::() + .cloned() + .or_else(|| as_any.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| { + tracing::warn!( + "SelectOption with non-string types requires text_value to be set" + ); + String::new() + }) + } + }); + + // Push this option to the context + let mut ctx: SelectContext = use_context(); + use_effect(move || { + let option_state = OptionState { + tab_index: index(), + value: RcPartialEqValue::new(value.cloned()), + text_value: text_value.cloned(), + id: id(), + }; + + // Add the option to the context's options + ctx.options.write().push(option_state); + }); + + use_effect_cleanup(move || { + ctx.options.write().retain(|opt| opt.id != *id.read()); + }); + + let onmounted = use_focus_controlled_item(props.index); + let focused = move || ctx.focus_state.is_focused(index()); + let disabled = ctx.disabled.cloned() || props.disabled.cloned(); + let selected = use_memo(move || { + ctx.value.read().as_ref().and_then(|v| v.as_ref::()) == Some(&props.value.read()) + }); + + use_context_provider(|| SelectOptionContext { + selected: selected.into(), + }); + + rsx! { + div { + role: "option", + id, + tabindex: if focused() { "0" } else { "-1" }, + onmounted, + + // ARIA attributes + aria_selected: selected(), + aria_disabled: disabled, + aria_label: props.aria_label.clone(), + aria_roledescription: props.aria_roledescription.clone(), + + onpointerdown: move |event| { + if !disabled && event.trigger_button() == Some(MouseButton::Primary) { + ctx.set_value.call(Some(RcPartialEqValue::new(props.value.cloned()))); + ctx.open.set(false); + } + }, + onblur: move |_| { + if focused() { + ctx.focus_state.blur(); + ctx.open.set(false); + } + }, + + ..props.attributes, + {props.children} + } + } +} + +/// The props for the [`SelectItemIndicator`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectItemIndicatorProps { + /// The children to render inside the indicator + children: Element, +} + +/// # SelectItemIndicator +/// +/// The `SelectItemIndicator` component is used to render an indicator for a selected item within a [`SelectList`](super::list::SelectList). The +/// children will only be rendered if the option is selected. +/// +/// This must be used inside a [`SelectOption`](SelectOption) component. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn SelectItemIndicator(props: SelectItemIndicatorProps) -> Element { + let ctx: SelectOptionContext = use_context(); + if !(ctx.selected)() { + return rsx! {}; + } + rsx! { + {props.children} + } +} diff --git a/primitives/src/select/components/select.rs b/primitives/src/select/components/select.rs new file mode 100644 index 00000000..8cb5be52 --- /dev/null +++ b/primitives/src/select/components/select.rs @@ -0,0 +1,168 @@ +//! Main Select component implementation. + +use core::panic; +use std::time::Duration; + +use crate::{select::context::RcPartialEqValue, use_controlled, use_effect}; +use dioxus::prelude::*; +use dioxus_core::Task; + +use super::super::context::SelectContext; +use crate::focus::use_focus_provider; + +/// Props for the main Select component +#[derive(Props, Clone, PartialEq)] +pub struct SelectProps { + /// The controlled value of the select + #[props(default)] + pub value: ReadOnlySignal>>, + + /// The default value of the select + #[props(default)] + pub default_value: Option, + + /// Callback when the value changes + #[props(default)] + pub on_value_change: Callback>, + + /// Whether the select is disabled + #[props(default)] + pub disabled: ReadOnlySignal, + + /// Name of the select for form submission + #[props(default)] + pub name: ReadOnlySignal, + + /// Optional placeholder text + #[props(default = ReadOnlySignal::new(Signal::new(String::from("Select an option"))))] + pub placeholder: ReadOnlySignal, + + /// Whether focus should loop around when reaching the end. + #[props(default = ReadOnlySignal::new(Signal::new(true)))] + pub roving_loop: ReadOnlySignal, + + /// Timeout in milliseconds before clearing typeahead buffer + #[props(default = ReadOnlySignal::new(Signal::new(Duration::from_millis(1000))))] + pub typeahead_timeout: ReadOnlySignal, + + #[props(extends = GlobalAttributes)] + attributes: Vec, + + children: Element, +} + +/// # Select +/// +/// The `Select` component is a searchable dropdown that allows users to choose from a list of options with keyboard navigation and typeahead search functionality. +/// +/// ## Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// ## Styling +/// +/// The [`Select`] component defines the following data attributes you can use to control styling: +/// - `data-state`: Indicates the current state of the select. Values are `open` or `closed`. +#[component] +pub fn Select(props: SelectProps) -> Element { + let (value, set_value_internal) = + use_controlled(props.value, props.default_value, props.on_value_change); + + let open = use_signal(|| false); + let mut typeahead_buffer = use_signal(String::new); + let options = use_signal(Vec::default); + let adaptive_keyboard = use_signal(super::super::text_search::AdaptiveKeyboard::new); + let list_id = use_signal(|| None); + let mut typeahead_clear_task: Signal> = use_signal(|| None); + + let value = use_memo(move || value().map(RcPartialEqValue::new)); + let set_value = use_callback(move |cursor_opt: Option| { + if let Some(value) = cursor_opt { + set_value_internal.call(Some( + value + .as_ref::() + .unwrap_or_else(|| { + panic!("The values of select and all options must match types") + }) + .clone(), + )); + } else { + set_value_internal.call(None); + } + }); + + let focus_state = use_focus_provider(props.roving_loop); + + // Clear the typeahead buffer when the select is closed + use_effect(move || { + if !open() { + // Cancel any pending clear task + if let Some(task) = typeahead_clear_task.write().take() { + task.cancel(); + } + // Clear the buffer immediately + typeahead_buffer.take(); + } + }); + + use_context_provider(|| SelectContext { + typeahead_buffer, + open, + value, + set_value, + options, + adaptive_keyboard, + list_id, + focus_state, + disabled: props.disabled, + placeholder: props.placeholder, + typeahead_clear_task, + typeahead_timeout: props.typeahead_timeout, + }); + + rsx! { + div { + // Data attributes + "data-state": if open() { "open" } else { "closed" }, + ..props.attributes, + {props.children} + } + } +} diff --git a/primitives/src/select/components/trigger.rs b/primitives/src/select/components/trigger.rs new file mode 100644 index 00000000..620c03d3 --- /dev/null +++ b/primitives/src/select/components/trigger.rs @@ -0,0 +1,110 @@ +//! SelectTrigger component implementation. + +use dioxus::prelude::*; + +use super::super::context::SelectContext; + +/// The props for the [`SelectTrigger`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectTriggerProps { + /// Additional attributes for the trigger button + #[props(extends = GlobalAttributes)] + attributes: Vec, + + /// The children to render inside the trigger + children: Element, +} + +/// # SelectTrigger +/// +/// The trigger button for the [`Select`](super::select::Select) component which controls if the [`SelectList`](super::list::SelectList) is rendered. +/// +/// This must be used inside a [`Select`](super::select::Select) component. +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// +/// ## Styling +/// +/// The [`SelectTrigger`] component defines a span with a `data-placeholder` attribute if a placeholder is set. +#[component] +pub fn SelectTrigger(props: SelectTriggerProps) -> Element { + let mut ctx = use_context::(); + let mut open = ctx.open; + + rsx! { + button { + // Standard HTML attributes + disabled: (ctx.disabled)(), + + onclick: move |_| { + open.toggle(); + }, + onkeydown: move |event| { + match event.key() { + Key::ArrowUp => { + open.set(true); + ctx.focus_state.focus_last(); + event.prevent_default(); + event.stop_propagation(); + } + Key::ArrowDown => { + open.set(true); + ctx.focus_state.focus_first(); + event.prevent_default(); + event.stop_propagation(); + } + _ => {} + } + }, + + // ARIA attributes + aria_haspopup: "listbox", + aria_expanded: open(), + aria_controls: ctx.list_id, + + // Pass through other attributes + ..props.attributes, + + // Render children (options) + {props.children} + } + } +} diff --git a/primitives/src/select/components/value.rs b/primitives/src/select/components/value.rs new file mode 100644 index 00000000..5bb22709 --- /dev/null +++ b/primitives/src/select/components/value.rs @@ -0,0 +1,89 @@ +//! SelectValue component implementation. + +use dioxus::prelude::*; + +use super::super::context::SelectContext; + +/// The props for the [`SelectValue`] component +#[derive(Props, Clone, PartialEq)] +pub struct SelectValueProps { + /// Additional attributes for the value element + #[props(extends = GlobalAttributes)] + attributes: Vec, +} + +/// # SelectValue +/// +/// The trigger button for the [`Select`](super::select::Select) component which controls if the [`SelectList`](super::list::SelectList) is rendered. +/// +/// This must be used inside a [`Select`](super::select::Select) component. +/// +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::select::{ +/// Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, SelectList, SelectOption, +/// SelectTrigger, SelectValue, +/// }; +/// #[component] +/// fn Demo() -> Element { +/// rsx! { +/// Select:: { +/// placeholder: "Select a fruit...", +/// SelectTrigger { +/// aria_label: "Select Trigger", +/// width: "12rem", +/// SelectValue {} +/// } +/// SelectList { +/// aria_label: "Select Demo", +/// SelectGroup { +/// SelectGroupLabel { "Fruits" } +/// SelectOption:: { +/// index: 0usize, +/// value: "apple", +/// "Apple" +/// SelectItemIndicator { "✔️" } +/// } +/// SelectOption:: { +/// index: 1usize, +/// value: "banana", +/// "Banana" +/// SelectItemIndicator { "✔️" } +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// +/// ## Styling +/// +/// The [`SelectValue`] component defines a span with a `data-placeholder` attribute if a placeholder is set. +#[component] +pub fn SelectValue(props: SelectValueProps) -> Element { + let ctx = use_context::(); + + let selected_text_value = use_memo(move || { + let value = ctx.value.read(); + value.as_ref().and_then(|v| { + ctx.options + .peek() + .iter() + .find(|opt| opt.value == *v) + .map(|opt| opt.text_value.clone()) + }) + }); + + let display_value = selected_text_value().unwrap_or_else(|| ctx.placeholder.cloned()); + + rsx! { + // Add placeholder option if needed + span { + "data-placeholder": ctx.value.read().is_none(), + ..props.attributes, + {display_value} + } + } +} diff --git a/primitives/src/select/context.rs b/primitives/src/select/context.rs new file mode 100644 index 00000000..ec84ea02 --- /dev/null +++ b/primitives/src/select/context.rs @@ -0,0 +1,169 @@ +//! Context types and implementations for the select component. + +use crate::focus::FocusState; +use dioxus::prelude::*; +use dioxus_core::Task; +use dioxus_time::sleep; + +use std::{any::Any, rc::Rc, time::Duration}; + +use super::text_search::AdaptiveKeyboard; + +trait DynPartialEq: Any { + fn eq(&self, other: &dyn Any) -> bool; +} + +impl DynPartialEq for T { + fn eq(&self, other: &dyn Any) -> bool { + other.downcast_ref::() == Some(self) + } +} + +#[derive(Clone)] +pub(crate) struct RcPartialEqValue { + value: Rc, +} + +impl RcPartialEqValue { + pub fn new(value: T) -> Self { + Self { + value: Rc::new(value), + } + } + + pub fn as_any(&self) -> &dyn Any { + (&*self.value) as &dyn Any + } + + pub fn as_ref(&self) -> Option<&T> { + self.as_any().downcast_ref::() + } +} + +impl PartialEq for RcPartialEqValue { + fn eq(&self, other: &Self) -> bool { + self.value.eq(&*other.value) + } +} + +/// Main context for the select component containing all shared state +#[derive(Clone, Copy)] +pub(super) struct SelectContext { + /// The typeahead buffer for searching options + pub typeahead_buffer: Signal, + /// If the select is open + pub open: Signal, + /// Current value + pub value: Memo>, + /// Set the value callback + pub set_value: Callback>, + /// A list of options with their states + pub options: Signal>, + /// Adaptive keyboard system for multi-language support + pub adaptive_keyboard: Signal, + /// The ID of the list for ARIA attributes + pub list_id: Signal>, + /// The focus state for the select + pub focus_state: FocusState, + /// Whether the select is disabled + pub disabled: ReadOnlySignal, + /// The placeholder text + pub placeholder: ReadOnlySignal, + /// Task handle for clearing typeahead buffer + pub typeahead_clear_task: Signal>, + /// Timeout before clearing typeahead buffer + pub typeahead_timeout: ReadOnlySignal, +} + +impl SelectContext { + /// Select the currently focused item + pub fn select_current_item(&mut self) { + // If the select is open, select the focused item + if self.open.cloned() { + if let Some(focused_index) = self.focus_state.current_focus() { + let options = self.options.read(); + if let Some(option) = options.iter().find(|opt| opt.tab_index == focused_index) { + self.set_value.call(Some(option.value.clone())); + self.open.set(false); + } + } + } + } + + /// Learn from a keyboard event mapping physical key to logical character + pub fn learn_from_keyboard_event(&mut self, physical_code: &str, logical_char: char) { + let mut adaptive = self.adaptive_keyboard.write(); + let logical_char = logical_char.to_lowercase().next().unwrap_or(logical_char); + adaptive.learn_from_event(physical_code, logical_char); + } + + /// Add text to the typeahead buffer for searching + pub fn add_to_typeahead_buffer(&mut self, text: &str) { + // Cancel any existing clear task to prevent race conditions + if let Some(existing_task) = self.typeahead_clear_task.write().take() { + existing_task.cancel(); + } + + // Update the buffer and get the current content + let typeahead = { + let mut typeahead_buffer = self.typeahead_buffer.write(); + typeahead_buffer.push_str(text); + typeahead_buffer.clone() + }; + + // Create references for the async closure + let mut typeahead_buffer_signal = self.typeahead_buffer; + let mut typeahead_clear_task_signal = self.typeahead_clear_task; + + // Spawn a new task to clear the buffer after the configured timeout + let timeout = self.typeahead_timeout.cloned(); + let new_task = spawn(async move { + sleep(timeout).await; + + // Clear the buffer + typeahead_buffer_signal.write().clear(); + + // Remove our own task handle to indicate no task is active + typeahead_clear_task_signal.write().take(); + }); + + // Store the new task handle + self.typeahead_clear_task.write().replace(new_task); + + // Focus the best match using adaptive keyboard + let options = self.options.read(); + let keyboard = self.adaptive_keyboard.read(); + + if let Some(best_match_index) = + super::text_search::best_match(&keyboard, &typeahead, &options) + { + self.focus_state.set_focus(Some(best_match_index)); + } + } +} + +/// State for individual select options +pub(super) struct OptionState { + /// Tab index for focus management + pub tab_index: usize, + /// The value of the option + pub value: RcPartialEqValue, + /// Display text for the option + pub text_value: String, + /// Unique ID for the option + pub id: String, +} + +/// Context for select option components to know if they're selected +#[derive(Clone, Copy)] +pub(super) struct SelectOptionContext { + /// Whether this option is currently selected + pub selected: ReadOnlySignal, +} + +/// Context for select group components +#[derive(Clone, Copy)] +pub(super) struct SelectGroupContext { + /// ID of the element that labels this group + pub labeled_by: Signal>, +} diff --git a/primitives/src/select/mod.rs b/primitives/src/select/mod.rs new file mode 100644 index 00000000..c3066837 --- /dev/null +++ b/primitives/src/select/mod.rs @@ -0,0 +1,80 @@ +//! Defines the [`Select`] component and its sub-components, which provide a searchable select input with keyboard navigation. +//! +//! The Select component consists of several parts that work together: +//! - [`Select`] - The root container component +//! - [`SelectTrigger`] - The button that opens/closes the dropdown +//! - [`SelectList`] - The dropdown container for options +//! - [`SelectOption`] - Individual selectable options +//! - [`SelectItemIndicator`] - Visual indicator for selected items +//! - [`SelectGroup`] - Groups related options together +//! - [`SelectGroupLabel`] - Labels for option groups +//! - [`SelectValue`] - Displays the currently selected value +//! +//! ## Features +//! +//! - **Keyboard Navigation**: Full keyboard support with arrow keys, home/end, enter, and escape +//! - **Typeahead Search**: Smart text search that adapts to different keyboard layouts +//! - **Accessibility**: ARIA compliant with proper roles and attributes +//! - **Customizable**: Flexible styling through data attributes and CSS +//! - **Focus Management**: Automatic focus handling and restoration +//! +//! ## Typeahead Buffer Behavior +//! +//! The Select component implements an typeahead search buffer that lets you type while the dropdown is open to focus a matching +//! option. The buffer will be cleared after some amount of time has passed with no new input. The timeout is 1 second by default, +//! but can be configured by setting the [`SelectProps::typeahead_timeout`]. +//! +//! ## Example +//! +//! ```rust +//! use dioxus::prelude::*; +//! use dioxus_primitives::select::{ +//! Select, SelectGroup, SelectGroupLabel, SelectItemIndicator, +//! SelectList, SelectOption, SelectTrigger, SelectValue, +//! }; +//! +//! #[component] +//! fn Demo() -> Element { +//! rsx! { +//! Select:: { +//! placeholder: "Select a fruit...", +//! SelectTrigger{ +//! aria_label: "Select Trigger", +//! width: "12rem", +//! SelectValue {} +//! } +//! SelectList { +//! aria_label: "Select Demo", +//! SelectGroup { +//! SelectGroupLabel { "Fruits" } +//! SelectOption:: { +//! index: 0usize, +//! value: "apple", +//! "Apple" +//! SelectItemIndicator { "✔️" } +//! } +//! SelectOption:: { +//! index: 1usize, +//! value: "banana", +//! "Banana" +//! SelectItemIndicator { "✔️" } +//! } +//! } +//! } +//! } +//! } +//! } +//! ``` + +// Internal modules +mod components; +mod context; +pub(crate) mod text_search; + +// Re-export all public components and types +pub use components::{ + Select, SelectGroup, SelectGroupLabel, SelectGroupLabelProps, SelectGroupProps, + SelectItemIndicator, SelectItemIndicatorProps, SelectList, SelectListProps, SelectOption, + SelectOptionProps, SelectProps, SelectTrigger, SelectTriggerProps, SelectValue, + SelectValueProps, +}; diff --git a/primitives/src/select/text_search.rs b/primitives/src/select/text_search.rs new file mode 100644 index 00000000..5a553e9b --- /dev/null +++ b/primitives/src/select/text_search.rs @@ -0,0 +1,705 @@ +//! Text search and matching algorithms for the select component. + +use crate::select::context::OptionState; +use core::f32; +use std::collections::HashMap; + +/// Find the best matching option based on typeahead input +pub(super) fn best_match( + keyboard: &AdaptiveKeyboard, + typeahead: &str, + options: &[OptionState], +) -> Option { + if typeahead.is_empty() { + return None; + } + + let typeahead_characters: Box<[_]> = typeahead.chars().collect(); + + options + .iter() + .map(|opt| { + let value = &opt.text_value; + let value_characters: Box<[_]> = value.chars().collect(); + let distance = normalized_distance(&typeahead_characters, &value_characters, keyboard); + (distance, opt.tab_index) + }) + .min_by(|(d1, _), (d2, _)| f32::total_cmp(d1, d2)) + .map(|(_, value)| value) +} + +/// Calculate normalized distance between typeahead and value characters +pub(super) fn normalized_distance( + typeahead_characters: &[char], + value_characters: &[char], + keyboard: &AdaptiveKeyboard, +) -> f32 { + // Only use the the start of the value characters + let value_characters = + &value_characters[..value_characters.len().min(typeahead_characters.len())]; + // Only use the end of the typeahead characters + let typeahead_characters = &typeahead_characters[typeahead_characters + .len() + .saturating_sub(value_characters.len())..]; + + levenshtein_distance(typeahead_characters, value_characters, |a, b| { + keyboard.substitution_cost(a, b) + }) +} + +/// The recency bias of the levenshtein distance function +pub(super) fn recency_bias(char_index: usize, total_length: usize) -> f32 { + ((char_index as f32 + 1.5).ln() / (total_length as f32 + 1.5).ln()).powi(4) +} + +// We use a weighted Levenshtein distance to account for the recency of characters +// More recent characters have a higher weight, while older characters have a lower weight +// The first few characters in the value are weighted more heavily +// +// When substitution is required, the substitution is cheaper for characters that are closer together on the keyboard +fn levenshtein_distance( + typeahead: &[char], + value: &[char], + substitution_cost: impl Fn(char, char) -> f32, +) -> f32 { + let mut dp = vec![vec![0.0; value.len() + 1]; typeahead.len() + 1]; + + let mut prev = 0.0; + for j in 0..=value.len() { + let new = prev + (1.0 - recency_bias(j, value.len())) * 0.5; + prev = new; + dp[0][j] = new; + } + let mut prev = 0.0; + for (i, row) in dp.iter_mut().enumerate().take(typeahead.len() + 1) { + let new = prev + recency_bias(i, typeahead.len()) * 0.5; + prev = new; + row[0] = new; + } + + for i in 1..=typeahead.len() { + for j in 1..=value.len() { + let cost = if typeahead[i - 1] == value[j - 1] { + 0.0 + } else { + substitution_cost(typeahead[i - 1], value[j - 1]) + }; + + dp[i][j] = f32::min( + f32::min( + // Insertion is cheaper for old characters in the typeahead + dp[i - 1][j] + recency_bias(i, typeahead.len()), + // Deletion is cheaper for untyped characters in the value + dp[i][j - 1] + (1.0 - recency_bias(j, value.len())), + ), + // Substitution + dp[i - 1][j - 1] + cost * 2.0 * recency_bias(i, typeahead.len()), + ); + } + } + + let result = dp[typeahead.len()][value.len()]; + + let max_possible = dp[typeahead.len()][0].max(dp[0][value.len()]); + + // Normalize the result to a range of 0.0 to 1.0 + result / max_possible +} + +/// Adaptive keyboard learning system for multi-language support +#[derive(Debug, Clone)] +pub struct AdaptiveKeyboard { + /// Physical key position mappings learned from events + physical_mappings: HashMap, + /// Our current best guess of the keyboard layout based on learned mappings + layout: KeyboardLayout, +} + +impl Default for AdaptiveKeyboard { + fn default() -> Self { + Self::new() + } +} + +impl AdaptiveKeyboard { + /// Create a new adaptive keyboard system + pub fn new() -> Self { + Self { + physical_mappings: HashMap::new(), + layout: KeyboardLayout::Qwerty, + } + } + + /// Learn from a keyboard event mapping physical key to logical character + pub fn learn_from_event(&mut self, physical_code: &str, logical_char: char) { + self.physical_mappings + .insert(physical_code.to_string(), logical_char); + self.layout = KeyboardLayout::guess(&self.physical_mappings); + } + + /// Calculate hybrid substitution cost using multiple strategies + pub fn substitution_cost(&self, a: char, b: char) -> f32 { + if a == b { + return 0.0; + } + + let a_lowercase = a.to_lowercase().next().unwrap_or(a); + let b_lowercase = b.to_lowercase().next().unwrap_or(b); + + // Try physical key distance if we have mappings + let physical_cost = + self.layout + .distance_cost(a_lowercase, b_lowercase) + .map_or(f32::INFINITY, |cost| { + cost * 0.3 // Physical proximity is a strong signal + }); + + // Use Unicode codepoint similarity + let unicode_cost = self.unicode_similarity_cost(a, b); + + // Check phonetic similarity + let phonetic_cost = self.phonetic_similarity_cost(a_lowercase, b_lowercase); + + // Return the minimum of all costs + [physical_cost, unicode_cost, phonetic_cost] + .iter() + .cloned() + .fold(f32::INFINITY, f32::min) + } + + /// Calculate similarity based on Unicode codepoint proximity + fn unicode_similarity_cost(&self, a: char, b: char) -> f32 { + let diff = (a as u32).abs_diff(b as u32) as f32; + + // Characters close in Unicode are often similar + // Scale: adjacent codepoints get ~0.1 cost, distant ones approach 1.0 + (diff / 100.0).clamp(0.1, 1.0) + } + + /// Check phonetic similarity using small lookup groups + fn phonetic_similarity_cost(&self, a: char, b: char) -> f32 { + // Small groups of phonetically similar characters across scripts + const PHONETIC_GROUPS: &[&[char]] = &[ + // "A" sounds + &['a', 'а', 'α', 'ا', 'আ'], + // "B" sounds + &['b', 'б', 'β', 'ب', 'ব'], + // "S" sounds + &['s', 'с', 'σ', 'س', 'স'], + // "T" sounds + &['t', 'т', 'τ', 'ت', 'ত'], + // "N" sounds + &['n', 'н', 'ν', 'ن', 'ন'], + // "R" sounds + &['r', 'р', 'ρ', 'ر', 'র'], + // "L" sounds + &['l', 'л', 'λ', 'ل', 'ল'], + // "M" sounds + &['m', 'м', 'μ', 'م', 'ম'], + // "K" sounds + &['k', 'к', 'κ', 'ك', 'ক'], + // "P" sounds + &['p', 'п', 'π', 'پ', 'প'], + // "F" sounds + &['f', 'ф', 'φ', 'ف', 'ফ'], + // "O" sounds + &['o', 'о', 'ο', 'و', 'ও'], + // "E" sounds + &['e', 'е', 'ε', 'ه', 'এ'], + // "I" sounds + &['i', 'и', 'ι', 'ي', 'ই'], + // "U" sounds + &['u', 'у', 'υ', 'و', 'উ'], + // "D" sounds + &['d', 'д', 'δ', 'د', 'দ'], + // "G" sounds + &['g', 'г', 'γ', 'ج', 'গ'], + // "H" sounds + &['h', 'х', 'η', 'ه', 'হ'], + // "V" sounds + &['v', 'в', 'β', 'و', 'ভ'], + // "Z" sounds + &['z', 'з', 'ζ', 'ز', 'জ'], + // "Y" sounds + &['y', 'й', 'υ', 'ي'], + ]; + + for group in PHONETIC_GROUPS { + if group.contains(&a) && group.contains(&b) { + return 0.2; // Low cost for phonetically similar characters + } + } + + 1.0 // Default high cost + } +} + +/// Supported keyboard layouts for optimized text matching +#[derive(Debug, Clone, Copy, Default, PartialEq)] + +pub enum KeyboardLayout { + Qwerty, + ColemakDH, + Colemak, + Dvorak, + Workman, + Azerty, + Qwertz, + #[default] + Unknown, +} + +impl KeyboardLayout { + const KNOWN_KEYBOARD_LAYOUTS: &'static [KeyboardLayout] = &[ + KeyboardLayout::Qwerty, + KeyboardLayout::ColemakDH, + KeyboardLayout::Colemak, + KeyboardLayout::Dvorak, + KeyboardLayout::Workman, + KeyboardLayout::Azerty, + KeyboardLayout::Qwertz, + ]; + + /// Guess the keyboard layout based on observed key positions + pub fn guess(known_key_positions: &HashMap) -> KeyboardLayout { + Self::KNOWN_KEYBOARD_LAYOUTS + .iter() + .copied() + .find(|layout| { + known_key_positions.iter().all(|(from, to)| { + let Some(from_char) = code_to_char(from) else { + return false; + }; + match ( + Self::Qwerty.char_position(from_char), + layout.char_position(*to), + ) { + (Some(from_pos), Some(to_pos)) => from_pos == to_pos, + _ => false, + } + }) + }) + .unwrap_or_default() + } + + /// Calculate substitution cost between two characters based on keyboard distance + pub fn distance_cost(&self, a: char, b: char) -> Option { + let (a_pos, b_pos) = match (self.char_position(a), self.char_position(b)) { + (Some(a_pos), Some(b_pos)) => (a_pos, b_pos), + _ => return None, + }; + + let dx = (a_pos.0 as f32 - b_pos.0 as f32).abs(); + let dy = (a_pos.1 as f32 - b_pos.1 as f32).abs(); + let distance = (dx * dx + dy * dy).sqrt(); + + // Scale the distance to be between 0.0 and 1.0 + Some((distance / 10.0).clamp(0.0, 1.0)) + } + + /// Get the position of a character on the keyboard layout + fn char_position(&self, c: char) -> Option<(usize, usize)> { + let layout = match self { + KeyboardLayout::Qwerty => &QWERTY_KEYBOARD_LAYOUT, + KeyboardLayout::ColemakDH => &COLEMACK_DH_KEYBOARD_LAYOUT, + KeyboardLayout::Colemak => &COLEMAK_KEYBOARD_LAYOUT, + KeyboardLayout::Dvorak => &DVORAK_KEYBOARD_LAYOUT, + KeyboardLayout::Workman => &WORKMAN_KEYBOARD_LAYOUT, + KeyboardLayout::Azerty => &AZERTY_KEYBOARD_LAYOUT, + KeyboardLayout::Qwertz => &QWERTZ_KEYBOARD_LAYOUT, + KeyboardLayout::Unknown => return None, + }; + + for (row_idx, row) in layout.iter().enumerate() { + for (col_idx, &ch) in row.iter().enumerate() { + if ch == c { + return Some((col_idx, row_idx)); + } + } + } + None + } +} + +/// Convert a key code to a character +pub(super) fn code_to_char(code: &str) -> Option { + match code { + "KeyA" => Some('a'), + "KeyB" => Some('b'), + "KeyC" => Some('c'), + "KeyD" => Some('d'), + "KeyE" => Some('e'), + "KeyF" => Some('f'), + "KeyG" => Some('g'), + "KeyH" => Some('h'), + "KeyI" => Some('i'), + "KeyJ" => Some('j'), + "KeyK" => Some('k'), + "KeyL" => Some('l'), + "KeyM" => Some('m'), + "KeyN" => Some('n'), + "KeyO" => Some('o'), + "KeyP" => Some('p'), + "KeyQ" => Some('q'), + "KeyR" => Some('r'), + "KeyS" => Some('s'), + "KeyT" => Some('t'), + "KeyU" => Some('u'), + "KeyV" => Some('v'), + "KeyW" => Some('w'), + "KeyX" => Some('x'), + "KeyY" => Some('y'), + "KeyZ" => Some('z'), + "Digit0" => Some('0'), + "Digit1" => Some('1'), + "Digit2" => Some('2'), + "Digit3" => Some('3'), + "Digit4" => Some('4'), + "Digit5" => Some('5'), + "Digit6" => Some('6'), + "Digit7" => Some('7'), + "Digit8" => Some('8'), + "Digit9" => Some('9'), + _ => None, + } +} + +// Keyboard layout definitions +static QWERTY_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], + ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';'], + ['z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'], +]; + +static COLEMACK_DH_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['q', 'w', 'f', 'p', 'b', 'j', 'l', 'u', 'y', ';'], + ['a', 'r', 's', 't', 'g', 'm', 'n', 'e', 'i', 'o'], + ['x', 'c', 'd', 'v', 'z', 'k', 'h', ',', '.', '/'], +]; + +static COLEMAK_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y', ';'], + ['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'], + ['z', 'x', 'c', 'v', 'b', 'k', 'm', ',', '.', '/'], +]; + +static DVORAK_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'], + ['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'], + [';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'], +]; + +static WORKMAN_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['q', 'd', 'r', 'w', 'b', 'j', 'f', 'u', 'p', ';'], + ['a', 's', 'h', 't', 'g', 'y', 'n', 'e', 'o', 'i'], + ['z', 'x', 'm', 'c', 'v', 'k', 'l', ',', '.', '/'], +]; + +static AZERTY_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], + ['q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm'], + ['w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!'], +]; + +static QWERTZ_KEYBOARD_LAYOUT: [[char; 10]; 4] = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p'], + ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö'], + ['y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'], +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::select::context::{OptionState, RcPartialEqValue}; + use std::collections::HashMap; + + #[test] + fn test_levenshtein_distance() { + let typeahead = ['a', 'b', 'c']; + let value = ['a', 'b', 'c']; + let distance = levenshtein_distance(&typeahead, &value, |_, _| 1.0); + assert!(distance < 0.01); // Very small but not exactly 0 due to recency bias + + let typeahead = ['a', 'b', 'c']; + let value = ['a', 'b', 'd']; + let distance = levenshtein_distance(&typeahead, &value, |_, _| 1.0); + assert!(distance > 0.0); + + let typeahead = ['a', 'b']; + let value = ['a', 'b', 'c']; + let distance = levenshtein_distance(&typeahead, &value, |_, _| 1.0); + assert!(distance > 0.0); + + let typeahead = ['a', 'b', 'c']; + let value = ['a', 'b']; + let distance = levenshtein_distance(&typeahead, &value, |_, _| 1.0); + assert!(distance > 0.0); + + // Test with keyboard-aware substitution costs + let typeahead = ['q', 'w']; + let value = ['q', 'e']; + let qwerty_distance = levenshtein_distance(&typeahead, &value, |a, b| { + KeyboardLayout::Qwerty.distance_cost(a, b).unwrap() + }); + let uniform_distance = levenshtein_distance(&typeahead, &value, |_, _| 1.0); + + // 'w' and 'e' are adjacent on QWERTY, so should have lower cost than uniform + assert!(qwerty_distance < uniform_distance); + } + + #[test] + fn test_normalized_distance() { + let typeahead_chars = ['a', 'b', 'c']; + let value_chars = ['a', 'b', 'c']; + let keyboard = AdaptiveKeyboard::default(); + + let distance = normalized_distance(&typeahead_chars, &value_chars, &keyboard); + assert!(distance < 0.01); // Very small but not exactly 0 due to recency bias + + let typeahead_chars = ['a', 'b', 'c']; + let value_chars = ['a', 'b', 'd']; + let distance = normalized_distance(&typeahead_chars, &value_chars, &keyboard); + assert!(distance > 0.0); + + // Test truncation behavior + let typeahead_chars = ['a', 'b', 'c', 'd', 'e']; + let value_chars = ['x', 'y', 'z']; + let distance = normalized_distance(&typeahead_chars, &value_chars, &keyboard); + assert!(distance > 0.0); + + // Test with keyboard-aware costs + let typeahead_chars = ['q']; + let value_chars = ['w']; + let qwerty_distance = normalized_distance(&typeahead_chars, &value_chars, &keyboard); + + let typeahead_chars = ['q']; + let value_chars = ['p']; + let far_distance = normalized_distance(&typeahead_chars, &value_chars, &keyboard); + + // Adjacent keys should have lower distance than distant keys + assert!(qwerty_distance < far_distance); + } + + #[test] + fn test_detect_keyboard_layout() { + // Test QWERTY detection + let mut known_positions = HashMap::new(); + known_positions.insert("KeyQ".to_string(), 'q'); + known_positions.insert("KeyW".to_string(), 'w'); + known_positions.insert("KeyE".to_string(), 'e'); + + let detected = KeyboardLayout::guess(&known_positions); + assert_eq!(detected, KeyboardLayout::Qwerty); + + // Test with colemak dh matches + let mut known_positions = HashMap::new(); + known_positions.insert("KeyQ".to_string(), 'q'); + known_positions.insert("KeyW".to_string(), 'w'); + known_positions.insert("KeyE".to_string(), 'f'); + known_positions.insert("KeyR".to_string(), 'p'); + + let detected = KeyboardLayout::guess(&known_positions); + assert_eq!(detected, KeyboardLayout::ColemakDH); + + // Test empty input + let known_positions = HashMap::new(); + let detected = KeyboardLayout::guess(&known_positions); + assert_eq!(detected, KeyboardLayout::Qwerty); + } + + #[test] + fn test_keyboard_layout_substitution_cost() { + let layout = KeyboardLayout::Qwerty; + + // Same character should have zero cost + assert_eq!(layout.distance_cost('a', 'a'), Some(0.0)); + + // Adjacent characters should have lower cost than distant ones + let adjacent_cost = layout.distance_cost('q', 'w'); + let distant_cost = layout.distance_cost('q', 'p'); + assert!(adjacent_cost < distant_cost); + + // Unknown characters should return None + let unknown_cost = layout.distance_cost('α', 'β'); + assert_eq!(unknown_cost, None); + } + + #[test] + fn test_best_match() { + let options = vec![ + OptionState { + tab_index: 0, + value: RcPartialEqValue::new("apple"), + text_value: "Apple".to_string(), + id: "apple".to_string(), + }, + OptionState { + tab_index: 1, + value: RcPartialEqValue::new("banana"), + text_value: "Banana".to_string(), + id: "banana".to_string(), + }, + OptionState { + tab_index: 2, + value: RcPartialEqValue::new("cherry"), + text_value: "Cherry".to_string(), + id: "cherry".to_string(), + }, + ]; + + let layout = AdaptiveKeyboard::default(); + + // Exact prefix match + let result = best_match(&layout, "App", &options); + assert_eq!(result, Some(0)); + + // Partial match + let result = best_match(&layout, "ban", &options); + assert_eq!(result, Some(1)); + + // Empty typeahead should return None + let result = best_match(&layout, "", &options); + assert_eq!(result, None); + + // No match should return closest option + let result = best_match(&layout, "xyz", &options); + assert!(result.is_some()); + } + + #[test] + fn test_recency_bias() { + // Later characters should have higher bias (recency bias favors recent chars) + let early_bias = recency_bias(0, 10); + let late_bias = recency_bias(9, 10); + assert!(late_bias > early_bias); + + // Single character should have maximum bias + let single_bias = recency_bias(0, 1); + assert!(single_bias > 0.0); + assert!(single_bias <= 1.0); + } + + #[test] + fn test_adaptive_keyboard_learning() { + let mut adaptive = AdaptiveKeyboard::new(); + + // Test learning from keyboard events + adaptive.learn_from_event("KeyA", 'ф'); // Russian 'f' sound on A key + adaptive.learn_from_event("KeyS", 'ы'); // Russian 'y' sound on S key + assert_eq!(adaptive.layout, KeyboardLayout::Unknown); + + // Should have learned the mappings + assert_eq!(adaptive.physical_mappings.get("KeyA"), Some(&'ф')); + assert_eq!(adaptive.physical_mappings.get("KeyS"), Some(&'ы')); + + let options = vec![ + OptionState { + tab_index: 0, + value: RcPartialEqValue::new("ф"), + text_value: "ф".to_string(), + id: "ф".to_string(), + }, + OptionState { + tab_index: 1, + value: RcPartialEqValue::new("banana"), + text_value: "Banana".to_string(), + id: "banana".to_string(), + }, + ]; + + // ы should be a closer match to ф than banana + let result = best_match(&adaptive, "ф", &options); + assert_eq!(result, Some(0)); + + // b should still match banana + let result = best_match(&adaptive, "b", &options); + assert_eq!(result, Some(1)); + } + + #[test] + fn test_unicode_similarity() { + let adaptive = AdaptiveKeyboard::new(); + + // Close Unicode codepoints should have low cost + let close_cost = adaptive.unicode_similarity_cost('a', 'b'); // U+0061 vs U+0062 + let far_cost = adaptive.unicode_similarity_cost('a', '中'); // U+0061 vs U+4E2D + + assert!(close_cost < far_cost); + assert!(close_cost >= 0.1); + assert!(far_cost <= 1.0); + + // Same characters should have zero cost handled by main function + assert_eq!(adaptive.substitution_cost('a', 'a'), 0.0); + } + + #[test] + fn test_phonetic_similarity() { + let adaptive = AdaptiveKeyboard::new(); + + // Test Latin and Cyrillic 'a' sounds + let phonetic_cost = adaptive.phonetic_similarity_cost('a', 'а'); + assert_eq!(phonetic_cost, 0.2); // Should be low cost + + // Test Latin and Arabic 'b' sounds + let phonetic_cost = adaptive.phonetic_similarity_cost('b', 'ب'); + assert_eq!(phonetic_cost, 0.2); + + // Test unrelated characters + let unrelated_cost = adaptive.phonetic_similarity_cost('x', 'ж'); + assert_eq!(unrelated_cost, 1.0); // Should be high cost + } + + #[test] + fn test_hybrid_substitution_cost() { + let mut adaptive = AdaptiveKeyboard::new(); + + // Set up some learned mappings and corrections + adaptive.learn_from_event("KeyA", 'ф'); + adaptive.learn_from_event("KeyS", 'ы'); + + // Test physical key cost for mapped characters + let physical_cost = adaptive.substitution_cost('ф', 'ы'); + assert!(physical_cost < 1.0); // Should use physical distance + + // Test phonetic similarity fallback + let phonetic_cost = adaptive.substitution_cost('b', 'ب'); + assert_eq!(phonetic_cost, 0.2); + + // Test unicode similarity fallback + let unicode_cost = adaptive.substitution_cost('x', 'y'); + assert!(unicode_cost < 1.0); + assert!(unicode_cost >= 0.1); + } + + #[test] + fn test_multilingual_matching() { + let mut adaptive = AdaptiveKeyboard::new(); + + // Russian + adaptive.learn_from_event("KeyA", 'ф'); + adaptive.learn_from_event("KeyB", 'и'); + + // Arabic + adaptive.learn_from_event("KeyS", 'س'); + adaptive.learn_from_event("KeyT", 'ت'); + + // Chinese characters that are far apart in Unicode get high cost + let distant_chinese_cost = adaptive.substitution_cost('中', '国'); + assert_eq!(distant_chinese_cost, 1.0); // Distant characters should have max cost + + // But closer Chinese characters should have lower cost + let close_chinese_cost = adaptive.substitution_cost('中', '丰'); // U+4E2D vs U+4E30 + assert!(close_chinese_cost < 1.0); // Close Unicode characters should work + + // Mixed script matching - test f/ф which are phonetically similar + let mixed_cost = adaptive.substitution_cost('f', 'ф'); + assert!(mixed_cost < 1.0); // Should work through phonetic similarity + } +}