Compare commits
750 Commits
4f7d1e86b3
...
qa/stable
| Author | SHA1 | Date | |
|---|---|---|---|
| a4a24c148b | |||
| 9777ccb948 | |||
| 20a98d89b5 | |||
| e7f713795e | |||
| a0e4eeb479 | |||
| 0a6beb898b | |||
| 3a46b24c02 | |||
| 840025b66b | |||
| 9172287e0d | |||
| 0e1c3c93ec | |||
| 1f292e1e17 | |||
| 046f0bffb7 | |||
| 911ff29064 | |||
| e55097fd8f | |||
| b288652e20 | |||
| 7ac36ad320 | |||
| d5e16027f8 | |||
| d0ef012290 | |||
| a2b545a9e1 | |||
| b4c8d5257c | |||
| 5d02c8183f | |||
| f6e1d30068 | |||
| 21b98ad759 | |||
| 4619c8823a | |||
| e5943aa23b | |||
| 1bc0883ed7 | |||
| 28972a0e8e | |||
| ea803196bf | |||
| 1d25c8a4f5 | |||
| c3791c5a51 | |||
| da09e29058 | |||
| 74dacdc8f2 | |||
| 19d5e90006 | |||
| dcf3614d5c | |||
| d7a8b0a053 | |||
| 0c86afd6ab | |||
| 925a029f8f | |||
| db49d1050e | |||
| 817d94a42a | |||
| 4b37399593 | |||
| 3e6c32520b | |||
| 05720aacbe | |||
| 52c301523e | |||
| 20290f67a4 | |||
| ffb4f6c4d5 | |||
| 0768a34801 | |||
| 309cb795de | |||
| 2d93480fbf | |||
| 04433ee19f | |||
| 0a5665e296 | |||
| 8d154daa6a | |||
| 2f93dcb587 | |||
| e2c979e8cc | |||
| 110fc54a20 | |||
| faa167a9f4 | |||
| 513304f137 | |||
| 4359f508e5 | |||
| 8e358c4faf | |||
| 766fe76cc4 | |||
| f8421077bd | |||
| 3a637c60c8 | |||
| 6beaf749e7 | |||
| a76cc536f4 | |||
| 0e7aca6ebc | |||
| 25b4d89dae | |||
| 7056b62bf0 | |||
| 9cd6d14ed2 | |||
| dcf470c0c7 | |||
| 77d1c72e1e | |||
| 184406f965 | |||
| c45e942875 | |||
| 1a77f76f4f | |||
| a384350697 | |||
| d10d75db53 | |||
| 6c733157a7 | |||
| 0fe8a011c6 | |||
| aed5c75825 | |||
| 27500636c4 | |||
| a9ffbae06d | |||
| b62812c7fe | |||
| f873baf66a | |||
| 2c9c0c1012 | |||
| c9665501aa | |||
| 79bf7d6f60 | |||
| f794475623 | |||
| 0c5bf366f4 | |||
| be169b381b | |||
| 57d3520f82 | |||
| ae105b6e44 | |||
| 0be2287ae3 | |||
| f1916ef099 | |||
| 8c88d6c33a | |||
| 6dfc2f8b83 | |||
| 205a204d7e | |||
| 0ce51ebc28 | |||
| 94c7818a50 | |||
| 08f14ce0db | |||
| adcd0f7dc5 | |||
| 0851de91a1 | |||
| b24da598d1 | |||
| 468b177f4f | |||
| d8e759e649 | |||
| 9cb953e562 | |||
| ddd6f9305a | |||
| ed3b863923 | |||
| b9c592a52a | |||
| c013998f88 | |||
| a8ff91c0a8 | |||
| 23d3ce7a0b | |||
| 9d441994ff | |||
| 2c1fc46ed7 | |||
| e53161a53f | |||
| a2ba3d2ffe | |||
| 2a7fce70aa | |||
| 308a7ee940 | |||
| 2c0a760f18 | |||
| 8dd31bde4b | |||
| 3533459e00 | |||
| df40eb0073 | |||
| 3914d5d47d | |||
| abba348c9a | |||
| beca3d8d52 | |||
| d2829ba2d5 | |||
| 77c4ed8383 | |||
| 94d222f731 | |||
| 346d514284 | |||
| e0de7dfe7b | |||
| f88e57f1b3 | |||
| a093c07d40 | |||
| 9e72607952 | |||
| da166af7c3 | |||
| 8dc7201eb4 | |||
| d6105d9801 | |||
| 003dae302c | |||
| e109a9aa50 | |||
| f388464869 | |||
| 09cccb292f | |||
| 85812abb6c | |||
| c4cf0afc7b | |||
| d1b213fb01 | |||
| 56ad087e56 | |||
| c4384be959 | |||
| f20f673e76 | |||
| 0c5701512a | |||
| d0e408fae6 | |||
| c98f98e3f4 | |||
| cdf68b457e | |||
| 82e69df6df | |||
| 65c8faaa66 | |||
| 865477c37f | |||
| 802dbabe05 | |||
| ef58a415e7 | |||
| 242624c33a | |||
| c36b5078af | |||
| 414c61a002 | |||
| b5576c7082 | |||
| b37b23c813 | |||
| 8db2e8ef72 | |||
| eac7d4b337 | |||
| 573d3aa8a9 | |||
| 72432e8d31 | |||
| b454d5f7b0 | |||
| fa84a392fb | |||
| b93e8b01a5 | |||
| 2b8deb5099 | |||
| d6e4319151 | |||
| 1cb9d7a25a | |||
| b6c2afbd97 | |||
| 2c08c11645 | |||
| 1680c4ef8d | |||
| 6e048c1cd3 | |||
| b8f90b8e0d | |||
| 02d7eb3c4d | |||
| 368d47cbca | |||
| dccf79bb01 | |||
| 9380304e80 | |||
| 8fc4ec67e8 | |||
| cf44880df7 | |||
| a7b34894ed | |||
| 90bd60e0cf | |||
| e2fb02f40e | |||
| 6238012198 | |||
| 71cff84932 | |||
| 617269166a | |||
| fe9f1fb9ec | |||
| 80671f846f | |||
| f8bf00d2f8 | |||
| 854f425401 | |||
| 2ab472c9a5 | |||
| 62d8107266 | |||
| 67a9c23360 | |||
| 047b6c599c | |||
| 5863571e28 | |||
| 07a5275a7a | |||
| 79cc55270e | |||
| 11a22f93c1 | |||
| ee39b6088a | |||
| 3036f26505 | |||
| 9a1a6f9ed1 | |||
| 364ac80f40 | |||
| 5771651e44 | |||
| 036e044070 | |||
| 8e97e99cf4 | |||
| 48efb44a26 | |||
| 6437132ba3 | |||
| 43af4cf29d | |||
| dec3a9ff8a | |||
| ad87db767d | |||
| d23d861c45 | |||
| d489a3d9c3 | |||
| 80ffee54d7 | |||
| eb9c606e08 | |||
| 4b57f1126c | |||
| 33cb04ff68 | |||
| b6229e2a1c | |||
| cd20b0ce99 | |||
| 2c348704a0 | |||
| 38731955dc | |||
| 0715131a70 | |||
| 64289b59ca | |||
| 42ea31d5b5 | |||
| 19741b52b6 | |||
| c5238c6f3a | |||
| ab2bd00614 | |||
| 6e52cc6206 | |||
| 26c9867576 | |||
| ea5149338a | |||
| cd8a8e9b12 | |||
| f396a5545c | |||
| aa27a16912 | |||
| 29d2d8d70f | |||
| aa7be506c9 | |||
| 69c9cfeade | |||
| 9aa71e7cf4 | |||
| 594051975b | |||
| 1ce98bdacd | |||
| 53338960be | |||
| 9e85ef3f12 | |||
| 226271986e | |||
| 5f150dccaa | |||
| fc8c5ad776 | |||
| 55980c5ec1 | |||
| d50455318d | |||
| 671c87456e | |||
| 5443aad8be | |||
| 7e5e9f50d6 | |||
| 8740ab02a9 | |||
| 45e56d06bb | |||
| 0eb67c48fe | |||
| 39f53041e4 | |||
| be9d45e2e8 | |||
| 5f38d8a0e0 | |||
| 61f07e4c37 | |||
| 94c8b8646c | |||
| cd15c7079c | |||
| 051baf0e01 | |||
| 0d3c166674 | |||
| 4a571af7ba | |||
| 36c787a203 | |||
| 7579170912 | |||
| a692cab80f | |||
| b19ea5d2b9 | |||
| 4b113215c7 | |||
| 87bdc03213 | |||
| 11abb36f00 | |||
| 93a7c675fe | |||
| fa247fc259 | |||
| 9c783cd834 | |||
| 33a7a0bc6f | |||
| 11ab8a13e7 | |||
| 63351dcaba | |||
| c5826b7211 | |||
| 126da0583a | |||
| 063b6cb72b | |||
| 3067c6380a | |||
| 467de41d4e | |||
| 92bc8bb78a | |||
| 0c978c9811 | |||
| 1ac8baedfe | |||
| 5c80346179 | |||
| f042c668da | |||
| 5172abda5d | |||
| ed2dd1513f | |||
| 39a5d0d9b8 | |||
| 34985fff43 | |||
| d7ad79963b | |||
| 17da5618f5 | |||
| 050534a07f | |||
| 0fb919893f | |||
| 756a7e899d | |||
| a607a40d19 | |||
| 1dca26901b | |||
| 350544927d | |||
| 3b0888d56b | |||
| 6ccc341998 | |||
| 7fa60fbf57 | |||
| fd2e9517d3 | |||
| 82fa8c8732 | |||
| 053f38918d | |||
| 5a1d1660e9 | |||
| e0cf55e875 | |||
| b30dfe3501 | |||
| 9180e2c9fd | |||
| b91c1952e9 | |||
| 4eaa177c6d | |||
| e1e39bb087 | |||
| f763f635d1 | |||
| dae6353b58 | |||
| 6ee86ac732 | |||
| 5e14fc0dde | |||
| 5bc854b939 | |||
| 197d57b4f8 | |||
| df514784c5 | |||
| 387f9f2dad | |||
| f3325003b7 | |||
| 2971f9a61f | |||
| 106bbcaa56 | |||
| 6c4796cded | |||
| 0821286329 | |||
| e4351c4b25 | |||
| e64f72b9fa | |||
| 91aa304d94 | |||
| bfb9178733 | |||
| ae1ff24dae | |||
| 83763d488d | |||
| bc32fe9490 | |||
| 2996b878e9 | |||
| 8148317f09 | |||
| 9ef863739d | |||
| fb69321fea | |||
| 8a0a907dca | |||
| 4f2ee02a3e | |||
| 1476a401ae | |||
| 0ebd6da6a8 | |||
| e64097f2fd | |||
| 8fffaf5d1d | |||
| 68bfbddc82 | |||
| 0e996e42af | |||
| bb28f86a11 | |||
| 654a9ddfb4 | |||
| a67a9fe0a7 | |||
| 43d35fcb81 | |||
| b83cf59362 | |||
| 529455ca5a | |||
| 7e47a54ca5 | |||
| 276a46cccb | |||
| 5908202cd7 | |||
| 85ae90a5af | |||
| 999f3f6ec1 | |||
| ecd24d55be | |||
| 2d81907065 | |||
| 551b2f6407 | |||
| fe7972574a | |||
| 089527fd7d | |||
| 9ee04d7bb0 | |||
| 03df0d1294 | |||
| 85f009dff8 | |||
| 1289b54af3 | |||
| 90ed48cf86 | |||
| 320dac3baf | |||
| 7b4adb01a0 | |||
| a9a084ca97 | |||
| 20ff9d4349 | |||
| a3943ecb19 | |||
| d048717e76 | |||
| ee6d84457a | |||
| f125369744 | |||
| 5a1d5ba6d7 | |||
| a1e8384711 | |||
| 92189333ae | |||
| 77afc693ad | |||
| c6fd8b8afb | |||
| ee758817d3 | |||
| cc9ec02c94 | |||
| 31650b1275 | |||
| cbce645b3b | |||
| 66c39e0145 | |||
| 2316445b1e | |||
| 7084fe1feb | |||
| ea4e781bd4 | |||
| 2c17696cc5 | |||
| 250601dc78 | |||
| a071cb041e | |||
| 6a605bace3 | |||
| dd55fee177 | |||
| 8c2ff7bc21 | |||
| 3804759f45 | |||
| a754bd6b25 | |||
| 8c9541d209 | |||
| bd98fc8e70 | |||
| adc94cabf5 | |||
| 879be290d4 | |||
| 649ebf3291 | |||
| ff9442904b | |||
| b6a6827f9b | |||
| a80644c35b | |||
| e1b7de6f20 | |||
| 804ab3b18f | |||
| dddbccd97d | |||
| b533903994 | |||
| bb1625a06d | |||
| 6febeba211 | |||
| eea5074edf | |||
| fe78275304 | |||
| a8e13e38e7 | |||
| 0bcf73fb76 | |||
| 8b742061ec | |||
| ef564fc72b | |||
| 084ffcd980 | |||
| 312e80118f | |||
| cbf0526408 | |||
| 638f623750 | |||
| 476747aaca | |||
| c2412fb360 | |||
| 1831aecab6 | |||
| bbebb7072e | |||
| 2848dd555d | |||
| a3ece117c7 | |||
| ea77f4bb34 | |||
| ab758ab9fc | |||
| c423d9cb2d | |||
| 06091757c7 | |||
| 313954f464 | |||
| bc984ae837 | |||
| df75058db4 | |||
| f59e9b70e3 | |||
| 7418b7970b | |||
| 04f6df87f5 | |||
| 1455f82602 | |||
| d0eea04b48 | |||
| 16032c871a | |||
| 4a655d2dea | |||
| 38284ac425 | |||
| 2387335548 | |||
| 734eb33c48 | |||
| 2adae6e29e | |||
| adc69fa880 | |||
| af09500238 | |||
| 99214ff867 | |||
| 1009c891d9 | |||
| 9b1c068ead | |||
| 3058da101d | |||
| 5b8472eb1f | |||
| 5af2139366 | |||
| 508d11a29e | |||
| 6cde61829e | |||
| 36087b1b20 | |||
| e40cd2c1fd | |||
| 2a7fdcd9d7 | |||
| 08ceb8f127 | |||
| a3db5e66fc | |||
| 1af4e57a16 | |||
| b3fd20c103 | |||
| 55da08f27c | |||
| a8026bbee2 | |||
| f6706c1622 | |||
| b1bc8c6ed0 | |||
| 21950d9ddd | |||
| 7b41630b96 | |||
| 2a9193ce19 | |||
| 6701c226fb | |||
| 0f6331ccc6 | |||
| fc195dfb56 | |||
| 9d0c195beb | |||
| eac02f0c80 | |||
| d9559aee1d | |||
| 42e43c0415 | |||
| 23d1e063dc | |||
| 88c2515f2e | |||
| ea2a846734 | |||
| 6158f49583 | |||
| b39964011c | |||
| 6a5e12b5a1 | |||
| 48b0f356d4 | |||
| fc58442bbc | |||
| 023a2eb7a0 | |||
| 686b0f7fb8 | |||
| 490c9fa6ed | |||
| 56e5d18753 | |||
| 63d3c910a7 | |||
| 85731a206a | |||
| ba102e06fd | |||
| 0338f21dab | |||
| 72c5d24961 | |||
| f4a51017ae | |||
| 151d901bb9 | |||
| 3e1e4ea758 | |||
| 5a6c7a5ba0 | |||
| 85e63455cd | |||
| 176504b6c4 | |||
| c67b167f44 | |||
| 123e7e6ce8 | |||
| 2f9f83eb80 | |||
| 6cf4905a71 | |||
| dcd5e0e818 | |||
| 2ab9406902 | |||
| 9a118f662d | |||
| c2900a4852 | |||
| b4ab92ed98 | |||
| cb2601d3d6 | |||
| b814e47473 | |||
| ec7aaf0a7d | |||
| 5c25e88c98 | |||
| 5372fdd1f3 | |||
| bf1bd2241b | |||
| f40ef6d3ea | |||
| f90033707c | |||
| e042143468 | |||
| 9abadf0c4d | |||
| 1bf35dacdc | |||
| d88bcc0cca | |||
| 5601e59cf4 | |||
| 4d76ba086a | |||
| 48f94659e4 | |||
| 137bf87d83 | |||
| c66bcf35da | |||
| 7d57f261df | |||
| f398f09ab2 | |||
| 1132220a23 | |||
| 8e2175df29 | |||
| 10eb402c72 | |||
| e497c160f2 | |||
| f4701af984 | |||
| abfbb821b7 | |||
| f373b8cf31 | |||
| 265842ef63 | |||
| befed761d9 | |||
| b190718774 | |||
| aa165318ed | |||
| a5d4fafab1 | |||
| 44efe8b547 | |||
| c32f20cc9b | |||
| 13266f0816 | |||
| 3fe1fe3bc5 | |||
| c8bf4a92a4 | |||
| 306fd90ab7 | |||
| 6e6860abab | |||
| 7e0612ef47 | |||
| 5e81cfe42b | |||
| 03eab7ab67 | |||
| c82446aba7 | |||
| 90bba1c0b6 | |||
| 1b106168a7 | |||
| ac5c5cd233 | |||
| 8e6c84ade3 | |||
| 47756c899a | |||
| 46a90daf32 | |||
| 1e6898be65 | |||
| 2fc4d17615 | |||
| 68ca2ba2a7 | |||
| ce662bd0ff | |||
| 9a7c46a3c6 | |||
| fa07101f76 | |||
| cf4ec96946 | |||
| 9ff2c7b651 | |||
| a123140fab | |||
| 249fa03f01 | |||
| bd64e437b1 | |||
| b2beb43736 | |||
| db5d38c5f4 | |||
| f5db24a52f | |||
| 151b54243e | |||
| ab0f1a5286 | |||
| 37412b31ef | |||
| 9dfe9cfac5 | |||
| f3c83e9552 | |||
| ba196b8f3f | |||
| c324afcf05 | |||
| 263fbe9e70 | |||
| 2c0b9fdad6 | |||
| 907b8eb09c | |||
| b9261fb4ab | |||
| 1de7ec3166 | |||
| 9bb0704b0a | |||
| 6b895bfab1 | |||
| 1a913fa593 | |||
| 45a00f6c21 | |||
| d487303eaa | |||
| b77eb18142 | |||
| ae9d500e9c | |||
| 640c2860b9 | |||
| 182158bfd7 | |||
| ebd579380f | |||
| 706d86344c | |||
| 2aaf30fc09 | |||
| 6c5e331248 | |||
| a6d0692e15 | |||
| 532dcbb84f | |||
| 5fb61e1fc9 | |||
| 5c0caeb94c | |||
| e6cf8751d7 | |||
| 3fe59167f0 | |||
| 99981feb5f | |||
| 1cd47317a2 | |||
| cc3c649b80 | |||
| 173c3d520a | |||
| 805b54d035 | |||
| 31442958ce | |||
| 900ceeb5f1 | |||
| b7824f7e2a | |||
| 9e3d91493b | |||
| 4ca6f5e460 | |||
| 2b91b77afd | |||
| 835967e507 | |||
| fd1de1a5c8 | |||
| a6cd41c0c5 | |||
| 175f6dbf95 | |||
| f56ca51cb0 | |||
| 0dd0f50985 | |||
| dbdae9ffdf | |||
| 184f972486 | |||
| 9ef1b41954 | |||
| a0db91657c | |||
| 7684ae2b10 | |||
| 89cddada21 | |||
| fcec391074 | |||
| 7f947237bb | |||
| e6bf801679 | |||
| a755758577 | |||
| e54fdb6f22 | |||
| 73b37b1cdf | |||
| 818e20eb95 | |||
| 30f5a4558d | |||
| 697d354703 | |||
| bb8c44df60 | |||
| 81ea18936c | |||
| 95b46a05e7 | |||
| b93b6dd09b | |||
| 1eff67bccb | |||
| e270e09903 | |||
| e5c7778de5 | |||
| cb8a9ea357 | |||
| 1fa593317d | |||
| d7bfabb3ab | |||
| 5c10eb2a5f | |||
| d81b724408 | |||
| cdef6050d0 | |||
| 1fe76e26b5 | |||
| a650b6d91e | |||
| 0a0dc7a853 | |||
| 7437fed395 | |||
| b50315bb86 | |||
| c42eb4b33c | |||
| fc8a541fe6 | |||
| f0b115db94 | |||
| 314366aa8d | |||
| 39d105ad30 | |||
| cf251640b3 | |||
| 931ef705da | |||
| d685f66527 | |||
| 78779d7bb6 | |||
| fa8b0b839c | |||
| b45e6fb561 | |||
| 82909c5ed1 | |||
| 587ea92edb | |||
| 3143a8bd34 | |||
| 94669974ee | |||
| 8ac71c4efd | |||
| f1703b3e68 | |||
| 8b5f355d7e | |||
| 8711fae1af | |||
| d461a3ed33 | |||
| fe165a3cba | |||
| f20e977fdf | |||
| 052c215609 | |||
| 097f79fa1e | |||
| e203c9ed5d | |||
| 4b0dbfecec | |||
| 4f512b0d6b | |||
| 1a161d6e1f | |||
| 086f145b4f | |||
| e5ed297842 | |||
| 84f83df615 | |||
| d9feeda0fd | |||
| b263075e65 | |||
| d80e132a45 | |||
| c6ce184aeb | |||
| 6ad0765e4b | |||
| d961d570b5 | |||
| 563730c755 | |||
| ccb99c2065 | |||
| e6b4a84085 | |||
| 2b65922c7e | |||
| 9e8b435175 | |||
| 59dc091210 | |||
| 9bef9d20b7 | |||
| 874c2b901d | |||
| c5a4a37508 | |||
| e009f5c775 | |||
| 646fed92d5 | |||
| 42a954cba2 | |||
| 34abe3f1e8 | |||
| bf05e991ba | |||
| 44b86971ec | |||
| cfe299c377 | |||
| 27d797ab63 | |||
| 2e48d9413f | |||
| a036b0abfd | |||
| e781e72320 | |||
| 85988f0efc | |||
| 6cd991d6ba | |||
| 00a7b90f44 | |||
| 62784b1f2d | |||
| 11ac538d5c | |||
| 90d3b64fa0 | |||
| 3161340852 | |||
| b3c3bc70a4 | |||
| 4ffc6545ef | |||
| 1b75fb46d7 | |||
| 07b9b759c7 | |||
| 2f8ccb68d8 | |||
| cf65939af4 | |||
| d8d45fcac0 | |||
| 086831d474 | |||
| 0e385cdb3a | |||
| 7cf1f2877a | |||
| db96a30439 | |||
| 511a3901a7 | |||
| b6c948998b | |||
| 8d439f068f | |||
| 9651c39f83 | |||
| 86c72f7090 | |||
| c0bb8d705d | |||
| 5131977f54 | |||
| bfa70132da | |||
| d5ac26af31 | |||
| 33979b23a0 | |||
| 0c42c6f71e | |||
| 98f557cba8 | |||
| 7ce8bf72d4 | |||
| a5439fe4a6 | |||
| 35c9f00e26 | |||
| 265060fdb3 | |||
| fbe0348b47 | |||
| 99ce0cdd63 | |||
| f1363e81f7 | |||
| f57da4c74e | |||
| 3fb3cd2395 | |||
| f2fc286eeb | |||
| b9b05f53fb | |||
| c19f83b413 | |||
| 67374c78c3 | |||
| cbf1d4a248 | |||
| c457e0cc33 | |||
| 84d008b5fd | |||
| fd4ee018e6 | |||
| 62d4ce648d | |||
| ee20322e3f | |||
| dc4bac4d17 | |||
| 7072f77f88 |
+3291
-38
File diff suppressed because it is too large
Load Diff
+3550
-100
File diff suppressed because it is too large
Load Diff
+395
-36
@@ -1,38 +1,397 @@
|
||||
{"time":"2026-10-23T15:47:00+08:00","id":"b01","result":"done","summary":"新增 tooltip 悬浮提示组件(包裹式 API / 150ms 延迟 / Esc 收起 / reduced-motion 兜底),同步 manifest + llms-full.txt + demo 区块,并修复 ua style 对 URL 中 // 的误判;npm run check 全绿"}
|
||||
{"time":"2026-10-23T15:50:03+08:00","id":"b02","result":"done","summary":"新增 toast 消息提示组件:四语义工厂 + showToast,右上角堆叠,默认 3s 自动消失,微动画 + reduced-motion 兜底;manifest/docs/llms 同步,check 全绿"}
|
||||
{"time":"2026-10-23T15:53:33+08:00","id":"b03","result":"done","summary":"新增 skeleton 骨架屏组件:text/rect/circle 三形状 + 扫光微动画 + reduced-motion 静态化;manifest/docs/llms/demo 同步,check 全绿"}
|
||||
{"time":"2026-10-23T16:00:11+08:00","id":"b04","result":"done","summary":"新增独立 pagination 分页器(page 信号双向绑定 + 省略号折叠 + 键盘可达),dataTable 新增 page 受控桥接实现联动;demo 联动卡片,check 全绿"}
|
||||
{"time":"2026-10-23T16:03:46+08:00","id":"b05","result":"done","summary":"文档站核查确认已 manifest 驱动;补齐默认值信息量缺口:CLI propsTable 新增默认值列(df 字段)+ llms-full 默认标注,16 处默认值提取;check 全绿"}
|
||||
{"time":"2026-10-23T16:08:17+08:00","id":"c01","result":"done","summary":"button/checkbox/switchButton 补 disabled(boolean|Signal),复用 h() 布尔/信号既有语义零辅助代码;CSS 通用禁用态 + demo 信号联动示例;check 全绿"}
|
||||
{"time":"2026-10-23T16:10:31+08:00","id":"c02","result":"done","summary":"textField/textarea 补 disabled(boolean|Signal),与 c01 同模式;禁用态 CSS 覆盖输入类;demo locked 信号联动;check 全绿"}
|
||||
{"time": "2026-10-23T16:55:00+08:00", "id": "d01", "result": "done", "summary": "新增下拉菜单 menu:触发器 + 自绘弹层,disabled/danger/divider 条目,↑↓ 环形导航 + Esc 回焦,reduced-motion 兜底;manifest/llms-full/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T17:02:00+08:00", "id": "d02", "result": "done", "summary": "select/radioGroup 补 disabled(布尔|信号),并修复 select 根节点缺 ua-open 导致箭头不旋转的既有缺陷;CSS :has() 行级弱化;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:47:56+08:00", "id": "d03", "result": "done", "summary": "新增折叠面板 accordion:单开/多开 + active 受控 + 禁用面板,grid 行高 0fr→1fr 过渡免测量,aria 关联完整;manifest/llms/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:48:36+08:00", "id": "d04", "result": "done", "summary": "dataTable 新增 loading(布尔|信号):表体换骨架行、筛选与翻页禁用、aria-busy;demo 加开关,manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:49:45+08:00", "id": "d05", "result": "done", "summary": "新增面包屑 breadcrumb:nav/ol 语义结构 + 末项 aria-current,href 走 <a>、无 href 走 onNavigate,分隔符可配;manifest/llms/demo 同步,check 全绿(中途被 style 门禁拦下多行注释,已改单行)"}
|
||||
{"time": "2026-10-23T16:50:49+08:00", "id": "d06", "result": "done", "summary": "新增 ua verify 门禁:清单 id/exports/props/steps/example 与 demo 锚点一致性,纳入 npm run check;顺带补齐 badge/alert/spinner 手把手第三条,check 全绿"}
|
||||
{"time": "2026-10-23T16:51:48+08:00", "id": "d07", "result": "done", "summary": "新增可交互气泡卡片 popover:点击/hover 触发、点外部与 Esc 收起、内容可交互且内部点击不误关;manifest/llms/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:52:28+08:00", "id": "d08", "result": "done", "summary": "演示页改双栏 shell:sticky 侧边目录由 .sec 自动反推 + IntersectionObserver 滚动高亮,窄屏隐藏、reduced-motion 关平滑滚动;check 全绿"}
|
||||
{"time": "2026-10-23T16:53:06+08:00", "id": "d09", "result": "done", "summary": "新增滑块 slider:value 信号双向绑定 + min/max/step + 布尔|信号禁用 + showValue,原生 range 承载键盘与触屏,accent-color 令牌化;manifest/llms/demo 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:53:44+08:00", "id": "d10", "result": "done", "summary": "toast 新增 closable(× 手动关闭)与同屏上限 4 条,退场统一走 dismiss() 双兜底;is-closable 才接收指针;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:54:36+08:00", "id": "d11", "result": "done", "summary": "modal 新增 size 三档(sm/md/lg)与 footer 操作区(右对齐 + 上分割线),缺省行为不变;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:55:10+08:00", "id": "d12", "result": "done", "summary": "新增可关闭标签 tag(五档语义色 + closable/onClose,无状态委托调用方重渲染);demo 用 renderList 演示真实删除;manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:55:55+08:00", "id": "d13", "result": "done", "summary": "dataTable 列定义新增皮肤层 TableColumn(align + format),内核 ColumnDef 不变;表头/单元格共用对齐类,format 走 textContent;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:56:36+08:00", "id": "d14", "result": "done", "summary": "新增抽屉 drawer:open 信号驱动侧滑、placement/width 可配、footer 操作区、遮罩/×/Esc 关闭并聚焦首元素;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:57:22+08:00", "id": "d15", "result": "done", "summary": "CLI 包补 ua.manifest.json(type: tooling,七个子命令 + 约定),注册表 5 包;修正 AGENTS/ARCHITECTURE 过期组件数 17→27;check 全绿"}
|
||||
{"time": "2026-10-23T16:58:51+08:00", "id": "d16", "result": "done", "summary": "dataTable 消费内核列宽 width:colgroup 固定像素 / flex 自适应,选择列 44px;demo 三列演示宽档;manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T16:59:46+08:00", "id": "d17", "result": "done", "summary": "表单错误态打通包裹式控件:focusout/change 取代 blur + aria-invalid + .ua-invalid 后代选择器覆盖 select/radio/slider;demo 加必填 select;check 全绿"}
|
||||
{"time": "2026-10-23T17:00:55+08:00", "id": "d18", "result": "done", "summary": "ua verify 新增皮肤硬编码颜色门禁(#hex 禁止,rgba 仅限阴影/遮罩),清掉 ui.css 5 处 #fff 改令牌;负向测试确认能拦;check 全绿"}
|
||||
{"time": "2026-10-23T17:01:46+08:00", "id": "d19", "result": "done", "summary": "dataTable 新增 selected 受控信号:与内核选择双向同步(内容比较 + 拷贝保护),demo 展示已选计数与清空;check 全绿"}
|
||||
{"time": "2026-10-23T17:02:24+08:00", "id": "d20", "result": "done", "summary": "progress 支持不确定态:value 可选,缺省时 35% 填充块往复 + aria-label 进行中;定量态补 aria-valuenow;demo/manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T17:03:02+08:00", "id": "d21", "result": "done", "summary": "无障碍收尾:modal × 补 aria-label/type、spinner role=status、skeleton aria-hidden、tablist aria-label + 禁用页签 aria-disabled;conventions 记录基线;check 全绿"}
|
||||
{"time": "2026-10-23T17:03:49+08:00", "id": "d22", "result": "done", "summary": "新增头像 avatar(src 图片 / 首字占位 + size + 四档语义底色),demo 独立 sec-avatar;中途被 verify 的 demo 锚点检查拦下已补;check 全绿"}
|
||||
{"time": "2026-10-23T17:04:27+08:00", "id": "d23", "result": "done", "summary": "alert 新增 title / closable / onClose 与 role=alert,关闭走 200ms 退场后摘除;demo 独立 sec-alert;manifest/llms 同步,check 全绿"}
|
||||
{"time": "2026-10-23T17:05:46+08:00", "id": "d24", "result": "done", "summary": "重写 README(定位/快速开始/28 组件/目录/门禁/Agent 接入/流水线),组件清单做成标记区块并由 ua verify 校验防漂移;负向测试通过,check 全绿"}
|
||||
{"time": "2026-10-23T17:06:17+08:00", "id": "d25", "result": "done", "summary": "menu 条目新增 shortcut 快捷键提示(右侧 kbd 灰字,两端对齐 + 单行省略,不绑按键);demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T09:09:16+00:00", "round": 26, "id": "d26", "result": "done", "commit": "82f48aa", "summary": "select 键盘导航:↑↓ 环形移动、Home/End 跳转、Esc 回焦触发器"}
|
||||
{"ts": "2026-10-23T09:11:42+00:00", "round": 27, "id": "d27", "result": "done", "commit": "fac74a3", "summary": "modal 焦点陷阱 + dialog 语义"}
|
||||
{"ts": "2026-10-23T09:11:42+00:00", "round": 28, "id": "d28", "result": "done", "commit": "", "summary": "drawer 焦点陷阱对齐 modal"}
|
||||
{"ts": "2026-10-23T09:12:49+00:00", "round": 29, "id": "d29", "result": "done", "commit": "", "summary": "menu Home/End 跳转对齐 select"}
|
||||
{"ts": "2026-10-23T15:34:23+08:00", "id": "b01", "result": "done", "commit": "a57577a", "summary": "新增 tooltip 悬浮提示组件(包裹式 API / 150ms 延迟 / Esc 收起 / reduced-motion 兜底),同步 manifest + llms-full.txt + demo 区块,并修复 ua style 对 URL 中 // 的误判;npm run check 全绿"}
|
||||
{"ts": "2026-10-23T15:50:20+08:00", "id": "b02", "result": "done", "commit": "ece38bc", "summary": "新增 toast 消息提示组件:四语义工厂 + showToast,右上角堆叠,默认 3s 自动消失,微动画 + reduced-motion 兜底;manifest/docs/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T15:53:34+08:00", "id": "b03", "result": "done", "commit": "2882057", "summary": "新增 skeleton 骨架屏组件:text/rect/circle 三形状 + 扫光微动画 + reduced-motion 静态化;manifest/docs/llms/demo 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:00:13+08:00", "id": "b04", "result": "done", "commit": "8c9a63b", "summary": "新增独立 pagination 分页器(page 信号双向绑定 + 省略号折叠 + 键盘可达),dataTable 新增 page 受控桥接实现联动;demo 联动卡片,check 全绿"}
|
||||
{"ts": "2026-10-23T16:03:47+08:00", "id": "b05", "result": "done", "commit": "83bc1e2", "summary": "文档站核查确认已 manifest 驱动;补齐默认值信息量缺口:CLI propsTable 新增默认值列(df 字段)+ llms-full 默认标注,16 处默认值提取;check 全绿"}
|
||||
{"ts": "2026-10-23T16:08:19+08:00", "id": "c01", "result": "done", "commit": "2a5892a", "summary": "button/checkbox/switchButton 补 disabled(boolean|Signal),复用 h() 布尔/信号既有语义零辅助代码;CSS 通用禁用态 + demo 信号联动示例;check 全绿"}
|
||||
{"ts": "2026-10-23T16:10:32+08:00", "id": "c02", "result": "done", "commit": "f9324dd", "summary": "textField/textarea 补 disabled(boolean|Signal),与 c01 同模式;禁用态 CSS 覆盖输入类;demo locked 信号联动;check 全绿"}
|
||||
{"ts": "2026-10-23T16:46:00+08:00", "id": "d01", "result": "done", "commit": "84277a7", "summary": "新增下拉菜单 menu:触发器 + 自绘弹层,disabled/danger/divider 条目,↑↓ 环形导航 + Esc 回焦,reduced-motion 兜底;manifest/llms-full/demo 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:47:03+08:00", "id": "d02", "result": "done", "commit": "6b9fa95", "summary": "select/radioGroup 补 disabled(布尔|信号),并修复 select 根节点缺 ua-open 导致箭头不旋转的既有缺陷;CSS :has() 行级弱化;demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:47:56+08:00", "id": "d03", "result": "done", "commit": "df2fb34", "summary": "新增折叠面板 accordion:单开/多开 + active 受控 + 禁用面板,grid 行高 0fr→1fr 过渡免测量,aria 关联完整;manifest/llms/demo 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:48:36+08:00", "id": "d04", "result": "done", "commit": "ccbdcb9", "summary": "dataTable 新增 loading(布尔|信号):表体换骨架行、筛选与翻页禁用、aria-busy;demo 加开关,manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:49:45+08:00", "id": "d05", "result": "done", "commit": "60c0524", "summary": "新增面包屑 breadcrumb:nav/ol 语义结构 + 末项 aria-current,href 走 <a>、无 href 走 onNavigate,分隔符可配;manifest/llms/demo 同步,check 全绿(中途被 style 门禁拦下多行注释,已改单行)"}
|
||||
{"ts": "2026-10-23T16:50:49+08:00", "id": "d06", "result": "done", "commit": "582318a", "summary": "新增 ua verify 门禁:清单 id/exports/props/steps/example 与 demo 锚点一致性,纳入 npm run check;顺带补齐 badge/alert/spinner 手把手第三条,check 全绿"}
|
||||
{"ts": "2026-10-23T16:51:48+08:00", "id": "d07", "result": "done", "commit": "fb9ab88", "summary": "新增可交互气泡卡片 popover:点击/hover 触发、点外部与 Esc 收起、内容可交互且内部点击不误关;manifest/llms/demo 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:52:28+08:00", "id": "d08", "result": "done", "commit": "1a4b2c1", "summary": "演示页改双栏 shell:sticky 侧边目录由 .sec 自动反推 + IntersectionObserver 滚动高亮,窄屏隐藏、reduced-motion 关平滑滚动;check 全绿"}
|
||||
{"ts": "2026-10-23T16:53:06+08:00", "id": "d09", "result": "done", "commit": "a11157b", "summary": "新增滑块 slider:value 信号双向绑定 + min/max/step + 布尔|信号禁用 + showValue,原生 range 承载键盘与触屏,accent-color 令牌化;manifest/llms/demo 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:53:44+08:00", "id": "d10", "result": "done", "commit": "a08f157", "summary": "toast 新增 closable(× 手动关闭)与同屏上限 4 条,退场统一走 dismiss() 双兜底;is-closable 才接收指针;demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:54:36+08:00", "id": "d11", "result": "done", "commit": "d1a2864", "summary": "modal 新增 size 三档(sm/md/lg)与 footer 操作区(右对齐 + 上分割线),缺省行为不变;demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:55:10+08:00", "id": "d12", "result": "done", "commit": "9eefdcb", "summary": "新增可关闭标签 tag(五档语义色 + closable/onClose,无状态委托调用方重渲染);demo 用 renderList 演示真实删除;manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:55:55+08:00", "id": "d13", "result": "done", "commit": "730ee1a", "summary": "dataTable 列定义新增皮肤层 TableColumn(align + format),内核 ColumnDef 不变;表头/单元格共用对齐类,format 走 textContent;demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:56:36+08:00", "id": "d14", "result": "done", "commit": "d3fc754", "summary": "新增抽屉 drawer:open 信号驱动侧滑、placement/width 可配、footer 操作区、遮罩/×/Esc 关闭并聚焦首元素;demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:57:22+08:00", "id": "d15", "result": "done", "commit": "1c03c81", "summary": "CLI 包补 ua.manifest.json(type: tooling,七个子命令 + 约定),注册表 5 包;修正 AGENTS/ARCHITECTURE 过期组件数 17→27;check 全绿"}
|
||||
{"ts": "2026-10-23T16:58:52+08:00", "id": "d16", "result": "done", "commit": "fa168b9", "summary": "dataTable 消费内核列宽 width:colgroup 固定像素 / flex 自适应,选择列 44px;demo 三列演示宽档;manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T16:59:46+08:00", "id": "d17", "result": "done", "commit": "bdc1fec", "summary": "表单错误态打通包裹式控件:focusout/change 取代 blur + aria-invalid + .ua-invalid 后代选择器覆盖 select/radio/slider;demo 加必填 select;check 全绿"}
|
||||
{"ts": "2026-10-23T17:00:55+08:00", "id": "d18", "result": "done", "commit": "4012923", "summary": "ua verify 新增皮肤硬编码颜色门禁(#hex 禁止,rgba 仅限阴影/遮罩),清掉 ui.css 5 处 #fff 改令牌;负向测试确认能拦;check 全绿"}
|
||||
{"ts": "2026-10-23T17:01:46+08:00", "id": "d19", "result": "done", "commit": "4555542", "summary": "dataTable 新增 selected 受控信号:与内核选择双向同步(内容比较 + 拷贝保护),demo 展示已选计数与清空;check 全绿"}
|
||||
{"ts": "2026-10-23T17:02:24+08:00", "id": "d20", "result": "done", "commit": "9cfdb43", "summary": "progress 支持不确定态:value 可选,缺省时 35% 填充块往复 + aria-label 进行中;定量态补 aria-valuenow;demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T17:03:02+08:00", "id": "d21", "result": "done", "commit": "00a6104", "summary": "无障碍收尾:modal × 补 aria-label/type、spinner role=status、skeleton aria-hidden、tablist aria-label + 禁用页签 aria-disabled;conventions 记录基线;check 全绿"}
|
||||
{"ts": "2026-10-23T17:03:49+08:00", "id": "d22", "result": "done", "commit": "f86da98", "summary": "新增头像 avatar(src 图片 / 首字占位 + size + 四档语义底色),demo 独立 sec-avatar;中途被 verify 的 demo 锚点检查拦下已补;check 全绿"}
|
||||
{"ts": "2026-10-23T17:04:28+08:00", "id": "d23", "result": "done", "commit": "f361a19", "summary": "alert 新增 title / closable / onClose 与 role=alert,关闭走 200ms 退场后摘除;demo 独立 sec-alert;manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T17:05:46+08:00", "id": "d24", "result": "done", "commit": "6efe3ab", "summary": "重写 README(定位/快速开始/28 组件/目录/门禁/Agent 接入/流水线),组件清单做成标记区块并由 ua verify 校验防漂移;负向测试通过,check 全绿"}
|
||||
{"ts": "2026-10-23T17:06:17+08:00", "id": "d25", "result": "done", "commit": "6a5e662", "summary": "menu 条目新增 shortcut 快捷键提示(右侧 kbd 灰字,两端对齐 + 单行省略,不绑按键);demo/manifest/llms 同步,check 全绿"}
|
||||
{"ts": "2026-10-23T17:08:53+08:00", "id": "d26", "result": "done", "commit": "82f48aa", "summary": "select 键盘导航:↑↓ 环形移动、Home/End 跳转、Esc 回焦触发器"}
|
||||
{"ts": "2026-10-23T17:10:37+08:00", "id": "d27", "result": "done", "commit": "fac74a3", "summary": "modal 焦点陷阱 + dialog 语义"}
|
||||
{"ts": "2026-10-23T17:11:31+08:00", "id": "d28", "result": "done", "commit": "8e16829", "summary": "drawer 焦点陷阱对齐 modal"}
|
||||
{"ts": "2026-10-23T17:12:43+08:00", "id": "d29", "result": "done", "commit": "f8934ba", "summary": "menu Home/End 跳转对齐 select"}
|
||||
{"ts":"2026-10-23T17:50:14+08:00","id":"e01","result":"done","commit":"20d7b94","summary":"新增 inputNumber 数字输入:−/+ 按钮 + 键盘上下步进,信号双向绑定,min/max/step 夹取,门禁全绿"}
|
||||
{"ts":"2026-10-23T17:57:31+08:00","id":"e02","result":"done","commit":"fc7a1e8","summary":"新增 stepper 步骤条:三态流转 + active 信号双向绑定 + clickable 回跳,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:03:34+08:00","id":"e03","result":"done","commit":"ee20322","summary":"新增 rate 评分组件:星标点选 + hover 预览 + 键盘方向键调节,信号双向绑定,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:11:46+08:00","id":"e04","result":"done","commit":"fd4ee01","summary":"新增 empty 空状态组件:title/description/action 居中占位,静态零依赖,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:14:46+08:00","id":"e05","result":"done","commit":"c457e0c","summary":"新增 divider 分隔线组件:水平/垂直 + 居中文案 + 虚线,门禁全绿"}
|
||||
{"ts":"2026-10-23T18:42:15+08:00","id":"f01","result":"done","commit":"c19f83b","summary":"组件数漂移纳入 ua verify 门禁:AGENTS/ARCHITECTURE 的「N 个组件」与 skin-dom 清单实际数比对,修正 27→33"}
|
||||
{"ts":"2026-10-23T18:44:08+08:00","id":"f02","result":"done","commit":"f2fc286","summary":"AGENTS.md 与 ITERATION.md 的 npm run check 描述补上 verify,与实际六步链一致"}
|
||||
{"ts":"2026-10-23T18:46:55+08:00","id":"f03","result":"done","commit":"f57da4c","summary":"ua 新增 lint-iterations 子命令校验留痕格式并并入 check 链,36 条历史违规行按 git author date 回填修复"}
|
||||
{"ts":"2026-10-23T18:48:36+08:00","id":"f04","result":"done","commit":"99ce0cd","summary":"commit-lint 收严:非 iter type 必须带 scope,裸 chore:/docs:/iter: 反向用例全部拒绝,COMMITTING.md 同步"}
|
||||
{"ts":"2026-10-23T18:52:12+08:00","id":"f05","result":"done","commit":"265060f","summary":"ua index 幂等化:剔除 generatedAt 比对且行尾归一化,内容无变化跳写,连续两次 check 后工作区干净"}
|
||||
{"ts":"2026-10-23T18:53:33+08:00","id":"f06","result":"done","commit":"a5439fe","summary":"pnpm-lock.yaml 经 pnpm install --lockfile-only 生成入库;ARCHITECTURE.md 目录树补 scripts/、COMMITTING、ITERATION、pnpm-workspace、.ua 明细"}
|
||||
{"ts":"2026-10-23T18:55:24+08:00","id":"g01","result":"done","commit":"98f557c","summary":"writeIfChanged 支持归一化比较,llms-full.txt 仅时间戳之差时跳写,docs 双跑工作区干净;backlog 灌入 g01-g03"}
|
||||
{"ts":"2026-10-23T18:59:34+08:00","id":"g02","result":"done","commit":"33979b2","summary":"新增 image 组件:缩略图点击/回车开遮罩大图,Esc/点击遮罩关闭并归还焦点;组件 33→34,manifest/docs/README/demo 同步"}
|
||||
{"ts":"2026-10-23T19:04:29+08:00","id":"g03","result":"done","commit":"bfa7013","summary":"textField/textarea/inputNumber 新增 prefix/suffix(addonWrap 零侵入包装,无附加不包裹),样式聚焦光环由组承载;demo 与 manifest 同步"}
|
||||
{"ts":"2026-10-23T19:05:48+08:00","id":"g04","result":"done","commit":"c0bb8d7","summary":"dataTable 筛选输入框用 g03 的 prefix 放搜索图标,事件冒泡与筛选回归正常,门禁全绿"}
|
||||
{"ts":"2026-10-23T19:07:37+08:00","id":"g05","result":"done","commit":"9651c39","summary":"verify 内联重渲染 Agent 文档与 llms-full.txt 归一化比对,漂移即拦截提示 npm run docs"}
|
||||
{"ts":"2026-10-23T19:08:37+08:00","id":"g06","result":"done","commit":"b6c9489","summary":"checkCountDocs 扫描列表加入 README.md,三份文档组件计数文本全部纳入漂移门禁"}
|
||||
{"ts":"2026-10-26T10:17:59+08:00","id":"g07","result":"done","commit":"db96a30","summary":"新增 timeline 时间线组件:语义色节点 + 左右轴线 + pending 进行中节点,demo/manifest/README/llms-full 同步"}
|
||||
{"ts":"2026-10-26T10:20:58+08:00","id":"g08","result":"done","commit":"0e385cd","summary":"新增 segmented 分段控制器:胶囊分段 + 滑动滑块 + 键盘导航,manifest/README/llms-full 同步"}
|
||||
{"ts":"2026-10-26T10:23:31+08:00","id":"g09","result":"done","commit":"d8d45fc","summary":"新增 descriptions 描述列表:label:value 网格 + 列数变量化 + 窄屏单列 + bordered 表格形态"}
|
||||
{"ts":"2026-10-26T10:25:17+08:00","id":"g10","result":"done","commit":"2f8ccb6","summary":"新增 statistic 统计数值:rAF 滚动过渡 + 千分位精度 + 涨红跌绿趋势,减弱动效直落终值"}
|
||||
{"ts":"2026-10-26T10:27:10+08:00","id":"g12","result":"done","commit":"1b75fb4","summary":"verify 交叉校验 backlog done 与 iterations.log 留痕双向一致,注入违规即拦截"}
|
||||
{"ts":"2026-10-26T10:29:17+08:00","id":"g14","result":"done","commit":"b3c3bc7","summary":"演示站侧边目录加关键字过滤框:输入即筛、空态提示、Esc 清空,滚动高亮不受影响"}
|
||||
{"ts":"2026-10-26T10:31:15+08:00","id":"g13","result":"done","commit":"90d3b64","summary":"新增 ua changelog:按提交类型分组生成 CHANGELOG.md,支持 --check 幂等校验"}
|
||||
{"ts":"2026-10-26T10:52:00+08:00","id":"g11","result":"done","commit":"00a7b90","summary":"新增 flip() 翻转判定:popover/tooltip/select/menu 打开时按视口空间自动上下翻转,弹层不再溢出被裁切"}
|
||||
{"ts":"2026-10-26T10:55:35+08:00","id":"g15","result":"done","commit":"85988f0","summary":"toast 悬停暂停倒计时、移出按剩余时长继续,长文案不再来不及读"}
|
||||
{"ts":"2026-10-26T10:58:01+08:00","id":"g16","result":"done","commit":"a036b0a","summary":"image 加载失败自动替换虚线占位块,fallback 可自定义文案,demo 附失效地址示例"}
|
||||
{"ts":"2026-10-26T11:00:20+08:00","id":"g17","result":"done","commit":"27d797a","summary":"select 展开后键入字母按前缀环形快跳选项,1s 无输入重置缓冲,既有键盘导航不变"}
|
||||
{"ts":"2026-10-26T11:04:02+08:00","id":"g18","result":"done","commit":"44b8697","summary":"textField clearable 开启后有值即显一键清空按钮,点击同步信号与 onInput,空值自动隐藏"}
|
||||
{"ts":"2026-10-26T11:06:38+08:00","id":"g19","result":"done","commit":"34abe3f","summary":"textarea maxCount 设原生 maxlength 并在下方附加块显示实时 n/limit 计数"}
|
||||
{"ts":"2026-10-26T11:08:38+08:00","id":"g20","result":"done","commit":"646fed9","summary":"inputNumber 长按 −/+ 400ms 后每 100ms 连发步进,松开或移出即停,单击不变"}
|
||||
{"ts":"2026-10-26T11:11:28+08:00","id":"g21","result":"done","commit":"c5a4a37","summary":"pagination jumpable 时末尾显示页码输入与总页数,回车跳转自动钳制范围"}
|
||||
{"ts":"2026-10-26T11:13:47+08:00","id":"g22","result":"done","commit":"9bef9d2","summary":"menu 展开后键入字母按前缀环形快跳菜单项,与 select 行为对齐"}
|
||||
{"ts":"2026-10-26T11:31:23+08:00","id":"g23","result":"done","commit":"9e8b435","summary":"tabs 聚焦时 Home/End 直达首个/末个启用页签并激活,方向键环形行为不变"}
|
||||
{"ts":"2026-10-26T11:33:41+08:00","id":"g24","result":"done","commit":"e6b4a84","summary":"新增 list 列表组件:header/footer 外壳 + avatar/title/description/extra 条目,空数组回落空状态,组件数 38→39"}
|
||||
{"ts":"2026-10-26T11:34:59+08:00","id":"g25","result":"done","commit":"563730c","summary":"新增 ua tokens 命令(--json 可选)与 MCP get_tokens 工具,llms-full 令牌名统一 kebab-case"}
|
||||
{"ts":"2026-10-26T11:36:38+08:00","id":"g26","result":"done","commit":"6ad0765","summary":"verify 新增令牌引用校验(拼错变量名即失败),ua tokens 与 llms-full 统一从 tokens.css 取令牌"}
|
||||
{"ts":"2026-10-26T11:37:50+08:00","id":"g27","result":"done","commit":"d80e132","summary":"新增 overlay.ts Esc 栈:modal/drawer 后开先关,栈空自动摘全局监听"}
|
||||
{"ts":"2026-10-26T11:38:48+08:00","id":"g28","result":"done","commit":"d9feeda","summary":"浮层 Esc 逐层关闭:menu/select/popover/tooltip/image 各自阻止冒泡,弹窗内的下拉收起不再连带关弹窗"}
|
||||
{"ts":"2026-10-26T11:40:10+08:00","id":"g29","result":"done","commit":"e5ed297","summary":"新增 carousel 轮播:横排位移 + 指示点/箭头/←→ 键 + autoplay 悬停暂停 + 环形回绕,组件数 39→40"}
|
||||
{"ts":"2026-10-26T11:41:05+08:00","id":"g30","result":"done","commit":"1a161d6","summary":"check 链加入 demo 构建,verify 新增产物锚点校验(负向删除锚点即失败)"}
|
||||
{"ts":"2026-10-26T11:42:29+08:00","id":"g31","result":"done","commit":"4b0dbfe","summary":"新增 tree 树形:递归节点 + 箭头展开收起 + 点选/Enter/→/← 键盘 + 禁用不响应,组件数 40→41"}
|
||||
{"ts":"2026-10-26T11:43:15+08:00","id":"g32","result":"done","commit":"097f79f","summary":"tree 补 ↑↓/Home/End 行间导航:只移焦点不改选中,收起分支子节点不参与遍历"}
|
||||
{"ts":"2026-10-26T11:44:35+08:00","id":"g33","result":"done","commit":"f20e977","summary":"新增 upload 文件选择:点击/拖拽/键盘选择 + 已选清单移除 + 同名去重,组件数 41→42"}
|
||||
{"ts":"2026-10-26T11:45:47+08:00","id":"g34","result":"done","commit":"d461a3e","summary":"ua info 支持组件 id:输出 props 表 + 步骤 + 示例,MCP get_component 同步支持组件名;顺带修皮肤包描述里的过时组件枚举"}
|
||||
{"ts":"2026-10-26T11:46:55+08:00","id":"g35","result":"done","commit":"8b5f355","summary":"新增 notification 通知卡片:右下角堆叠、标题+描述+操作区、返回 {el,close}、duration 0 常驻,组件数 42→43"}
|
||||
{"ts":"2026-10-26T11:47:48+08:00","id":"g36","result":"done","commit":"8ac71c4","summary":"新增 result 结果页:四档语义色 + 居中终态布局 + role=status,组件数 43→44"}
|
||||
{"ts":"2026-10-26T11:48:31+08:00","id":"g37","result":"done","commit":"3143a8b","summary":"新增 recipes.md 组合配方(表单校验提交/危险确认/表格分页批量/空态加载)并由 ua docs 追加进 llms-full"}
|
||||
{"ts":"2026-10-26T11:50:04+08:00","id":"g38","result":"done","commit":"82909c5","summary":"select 新增 searchable:弹层搜索框实时过滤、打开即聚焦、无命中提示,键盘只在可见项间移动;README 分组行补齐新组件"}
|
||||
{"ts":"2026-10-26T11:50:41+08:00","id":"g39","result":"done","commit":"fa8b0b8","summary":"ui.css 顶部统一 focus-visible 描边兜底(消费 --ua-color-accent),README 补可访问性约定章节"}
|
||||
{"ts":"2026-10-26T11:51:41+08:00","id":"g40","result":"done","commit":"d685f66","summary":"button 增 type(缺省 button)修掉表单内 click+submit 双触发,form 交按钮走原生 submit 并支持单行输入 Enter 提交"}
|
||||
{"ts":"2026-10-26T11:52:25+08:00","id":"g41","result":"done","commit":"cf25164","summary":"verify 新增 README「分组:」行校验:漏写/多写组件均报错(formItem 别名放行),负向测试通过"}
|
||||
{"ts":"2026-10-26T11:53:50+08:00","id":"g42","result":"done","commit":"314366a","summary":"pagination 支持 pageSizes 条数切换(切换回第 1 页并按新条数重算总页数),分页区与附加区分组避免重建跳页框"}
|
||||
{"ts":"2026-10-26T11:57:39+08:00","id":"g43","result":"done","commit":"fc8a541","summary":"select/menu/popover/image Esc 接入统一弹层栈,多层叠加逐层关闭"}
|
||||
{"ts":"2026-10-26T12:00:19+08:00","id":"g44","result":"done","commit":"b50315b","summary":"accordion 标题 ↑↓/Home/End 环形导航,跳过禁用行"}
|
||||
{"ts":"2026-10-26T12:02:16+08:00","id":"g45","result":"done","commit":"0a0dc7a","summary":"overlay 新增 lockScroll 引用计数,模态层全关才还原 body 滚动"}
|
||||
{"ts":"2026-10-26T12:08:17+08:00","id":"g46","result":"done","commit":"1fe76e2","summary":"新增 transfer 穿梭框:两栏按 targetKeys 派生、逐栏搜索、全选含半选、禁用条目不参与,组件数 44→45"}
|
||||
{"ts":"2026-10-26T12:10:25+08:00","id":"g47","result":"done","commit":"d81b724","summary":"新增 cascader 级联选择:多列联动/悬停下钻/点击提交/changeOnSelect/全键盘,浏览路径与已提交路径分离,组件数 45→46"}
|
||||
{"ts":"2026-10-26T12:11:45+08:00","id":"g48","result":"done","commit":"d7bfabb","summary":"新增 splitter 可拖拽分栏:指针拖拽+方向键微调+双击复位+min 夹逼+横纵两向,组件数 46→47"}
|
||||
{"ts":"2026-10-26T12:12:56+08:00","id":"g49","result":"done","commit":"cb8a9ea","summary":"新增 autoComplete 自动补全:输入即过滤、↑↓ 环形高亮、Enter 选中、Esc 入栈、options 支持函数式远程联想,组件数 47→48"}
|
||||
{"ts":"2026-10-26T12:14:00+08:00","id":"g50","result":"done","commit":"e270e09","summary":"新增 datePicker 日期选择:单月网格+翻月/今天+min-max 闭区间+逐日逐周逐月键盘导航,组件数 48→49"}
|
||||
{"ts":"2026-10-26T12:15:04+08:00","id":"g51","result":"done","commit":"b93b6dd","summary":"新增 backTop 返回顶部:阈值浮现+conic 进度环+平滑回顶(尊重 reduced-motion)+内滚容器支持,组件数 49→50"}
|
||||
{"ts":"2026-10-26T12:16:11+08:00","id":"g52","result":"done","commit":"81ea189","summary":"新增 colorPicker:预设色板(缺省语义令牌色)+原生取色器+手输 CSS 颜色串,组件数 50→51"}
|
||||
{"ts":"2026-10-26T12:17:23+08:00","id":"g53","result":"done","commit":"697d354","summary":"新增 tour 引导漫游:聚光挖洞+卡片自动选方位+滚动重算+完成/跳过回调+Esc 入栈,组件数 51→52"}
|
||||
{"ts":"2026-10-26T12:18:24+08:00","id":"g54","result":"done","commit":"818e20e","summary":"新增 watermark:canvas 平铺单元+令牌色解析+多行旋转间距可配+覆盖层交互穿透,组件数 52→53"}
|
||||
{"ts":"2026-10-26T12:19:23+08:00","id":"g55","result":"done","commit":"e54fdb6","summary":"新增 checkboxGroup:数组信号+min/max 禁用边界+checkAll 半选+纵横向,组件数 53→54"}
|
||||
{"ts":"2026-10-26T12:20:34+08:00","id":"g56","result":"done","commit":"e6bf801","summary":"ua style 新增单文件 200 行门禁(分层包 src 实现文件,types/index 豁免),CODING 约定变可执行检查"}
|
||||
{"ts":"2026-10-26T12:22:13+08:00","id":"g57","result":"done","commit":"fcec391","summary":"form 校验支持异步规则:validator 可返回 Promise,formItem 新增 validateAsync 并按轮次丢弃过期结论,提交期间禁用按钮"}
|
||||
{"ts":"2026-10-26T12:23:29+08:00","id":"g58","result":"done","commit":"7684ae2","summary":"新增 affix 固钉:阈值转 fixed + 外层等高占位防塌陷 + 宽度横向自适应,组件数 54→55"}
|
||||
{"ts":"2026-10-26T12:24:10+08:00","id":"g59","result":"done","commit":"9ef1b41","summary":"verify 检测 README 区块与分组行的重复组件项(整词计数),补齐文档漂移门禁"}
|
||||
{"ts":"2026-10-26T12:25:17+08:00","id":"g60","result":"done","commit":"dbdae9f","summary":"新增 multiSelect 多选下拉:标签回显/搜索/点选不收起/max 禁用边界/键盘导航,组件数 55→56"}
|
||||
{"ts":"2026-10-26T12:26:46+08:00","id":"g61","result":"done","commit":"f56ca51","summary":"tree 新增 multi 勾选:勾父选子、子全选回勾父、半选态、禁用项不参与级联,勾选不触发选中"}
|
||||
{"ts":"2026-10-26T12:27:35+08:00","id":"g62","result":"done","commit":"a6cd41c","summary":"verify 要求每个组件被 demo 真实调用(id( 或 id.),补齐「有锚点无渲染」的漏检"}
|
||||
{"ts":"2026-10-26T12:28:56+08:00","id":"g63","result":"done","commit":"835967e","summary":"card 增加 extra/footer/bordered/hoverable,标题行改 flex 头(视觉不变),悬停浮起带 reduced-motion 兜底"}
|
||||
{"ts":"2026-10-26T12:29:47+08:00","id":"g64","result":"done","commit":"4ca6f5e","summary":"重新生成 CHANGELOG(199 提交)并在 AGENTS 说明它为何不进 check 链、只在发版前刷新"}
|
||||
{"ts":"2026-10-26T12:32:31+08:00","id":"g65","result":"done","commit":"b7824f7","summary":"verify 新增文档 props 必须落到实现源码的校验(幽灵字段直接报错),并补齐 image/textField/textarea/pagination 四处漏记字段"}
|
||||
{"ts":"2026-10-26T12:34:27+08:00","id":"g66","result":"done","commit":"805b54d","summary":"verify 反向解析 types.ts 的 Props 字段并与文档 props 表双向比对,清单新增 propsType 覆盖接口名,补齐 form.onReset"}
|
||||
{"ts":"2026-10-26T12:36:19+08:00","id":"g67","result":"done","commit":"cc3c649","summary":"tag 增加 checkable 筛选片:button + aria-pressed 与信号双向绑定,选中态走 accent 淡底,不整块重渲染"}
|
||||
{"ts":"2026-10-26T12:37:22+08:00","id":"g68","result":"done","commit":"99981fe","summary":"alert 增加 showIcon/icon 语义图标(aria-hidden 纯装饰)与 banner 通栏形态,补齐文档与演示"}
|
||||
{"ts":"2026-10-26T12:38:50+08:00","id":"g69","result":"done","commit":"e6cf875","summary":"修正 toggleSort 非 multi 永远停在 asc 的循环 bug 与 getPageInfo 陈旧快照,冒烟补三条不变量"}
|
||||
{"ts":"2026-10-26T12:39:32+08:00","id":"g70","result":"done","commit":"5fb61e1","summary":"冒烟补多列排序优先级、setPageSize 回首页、reset 回默认态三条不变量,断言 9 → 16"}
|
||||
{"ts":"2026-10-26T12:40:29+08:00","id":"g71","result":"done","commit":"a6d0692","summary":"数据表可排序表头补 aria-sort 三态(ascending/descending/none),与视觉箭头同源"}
|
||||
{"ts":"2026-10-26T12:41:30+08:00","id":"g72","result":"done","commit":"2aaf30f","summary":"文档站搜索框补 aria-label 与 type=search,新增 sr-only 状态区播报匹配结果数"}
|
||||
{"ts":"2026-10-26T12:42:29+08:00","id":"g73","result":"done","commit":"ebd5793","summary":"表格底栏分页加 role=navigation 与 aria-label,页码行数改 role=status aria-live 播报"}
|
||||
{"ts":"2026-10-26T12:43:37+08:00","id":"g74","result":"done","commit":"640c286","summary":"modal/drawer 生成唯一标题 id 并挂 aria-labelledby,读屏能念出对话框名称"}
|
||||
{"ts":"2026-10-26T12:44:42+08:00","id":"g75","result":"done","commit":"b77eb18","summary":"评分组件补 tabindex 与每轮键绑定、重渲染后归还焦点,方向键可连按,补 focus-visible 光环"}
|
||||
{"ts":"2026-10-26T12:45:27+08:00","id":"g76","result":"done","commit":"45a00f6","summary":"文字头像容器补 role=img,aria-label 才会被读屏当作可访问名播报"}
|
||||
{"ts":"2026-10-26T12:46:11+08:00","id":"g77","result":"done","commit":"6b895bf","summary":"重新生成 CHANGELOG(226 提交),changelog:check 恢复一致"}
|
||||
{"ts":"2026-10-26T12:56:05+08:00","id":"g78","result":"done","commit":"b9261fb","summary":"新增 space 间距容器:flex gap 托管子元素间距,size 走令牌或像素,direction/align/justify/wrap/block 可调,清单与演示同步"}
|
||||
{"ts":"2026-10-26T12:57:15+08:00","id":"g79","result":"done","commit":"2c0b9fd","summary":"可排序表头进 Tab 序,Enter/空格 与点击共用同一 toggle,补 scope=col"}
|
||||
{"ts":"2026-10-26T12:58:08+08:00","id":"g80","result":"done","commit":"c324afc","summary":"通知自动关闭改为可暂停:悬停/聚焦扣减已流逝时间,离开按剩余续计"}
|
||||
{"ts":"2026-10-26T12:58:56+08:00","id":"g81","result":"done","commit":"f3c83e9","summary":"树改 roving tabindex:整棵树一个 Tab 停点,focusin 跟随焦点,keydown 复用 keyOf"}
|
||||
{"ts":"2026-10-26T13:00:21+08:00","id":"g82","result":"done","commit":"37412b3","summary":"upload 新增 maxSize/maxCount,accept 对拖拽生效,拒收原因走 role=status 提示行"}
|
||||
{"ts":"2026-10-26T13:01:16+08:00","id":"g83","result":"done","commit":"151b542","summary":"开关 input 补 role=switch,新增 ariaLabel 兜底可访问名,演示加无文案实例"}
|
||||
{"ts":"2026-10-26T13:02:41+08:00","id":"g84","result":"done","commit":"db5d38c","summary":"新增 columnResize 手柄:拖动/方向键改列宽、双击复位,定宽表头挂载"}
|
||||
{"ts":"2026-10-26T13:03:44+08:00","id":"g85","result":"done","commit":"bd64e43","summary":"progress 新增 label 可访问名与 variant 语义色,满格不回退 success"}
|
||||
{"ts":"2026-10-26T13:05:35+08:00","id":"g86","result":"done","commit":"a123140","summary":"overlay 新增 bindOutside,8 个弹层改用它,popover 删空 keydown"}
|
||||
{"ts":"2026-10-26T13:06:13+08:00","id":"g87","result":"done","commit":"cf4ec96","summary":"轮播 focusin/focusout 也暂停自动播放,区域与幻灯片补 aria-roledescription"}
|
||||
{"ts":"2026-10-26T13:08:26+08:00","id":"g88","result":"done","commit":"9a7c46a","summary":"内核 getAllRows 跳过分页取全量派生行,表格 exportable 导出 CSV(BOM+转义),冒烟补 4 条"}
|
||||
{"ts":"2026-10-26T13:09:11+08:00","id":"g89","result":"done","commit":"68ca2ba","summary":"esbuild 开 minifyWhitespace/minifySyntax,demo.html 278KB→205KB,标识符保留"}
|
||||
{"ts":"2026-10-26T13:09:49+08:00","id":"g90","result":"done","commit":"1e6898b","summary":"重新生成 CHANGELOG(253 提交),changelog:check 恢复一致"}
|
||||
{"ts":"2026-10-26T13:10:40+08:00","id":"g91","result":"done","commit":"47756c8","summary":"formItem 新增 hint 辅助说明,报错时自动让位给错误文案"}
|
||||
{"ts":"2026-10-26T13:11:29+08:00","id":"g92","result":"done","commit":"ac5c5cd","summary":"badge 新增 count/max/dot 三形态,label 转可选,样式走令牌"}
|
||||
{"ts":"2026-10-26T13:12:28+08:00","id":"g93","result":"done","commit":"90bba1c","summary":"tree 新增 searchable:命中保留父链并临时展开,隐藏枝退出键盘遍历"}
|
||||
{"ts":"2026-10-26T13:13:08+08:00","id":"g94","result":"done","commit":"03eab7a","summary":"recipes 增补配方 5:数据表受控分页联动 + 导出全量行 + 列宽拖拽"}
|
||||
{"ts":"2026-10-26T13:14:05+08:00","id":"g95","result":"done","commit":"7e0612e","summary":"statistic 新增 countdown 模式:每秒 DD 天 HH:MM:SS,到点或卸载自动停表"}
|
||||
{"ts":"2026-10-26T13:15:00+08:00","id":"g96","result":"done","commit":"306fd90","summary":"drawer placement 扩到四向,上下滑出按高度、横向铺满,复用焦点陷阱与 Esc 栈"}
|
||||
{"ts":"2026-10-26T13:15:34+08:00","id":"g97","result":"done","commit":"3fe1fe3","summary":"重新生成 CHANGELOG(267 提交),收尾确认与提交史一致"}
|
||||
{"ts":"2026-10-26T13:16:32+08:00","id":"g98","result":"done","commit":"c32f20c","summary":"modal/drawer 新增 closeOnOverlay,false 时点遮罩不关,× 与 Esc 不变"}
|
||||
{"ts":"2026-10-26T13:17:13+08:00","id":"g99","result":"done","commit":"a5d4faf","summary":"spinner 新增 label 可访问名,缺省仍为加载中"}
|
||||
{"ts":"2026-10-26T13:17:42+08:00","id":"g100","result":"done","commit":"b190718","summary":"重新生成 CHANGELOG(273 提交),本次唤起 22 轮收尾"}
|
||||
{"ts":"2026-10-26T13:50:16+08:00","id":"g101","result":"done","commit":"265842e","summary":"新增 anchor 锚点导航:滚动高亮 + aria-current、offset 偏移定位、replaceState 写 hash,组件 57→58"}
|
||||
{"ts":"2026-10-26T13:51:46+08:00","id":"g102","result":"done","commit":"abfbb82","summary":"trapFocus 下沉到 overlay,modal/drawer 改调用,image 预览层补焦点锁与初始聚焦"}
|
||||
{"ts":"2026-10-26T13:54:09+08:00","id":"g103","result":"done","commit":"e497c16","summary":"dataTable 空状态分「暂无数据 / 没有符合关键词的行 + 一键清除筛选」,空状态行抽 tableEmpty.ts"}
|
||||
{"ts":"2026-10-26T13:55:22+08:00","id":"g104","result":"done","commit":"8e2175d","summary":"lint-iterations 回查留痕提交:哈希须真实存在且主题指向同一条目"}
|
||||
{"ts":"2026-10-26T13:56:59+08:00","id":"g105","result":"done","commit":"f398f09","summary":"tabs 支持 closable 页签(× / Delete 触发 onClose,指示条量外壳,触摸常显)"}
|
||||
{"ts":"2026-10-26T13:58:26+08:00","id":"g106","result":"done","commit":"c66bcf3","summary":"新增 watchViewport:affix/backTop/anchor/tour 的 scroll+resize 卸载后自摘,防空转累积"}
|
||||
{"ts":"2026-10-26T13:59:52+08:00","id":"g107","result":"done","commit":"48f9465","summary":"新增 popconfirm 二次确认气泡(popover 补受控 open),组件 58→59"}
|
||||
{"ts":"2026-10-26T14:01:11+08:00","id":"g108","result":"done","commit":"5601e59","summary":"form 提交失败统计不通项数,顶部 role=alert 汇总条 + onError(count)"}
|
||||
{"ts":"2026-10-26T14:02:01+08:00","id":"g109","result":"done","commit":"1bf35da","summary":"tooltip 触摸屏改为按下即出、抬手延迟 1.2s 收,鼠标/键盘路径不变"}
|
||||
{"ts":"2026-10-26T14:03:25+08:00","id":"g110","result":"done","commit":"e042143","summary":"select 新增 clearable:有值显 ×(Delete/Backspace 亦可清),复位到未选态"}
|
||||
{"ts":"2026-10-26T14:04:44+08:00","id":"g111","result":"done","commit":"f40ef6d","summary":"breadcrumb 新增 maxItems:首项+「…」+末几项,点开完整展开,末项恒为当前页"}
|
||||
{"ts":"2026-10-26T14:05:44+08:00","id":"g112","result":"done","commit":"5372fdd","summary":"toast 新增 actionLabel/onAction:撤销按钮点后先收起再回调"}
|
||||
{"ts":"2026-10-26T14:06:40+08:00","id":"g113","result":"done","commit":"ec7aaf0","summary":"affix 新增 position=bottom:占位底边越线才贴底固定,边距改 bottom"}
|
||||
{"ts":"2026-10-26T14:07:17+08:00","id":"g114","result":"done","commit":"cb2601d","summary":"重新生成 CHANGELOG(301 提交),本次唤起 14 轮收尾"}
|
||||
{"ts":"2026-10-26T14:09:03+08:00","id":"g115","result":"done","commit":"c2900a4","summary":"新增 calendar 月历(marker 徽标进 aria-label),日期工具下沉 dateKit 与 datePicker 共用,组件 59→60"}
|
||||
{"ts":"2026-10-26T14:10:22+08:00","id":"g116","result":"done","commit":"2ab9406","summary":"drawer 新增 modal:false:透明遮罩不锁滚动不困焦点,页面可继续操作"}
|
||||
{"ts":"2026-10-26T14:11:13+08:00","id":"g117","result":"done","commit":"6cf4905","summary":"新增 trackSize(ResizeObserver 自断),tabs 指示条与 segmented 滑块随尺寸重算"}
|
||||
{"ts":"2026-10-26T14:11:42+08:00","id":"g118","result":"done","commit":"123e7e6","summary":"重新生成 CHANGELOG(309 提交),本次唤起 18 轮收尾"}
|
||||
{"ts":"2026-10-26T14:12:37+08:00","id":"g119","result":"done","commit":"176504b","summary":"empty 新增 icon:自定义插图进图标位,缺省 ⌀ 且保持 aria-hidden"}
|
||||
{"ts":"2026-10-26T14:13:19+08:00","id":"g120","result":"done","commit":"5a6c7a5","summary":"AGENTS.md 新增 10 行落点清单:新增/改参组件要动的文件与门禁原因"}
|
||||
{"ts":"2026-10-26T14:31:31+08:00","id":"g121","result":"done","commit":"151d901","summary":"textField 补 maxCount 字数统计(原生上限 + 附加组内 n/limit 实时计数)"}
|
||||
{"ts":"2026-10-26T14:34:12+08:00","id":"g122","result":"done","commit":"72c5d24","summary":"rate 支持 allowHalf 半星(左半区 0.5 分、键盘 0.5 步进、渐变裁字半格填充)"}
|
||||
{"ts":"2026-10-26T14:35:01+08:00","id":"g123","result":"done","commit":"ba102e0","summary":"收尾刷新 CHANGELOG(319 提交)"}
|
||||
{"ts":"2026-10-26T14:37:11+08:00","id":"g124","result":"done","commit":"63d3c91","summary":"checkbox 补 indeterminate 半选态(原生属性同步,零新增 CSS)"}
|
||||
{"ts":"2026-10-26T14:44:38+08:00","id":"g125","result":"done","commit":"490c9fa","summary":"开关补 loading 加载态(转圈滑块 + aria-busy,单一 effect 防双写)"}
|
||||
{"ts":"2026-10-26T14:49:32+08:00","id":"g126","result":"done","commit":"023a2eb","summary":"inputNumber 补 precision 小数位(toFixed 归一,消除步进浮点尾差)"}
|
||||
{"ts":"2026-10-26T14:51:40+08:00","id":"g127","result":"done","commit":"48b0f35","summary":"recipes 补配方 6:长表单编辑页(吸底操作条 + 提交三处锁定)"}
|
||||
{"ts":"2026-10-26T14:52:25+08:00","id":"g128","result":"done","commit":"b399640","summary":"收尾刷新 CHANGELOG(329 提交),顺带提交 g127 漏带的 llms-full.txt"}
|
||||
{"ts":"2026-10-26T15:18:37+08:00","id":"g129","result":"done","commit":"ea2a846","summary":"新增 typography 排版文本组件(标题 h1-h5/段落/行内 + 语义色/装饰位/对齐 + 多行截断 + 复制按钮),组件 60→61"}
|
||||
{"ts":"2026-10-26T15:20:05+08:00","id":"g130","result":"done","commit":"23d1e06","summary":"新增 grid 栅格容器(columns 固定列 / min auto-fit 自适应 / gutter / align / dense,窄屏单列),组件 61→62"}
|
||||
{"ts":"2026-10-26T15:21:23+08:00","id":"g131","result":"done","commit":"d9559ae","summary":"新增 floatButton(左下/右下停靠、label 胶囊与纯图标圆形、badge 角标、tooltip 兼 aria-label、reduced-motion 兜底),组件 62→63"}
|
||||
{"ts":"2026-10-26T15:22:59+08:00","id":"g132","result":"done","commit":"9d0c195","summary":"新增 mentions(@ 触发弹层、名字/关键词过滤、↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层、按光标替换写回信号),组件 63→64"}
|
||||
{"ts":"2026-10-26T15:24:26+08:00","id":"g133","result":"done","commit":"0f6331c","summary":"新增 treeSelect(复用 tree 的搜索/多选/级联,单选即收起、多选保持展开,共享 Esc 栈 + 点外收起),组件 64→65"}
|
||||
{"ts":"2026-10-26T15:25:53+08:00","id":"g134","result":"done","commit":"2a9193c","summary":"新增 ua sync-counts 幂等改写三处组件计数,verify 正则覆盖 README 标题「共 N 个」并给修法提示"}
|
||||
{"ts":"2026-10-26T15:27:19+08:00","id":"g135","result":"done","commit":"21950d9","summary":"form 新增 columns(1/2/3)复用 grid 铺列,窄屏自动单列、按钮区整行;demo 注册表单改两列"}
|
||||
{"ts":"2026-10-26T15:28:20+08:00","id":"g136","result":"done","commit":"f6706c1","summary":"新增 link(站外自动补 ↗ 与 rel=noopener noreferrer 并播报新窗口,muted/disabled 齐全),组件 65→66;sync-counts 首跑改写 3 份文档计数"}
|
||||
{"ts":"2026-10-26T15:29:51+08:00","id":"g137","result":"done","commit":"55da08f","summary":"演示页头部加暗色主题开关(data-theme + localStorage 记忆,初值跟随系统),--page-* 变量改为响应显式主题、媒体查询仅兜未指定时"}
|
||||
{"ts":"2026-10-26T15:31:27+08:00","id":"g138","result":"done","commit":"1af4e57","summary":"datePicker 新增 range/end/onRangeChange:首点定起点不收弹层、次点定终点并自动排序,区间内部低比重底色连成一条"}
|
||||
{"ts":"2026-10-26T15:32:47+08:00","id":"g139","result":"done","commit":"08ceb8f","summary":"position 新增 flipSide 四向判定,tooltip 支持 left/right 并自动翻转;气泡定位与出场位移拆成两条规则,避免通用 .ua-open 覆盖方位"}
|
||||
{"ts":"2026-10-26T15:35:04+08:00","id":"g140","result":"done","commit":"e40cd2c","summary":"dataTable 新增 onRowClick:传了行变 tabindex=0 并响应点击/Enter/空格,未传保持纯展示;文件压到 199 行"}
|
||||
{"ts":"2026-10-26T15:36:29+08:00","id":"g141","result":"done","commit":"6cde618","summary":"notification 新增 placement 四角停靠:每角独立容器与 3 条上限,顶部角落 column-reverse 让新通知贴边出现"}
|
||||
{"ts":"2026-10-26T15:37:34+08:00","id":"g142","result":"done","commit":"5af2139","summary":"space 新增 split:只在 2 个以上子元素间插分隔符,aria-hidden + 弱化色 + 不可选中"}
|
||||
{"ts":"2026-10-26T15:38:51+08:00","id":"g143","result":"done","commit":"3058da1","summary":"tabs 新增 extra 右侧操作区:与页签同一行共用分隔线,不传时 DOM 结构不变"}
|
||||
{"ts":"2026-10-26T15:40:02+08:00","id":"g144","result":"done","commit":"1009c89","summary":"新增 avatarGroup(叠放 + max 折叠 +N,整组 title 列全部姓名,+N 补可访问名),组件 66→67"}
|
||||
{"ts":"2026-10-26T15:41:22+08:00","id":"g145","result":"done","commit":"af09500","summary":"slider 新增 marks:按 min-max 换算百分比绝对定位在轨道下方,aria-hidden 纯装饰,轨道包一层不影响右侧数值"}
|
||||
{"ts":"2026-10-26T15:43:32+08:00","id":"g146","result":"done","commit":"2adae6e","summary":"progress 新增 type=circle:createElementNS 造 SVG 双圆 + dashoffset 出弧,中间百分比与 aria-label 自家拼,variant 换弧色"}
|
||||
{"ts":"2026-10-26T15:44:44+08:00","id":"g147","result":"done","commit":"2387335","summary":"recipes.md 补配方 7-9:筛选栏(treeSelect + 日期区间)、评论输入(mentions + avatarGroup)、详情页排版(grid + typography + descriptions)"}
|
||||
{"ts":"2026-10-26T15:48:24+08:00","id":"g148","result":"done","commit":"4a655d2","summary":"ua mcp 新增 list_recipes/get_recipe 取组合配方,verify 校验配方结构"}
|
||||
{"ts":"2026-10-26T15:49:55+08:00","id":"g149","result":"done","commit":"d0eea04","summary":"ua search 按 id/标题/props/步骤检索,未命中回相近 id"}
|
||||
{"ts":"2026-10-26T15:50:44+08:00","id":"g150","result":"done","commit":"04f6df8","summary":"ua info / MCP get_component 带出所属配方线索"}
|
||||
{"ts":"2026-10-26T15:51:49+08:00","id":"g151","result":"done","commit":"f59e9b7","summary":"verify 卡住 MCP 工具清单三处漂移"}
|
||||
{"ts":"2026-10-26T15:52:38+08:00","id":"g152","result":"done","commit":"bc984ae","summary":"ua list --components 出 67 组件全览"}
|
||||
{"ts":"2026-10-26T15:53:22+08:00","id":"g153","result":"done","commit":"0609175","summary":"ua search 多词 AND 与未命中退出码 1"}
|
||||
{"ts":"2026-10-26T15:54:06+08:00","id":"g154","result":"done","commit":"ab758ab","summary":"MCP search_components 返回组件级命中"}
|
||||
{"ts":"2026-10-26T15:55:14+08:00","id":"g155","result":"done","commit":"a3ece11","summary":"llms.txt 输出配方目录,修正 MCP 工具描述"}
|
||||
{"ts":"2026-10-26T15:55:38+08:00","id":"g156","result":"done","commit":"bbebb70","summary":"ua tokens <子串> 过滤令牌"}
|
||||
{"ts":"2026-10-26T15:56:57+08:00","id":"g157","result":"done","commit":"c2412fb","summary":"新增配方 10:暗色主题切换与 color-scheme 同步"}
|
||||
{"ts":"2026-10-26T16:00:46+08:00","id":"g158","result":"done","commit":"638f623","summary":"datePicker 新增 presets 快捷区间(days 相对今天 / from-to 绝对日期,min-max 钳制)"}
|
||||
{"ts":"2026-10-26T16:02:24+08:00","id":"g159","result":"done","commit":"312e801","summary":"multiSelect 新增 maxTagCount 标签折叠(+N 虚线条目,悬停看全量、点击展开)"}
|
||||
{"ts":"2026-10-26T16:03:56+08:00","id":"g160","result":"done","commit":"ef564fc","summary":"slider 新增 range 双滑块(叠轨 + 已选段,越界自动压平)"}
|
||||
{"ts":"2026-10-26T16:05:06+08:00","id":"g161","result":"done","commit":"0bcf73f","summary":"notification 支持 key 复用与 update 原地改标题/描述/语义色"}
|
||||
{"ts":"2026-10-26T16:07:27+08:00","id":"g162","result":"done","commit":"fe78275","summary":"verify 新增暗色令牌校验(颜色必覆盖、反向查多余变量),ua tokens 标注缺暗色"}
|
||||
{"ts":"2026-10-26T16:08:23+08:00","id":"g163","result":"done","commit":"6febeba","summary":"verify 补 backlog 编号递增/去重与留痕 ts 时区、单调、未来三类校验"}
|
||||
{"ts":"2026-10-26T16:09:59+08:00","id":"g164","result":"done","commit":"b533903","summary":"descriptions 新增 layout=vertical 与 extra 标题插槽"}
|
||||
{"ts":"2026-10-26T16:11:06+08:00","id":"g165","result":"done","commit":"804ab3b","summary":"stepper 新增 direction=vertical(圆点下方连线,已走段用主色)"}
|
||||
{"ts":"2026-10-26T16:13:05+08:00","id":"g166","result":"done","commit":"a80644c","summary":"select/multiSelect 支持 option.group 分组小标题(过滤后空组标题隐藏)"}
|
||||
{"ts":"2026-10-26T16:14:19+08:00","id":"g167","result":"done","commit":"ff94429","summary":"modal 新增 confirmText/cancelText/onConfirm 内置页脚,Promise 期间按钮转处理中"}
|
||||
{"ts":"2026-10-26T16:14:59+08:00","id":"g168","result":"done","commit":"879be29","summary":"配方 11:区间滑块 + 日期预设 + 分组下拉 + 表格的筛选区拼装"}
|
||||
{"ts":"2026-10-26T16:16:07+08:00","id":"g169","result":"done","commit":"bd98fc8","summary":"formItem 新增 span 跨列(窄屏/单列自动退回整行)"}
|
||||
{"ts":"2026-10-26T16:17:06+08:00","id":"g170","result":"done","commit":"a754bd6","summary":"pagination 新增 simple 极简模式与 hideOnSinglePage"}
|
||||
{"ts":"2026-10-26T16:17:49+08:00","id":"g171","result":"done","commit":"6a605ba","summary":"刷新 CHANGELOG 至 400 提交,补 llms.txt 的配方 11 目录行"}
|
||||
{"ts":"2026-10-26T16:19:32+08:00","id":"g172","result":"done","commit":"250601d","summary":"ua list 新增 --json(包 + 组件 id 结构化输出),CLI 清单与 AGENTS 同步"}
|
||||
{"ts":"2026-10-26T16:20:30+08:00","id":"g173","result":"done","commit":"ea4e781","summary":"upload 图片文件带 28px 缩略图,对象 URL 随文件离场 revoke"}
|
||||
{"ts":"2026-10-26T16:21:48+08:00","id":"g174","result":"done","commit":"2316445","summary":"timeline 新增 mode=alternate(轴线居中、奇偶分列、窄屏回落单侧)"}
|
||||
{"ts":"2026-10-26T16:23:24+08:00","id":"g175","result":"done","commit":"cbce645","summary":"popconfirm 的 onConfirm 可返回 Promise(就地 loading,成功才收起),modal 同步放宽类型"}
|
||||
{"ts": "2026-10-26T16:27:02+08:00", "id": "g176", "result": "done", "commit": "cc9ec02", "summary": "ua search 覆盖参数说明(五层字段表,命中非 id 标题时标出字段),MCP search_components 复用同表并补 matched 输出"}
|
||||
{"ts": "2026-10-26T16:29:32+08:00", "id": "g177", "result": "done", "commit": "c6fd8b8", "summary": "新增 ua recipes / ua recipes <编号>(含 --json),MCP 的 list_recipes/get_recipe 复用同一组件抽取函数,补 AGENTS 与 tools manifest"}
|
||||
{"ts": "2026-10-26T16:30:46+08:00", "id": "g178", "result": "done", "commit": "9218933", "summary": "verify 新增配方校验:代码块里必须至少认出一个已注册组件 id,拼错组件名直接报错(负向自检已确认会拦)"}
|
||||
{"ts":"2026-10-26T17:09:15+08:00","id":"g187","result":"done","commit":"20ff9d4","summary":"新增 virtualList 虚拟列表(固定行高窗口渲染 + rAF 合帧 + scrollTo 句柄),组件 67→68;上游人工推入 g179–g186 视觉域条目后改号 g187,rebase 后门禁全绿"}
|
||||
{"ts":"2026-10-26T17:17:27+08:00","id":"g179","result":"done","commit":"7b4adb0","summary":"弹层阴影语义化:8 处主题色投影归入 shadow-1..4 与新增 accent 两档令牌,shadow-literal 17→9,新基线 27 处,intro 补对照表"}
|
||||
{"ts":"2026-10-26T17:22:20+08:00","id":"g180","result":"done","commit":"90ed48c","summary":"内距令牌化:space-literal 第六类规则 + 四档半步令牌,字面值 160→13,新基线 40 处"}
|
||||
{"ts":"2026-10-26T17:30:00+08:00","id":"g181","result":"done","commit":"85f009d","summary":"控件高度统一 control-h 三档:四组件新增 size 入参,control-h-literal 门禁计数 0,demo 三档并排"}
|
||||
{"ts":"2026-10-26T17:33:15+08:00","id":"g182","result":"done","commit":"9ee04d7","summary":"暗色焦点环两令牌补覆盖 + verify 暗色齐全扩展到 shadow/focus 前缀(负向已拦)+ ua tokens 暗色列可见"}
|
||||
{"ts": "2026-10-26T17:36:54+08:00", "id": "g183", "result": "done", "commit": "fe79725", "summary": "字重行高令牌化:weight/leading 两类规则,72 处字面值全部归零,补 bold/none 档,三档归属注释就位"}
|
||||
{"ts":"2026-10-26T17:40:37+08:00","id":"g184","result":"done","commit":"2d81907","summary":"z-index 归零:20 处浮层字面值按五档令牌归位(z-literal 规则 + 幂等迁移),新基线 40 处"}
|
||||
{"ts":"2026-10-26T17:45:53+08:00","id":"g185","result":"done","commit":"999f3f6","summary":"分割线与轮廓分色:新增 border-literal 第十一类门禁(色值字面量,纯黑 rgba 边可迁移),19 处 1px 分割线改走 --ua-border-subtle,计数 0、基线 40 处"}
|
||||
{"ts":"2026-10-26T17:47:55+08:00","id":"g186","result":"done","commit":"5908202","summary":"视觉回归抽查清单固化:ITERATION.md 六组件 26 条可勾选项(五维度标签),ua verify 新增章节/条目/维度三重校验(负向已拦),AGENTS 与 cli 清单同步"}
|
||||
{"ts":"2026-10-26T17:50:31+08:00","id":"g188","result":"done","commit":"7e47a54","summary":"新增 commandPalette 命令面板(第 69 个组件):⌘K 唤起、label/hint/keywords 三层过滤、↑↓ 环形高亮 + aria-activedescendant、Esc 走弹层栈、开关归还焦点;样式全令牌,视觉基线仍 40 处"}
|
||||
{"ts":"2026-10-26T17:51:39+08:00","id":"g189","result":"done","commit":"b83cf59","summary":"补配方 12:命令面板 ⌘K 入口(id 分发 / keywords 兜拼音 / 二次确认外置 / hotkey 单实例),ua recipes 12 可取出骨架与六条要点"}
|
||||
{"ts":"2026-10-26T17:52:50+08:00","id":"g190","result":"done","commit":"a67a9fe","summary":"字号字面值归零:字号令牌 6→11 档(22/26/34px 展示档 + 0.92em/0.85em 相对缩放档),迁移 5 处,font-size-literal 0,总计数 40→35 并写新基线"}
|
||||
{"ts":"2026-10-26T17:53:57+08:00","id":"g191","result":"done","commit":"bb28f86","summary":"焦点环变体归零:新增 warning/danger/pulse/selected 四档语义环(亮暗各一套),四处自绘环改走令牌,focus-ring-variant 4→0、总计数 35→27 并写新基线"}
|
||||
{"ts":"2026-10-26T17:55:17+08:00","id":"g192","result":"done","commit":"68bfbdd","summary":"动效时长归零:交互档扩到四档(新增 slower 0.35s)+ 循环四档 0.6/0.8/1.2/1.6s,迁移仅对 animation+infinite 走循环表,9 处全清,总计数 27→18"}
|
||||
{"ts":"2026-10-26T17:56:20+08:00","id":"g193","result":"done","commit":"e64097f","summary":"阴影字面值归零:新增描边族 hairline/inset-border/inset-accent 与断口 gap 四档(亮暗各一套),5 处「用阴影画线」改走令牌,shadow-literal 0、总计数 18→13"}
|
||||
{"ts":"2026-10-26T17:57:11+08:00","id":"g194","result":"done","commit":"1476a40","summary":"audit:visual --json 增加 hits 明细(每类 line/prop/value/hint,空类目给空数组),棘轮报红时可直接定位到行;人读输出与退出码不变"}
|
||||
{"ts":"2026-10-26T17:58:16+08:00","id":"g195","result":"done","commit":"8a0a907","summary":"内距字面值归零:新增 space-6(24px) 与 space-indent(22px),13 处微距就近收敛,11 类硬编码合计 0 并写入零基线;负向自检确认任何新增字面值直接报红"}
|
||||
{"ts": "2026-10-26T18:10:33+08:00", "id": "g196", "result": "done", "commit": "9ef8637", "summary": "中性色阶地基:tokens 新增 surface-2/3/inset、text-secondary/faint、overlay/strong 9 令牌(亮暗双向),ui.css 遮罩 5 处 rgba 归档 + 虚拟列表私写分割线回归 border-subtle + 凹陷散点 3 处归档"}
|
||||
{"ts": "2026-10-26T18:10:33+08:00", "id": "g197", "result": "done", "commit": "8148317", "summary": "accent 淡底配方化:tint-accent(10%)/tint-accent-weak(5%) 亮暗各套,37 处 transparent 变体收敛(16 弱 + 21 标准),混 bg 与高浓档按规则保留"}
|
||||
{"ts": "2026-10-26T18:10:33+08:00", "id": "g198", "result": "done", "commit": "2996b87", "summary": "全控件按下态补齐:input/textarea 补 hover 边框过渡,select-trigger/menu-item/option/tab/acc-head/page-btn/calendar-day 补 :active(surface-2 深一档),9 规则 + 2 transition 补齐,:not(:disabled) 守卫"}
|
||||
{"ts": "2026-10-26T18:25:13+08:00", "id": "g199", "result": "done", "commit": "bfb9178", "summary": "质感层:新增 highlight 内高光/shadow-highlight/tracking-wide/tight 令牌(亮暗覆盖),主按钮内高光+hover 主题色投影,卡片贴地影+hover 升档边框减淡,字距 4 处全走令牌;幽灵按钮显式退出质感层(透明底白高光露线)"}
|
||||
{"ts": "2026-10-26T18:25:13+08:00", "id": "g200", "result": "done", "commit": "91aa304", "summary": "动效编排:私写缓动 3 处归零(ease-out/spring 归档),modal/drawer 内容分层入场(fast 档错峰),select/menu 弹层子项 nth-child 2..8 交错(--ua-duration-step 0.02s 派生),专块 reduced-motion 兜底"}
|
||||
{"ts": "2026-10-26T18:25:13+08:00", "id": "g201", "result": "done", "commit": "e64f72b", "summary": "焦点可达性:清点确认点击型控件已无裸 :focus 残留(38 处 focus-visible/7 处输入类保留);修「吞环」3 处——tab/option/menu-item 键焦改内嵌描边(面板滚动裁切场景)"}
|
||||
{"ts":"2026-10-26T19:03:16+08:00","id":"g202","result":"done","commit":"f332500","summary":"按下态可见性修正:option/menu-item/tab 改 tint-accent 并数值复核,四处 accent 混 bg 并入 tint-accent,字面 ease 全量换令牌"}
|
||||
{"ts":"2026-10-26T19:11:13+08:00","id":"g203","result":"done","commit":"df51478","summary":"文档站页头主题切换(亮/暗/跟随系统,localStorage 持久),demo 读 ?theme= 参数,iframe 带参重载联动暗色"}
|
||||
{"ts":"2026-10-26T19:13:53+08:00","id":"g204","result":"done","commit":"5bc854b","summary":"textarea 新增 autosize:布尔/对象(minRows/maxRows)双态,输入与信号写回都重算高度,行高从 computed style 推导零硬编码"}
|
||||
{"ts":"2026-10-26T19:15:31+08:00","id":"g205","result":"done","commit":"6ee86ac","summary":"textField 新增 password:原生打点 + 内置显示/隐藏切换按钮(复用 clear 样式),与 clearable/计数不互斥"}
|
||||
{"ts":"2026-10-26T19:16:49+08:00","id":"g206","result":"done","commit":"f763f63","summary":"rate 新增 allowClear:再点当前分(半星须精确相等)清零,aria-valuemin 同步 0/1"}
|
||||
{"ts":"2026-10-26T19:18:06+08:00","id":"g207","result":"done","commit":"4eaa177","summary":"progress 新增 format 回调:线/环形进度文本可定制(如 MB 计数),缺省行为不变,环形 aria-label 同步"}
|
||||
{"ts":"2026-10-26T19:20:36+08:00","id":"g208","result":"done","commit":"9180e2c","summary":"multiSelect 新增 clearable:右缘一键清空(复用 select 清空钮样式 + --ua-space-2 内收),清空保持焦点不误展开"}
|
||||
{"ts":"2026-10-26T19:22:56+08:00","id":"g209","result":"done","commit":"e0cf55e","summary":"演示页补齐 password/autosize/allowClear/clearable 四处现场演示,section 描述同步"}
|
||||
{"ts":"2026-10-26T19:47:23+08:00","id":"g210","result":"done","commit":"053f389","summary":"滚动条令牌化:新增 color.scrollbar / scrollbarHover 双令牌(含暗色覆盖),13 个滚动容器统一 thin + 令牌拇指、WebKit 伪元素胶囊拇指与悬停加深,audit:visual 十一类计数维持 0"}
|
||||
{"ts":"2026-10-26T19:49:12+08:00","id":"g211","result":"done","commit":"fd2e951","summary":"verify 新增 checkTokenParity:tokens.json color 键折算成 --ua-color-* 后与 tokens.css 双向查漏,并逐项比对亮/暗值;负向自检(改值、删键)均能报错点名变量"}
|
||||
{"ts":"2026-10-26T19:50:43+08:00","id":"g212","result":"done","commit":"6ccc341","summary":"tabs 激活页签自动回拉:溢出时只滚 .ua-tabs-head、就近对齐留 8px 边、已可见不动手、减弱动效直接跳位;demo 增八个页签窄容器演示"}
|
||||
{"ts":"2026-10-26T19:53:33+08:00","id":"g213","result":"done","commit":"3505449","summary":"新增 timePicker(70 个组件):时 / 分两列弹层,HH:mm 双向绑定、minuteStep 可配、激活格居中滚动、列内 ↑↓/Home/End 导航,走 Esc 栈与 flip 翻转"}
|
||||
{"ts":"2026-10-26T19:54:55+08:00","id":"g214","result":"done","commit":"a607a40","summary":"七个弹层的 7 份重复入场 keyframes 收敛为 ua-pop-drop / ua-pop-rise 两个,时长与缓动全走令牌,五个 .is-flip 弹层补方向覆盖(翻转后不再朝反方向滑入)"}
|
||||
{"ts":"2026-10-26T19:55:45+08:00","id":"g215","result":"done","commit":"0fb9198","summary":"配方 13:日期 + 时间组合预约时点,示范两段各管一段(YYYY-MM-DD / HH:mm)、拼装交给 computed、约束优先于校验"}
|
||||
{"ts":"2026-10-26T19:57:26+08:00","id":"g216","result":"done","commit":"17da561","summary":"timePicker 补 clearable:有值时右侧出现 ×(不进 Tab 序),Delete/Backspace 等价清空,清空写回空串并收起弹层"}
|
||||
{"ts":"2026-10-26T19:58:47+08:00","id":"g217","result":"done","commit":"34985ff","summary":"新增 frame 内核冒烟 14 条(跑 dist、无 DOM),顺带修掉 effect 的两个真缺陷:注销是空实现、重跑不清理旧依赖"}
|
||||
{"ts":"2026-10-26T19:59:28+08:00","id":"g218","result":"done","commit":"ed2dd15","summary":"timePicker 未选值时两列改停在此刻(分钟按步长折算并钳在列长内),打开即可就近选,不用从 00:00 往下翻"}
|
||||
{"ts":"2026-10-26T20:00:36+08:00","id":"g219","result":"done","commit":"f042c66","summary":"verify 新增重复关键帧检测(帧体剥注释归一后比对,报出两个动画名),首跑即抓出 ua-switch-spin ≡ ua-spin,开关转圈改为复用"}
|
||||
{"ts":"2026-10-26T20:02:26+08:00","id":"g221","result":"done","commit":"92bc8bb","summary":"verify 新增 checkScrollCoverage:括号配平扫出全部 overflow 容器,逐个核对是否在细条规则内(14/14 覆盖),负向自检摘掉 .ua-timepicker-col 即报错"}
|
||||
{"ts":"2026-10-26T20:03:19+08:00","id":"g222","result":"done","commit":"3067c63","summary":"timePicker 补 size 三档:高度并入顶部「控件高度三档」统一规则、组件段删掉重复高度声明,与 textField/select 同源"}
|
||||
{"ts":"2026-10-26T20:04:32+08:00","id":"g223","result":"done","commit":"126da05","summary":"timePicker 非档值(如 09:07 + 15 分钟档)改为按最近档高亮与滚动,锚点优先跟值,消除「有值却无高亮」的空弹层"}
|
||||
{"ts":"2026-10-26T20:05:13+08:00","id":"g224","result":"done","commit":"63351dc","summary":"timePicker 收起态支持 ↑↓ 按步长微调、Shift + ↑↓ 按小时微调,跨零点环形回绕,与收回合同一套 keydown"}
|
||||
{"ts":"2026-10-26T20:05:58+08:00","id":"g225","result":"done","commit":"9c783cd","summary":"时分两列补 1px 分界线 + 左侧内距,弹层与单元格定最小宽度并居中,消除「两列像一列」「弹层比触发器窄」的观感问题"}
|
||||
{"ts":"2026-10-26T20:06:34+08:00","id":"g226","result":"done","commit":"93a7c67","summary":"配方《通用约定》补三条单一来源规则:动效走 ua-pop-drop / ua-pop-rise、新滚动容器进 scrollbar 名单、颜色令牌两处双写"}
|
||||
{"ts":"2026-10-26T20:07:11+08:00","id":"g227","result":"done","commit":"87bdc03","summary":"timePicker 打开弹层即把焦点送进时列当前格(preventScroll 保住滚动位置),键盘用户不必再 Tab 找,Esc 仍归还触发器"}
|
||||
{"ts":"2026-10-26T20:07:32+08:00","id":"g220","result":"done","commit":"1ac8bae","summary":"滚动条统一规则补入 .ua-timepicker-col:脚本枚举 14 个 overflow 容器逐个核对,覆盖由 13/14 补到 14/14"}
|
||||
{"ts":"2026-10-26T20:30:30+08:00","id":"g228","result":"done","commit":"a692cab","summary":"新增 ua doctor 环境自检命令:node 版本/依赖/git 钩子/.ua 元数据秒级七项检查,任一失败退出码 1,AGENTS.md 接手流程改为先 doctor 再 check"}
|
||||
{"ts":"2026-10-26T20:41:17+08:00","id":"g229","result":"done","commit":"36c787a","summary":"混色浓度令牌化:--ua-alpha-* 六档落地,ui.css 44 处 color-mix 百分比全量换令牌并新增 audit:visual 第 12 类棘轮 mix-literal(基线 0),渐变停靠/几何百分比豁免"}
|
||||
{"ts":"2026-10-26T20:43:26+08:00","id":"g230","result":"done","commit":"0d3c166","summary":"demo 页级命令面板:Ctrl+K 输入即跳 63 个演示区块(与侧边目录同源反推),演示卡实例改 hotkey:false 示范多实例约定"}
|
||||
{"ts":"2026-10-26T20:46:13+08:00","id":"g231","result":"done","commit":"cd15c70","summary":"ua search 与 MCP search_components 覆盖配方域(标题/组件/要点三层),跨组件诉求可直达配方并带 ua recipes 指路"}
|
||||
{"ts":"2026-10-26T20:47:17+08:00","id":"g232","result":"done","commit":"61f07e4","summary":"ua doctor 升到 8 项:git user.name/user.email 未配置时 FAIL 并提示配置命令,堵住「全过却提交不了」的死角"}
|
||||
{"ts":"2026-10-26T21:17:00+08:00","id":"g233","result":"done","commit":"0eb67c4","summary":"位移量令牌化:transform 位移收敛为 --ua-translate-* 七档(10px 并入 8 档),audit:visual 新增 translate-literal 第十三类,ui.css 33 处字面量迁移后基线归零"}
|
||||
{"ts":"2026-10-26T21:23:14+08:00","id":"g234","result":"done","commit":"8740ab0","summary":"新增 pinInput 验证码输入(组件 70→71):进格/回删/移格/粘贴分发 + mask 密文,value 信号双向绑定,样式零新增视觉字面量"}
|
||||
{"ts":"2026-10-26T21:26:43+08:00","id":"g235","result":"done","commit":"5443aad","summary":"新增 copyButton 复制按钮(组件 71→72):已复制文案反馈 + execCommand 降级,text 支持函数实时取值,复用 .ua-btn 零新增样式"}
|
||||
{"ts":"2026-10-26T21:29:12+08:00","id":"g236","result":"done","commit":"d504553","summary":"新增 countdown 倒计时(组件 72→73):归零自动停表 + onFinish,句柄 start/stop/reset,缺省 mm:ss 可自定义格式"}
|
||||
{"ts":"2026-10-26T21:31:07+08:00","id":"g237","result":"done","commit":"fc8c5ad","summary":"新增 kbd 键盘按键标记(组件 73→74):键帽样式单键/组合键(+ 连接对读屏隐藏),纯令牌零字面量"}
|
||||
{"ts":"2026-10-26T21:35:34+08:00","id":"g238","result":"done","commit":"2262719","summary":"新增 highlight 关键词高亮(组件 74→75):mark 包裹命中片段,大小写不敏感全部命中,双信号响应式联动"}
|
||||
{"ts":"2026-10-26T21:38:14+08:00","id":"g239","result":"done","commit":"5333896","summary":"焦点描边令牌化:outline 三形态收敛为 --ua-focus-outline(_inset/_soft),audit 新增 outline-literal 第十四类,20 处迁移后基线归零"}
|
||||
{"ts":"2026-10-26T21:40:59+08:00","id":"g240","result":"done","commit":"5940519","summary":"button 增加 loading 态:转圈禁点 + aria-busy,布尔/信号双口径,reduced-motion 降级静态环"}
|
||||
{"ts":"2026-10-26T21:42:08+08:00","id":"g241","result":"done","commit":"69c9cfe","summary":"verify 增加演示锚点大写防护:sec- 锚点含大写直接报错,堵住锚点被正则静默吞掉的排查坑"}
|
||||
{"ts": "2026-10-26T21:53:37+08:00", "id": "g242", "result": "done", "commit": "29d2d8d", "summary": "新增 ua audit:contrast 对比度门禁(16 组文字×底色配对、亮暗分算、color-mix 求值),并修掉两条真实不达标:亮色辅助文字压凹陷底 4.39、暗色主按钮反白 3.92/强调文字压淡底 4.44"}
|
||||
{"ts": "2026-10-26T21:55:31+08:00", "id": "g243", "result": "done", "commit": "f396a55", "summary": "文档站与演示页外壳配色统一从 tokens.json 派生(新增 lib/shell-palette.mjs 单一来源),旧手写暗色 accent 漂移归零"}
|
||||
{"ts": "2026-10-26T21:57:55+08:00", "id": "g244", "result": "done", "commit": "ea51493", "summary": "新增 inputTag 标签输入(组件 76):回车/逗号成标签、chip 逐个删、空框退格删末个、失焦自动入列,max 满额禁输,样式复用输入组与标签零新增字面量"}
|
||||
{"ts": "2026-10-26T21:58:58+08:00", "id": "g245", "result": "done", "commit": "6e52cc6", "summary": "新增 prefers-contrast: more 高对比令牌(边框/弱文字加深一档,只改既有变量),audit:contrast 升为亮/暗 × 常规/高对比四套主题 16 对全验"}
|
||||
{"ts": "2026-10-26T22:00:07+08:00", "id": "g246", "result": "done", "commit": "c5238c6", "summary": "ua info 增加 --md:输出标题+参数表+用法+示例的 Markdown 片段,联合类型竖线自动转义,文本版与 --json 行为不变"}
|
||||
{"ts": "2026-10-26T22:03:38+08:00", "id": "g247", "result": "done", "commit": "42ea31d", "summary": "table-core 新增 setRows(换源保留排序/筛选/页码,按 rowKey 剔除失效选择),dataTable 的 data 支持信号数据源,冒烟 21→26 条"}
|
||||
{"ts": "2026-10-26T22:04:19+08:00", "id": "g248", "result": "done", "commit": "0715131", "summary": "新增配方 14「标签筛选条 → 表格联动」:多条件在信号层派生、给表格传信号而非数组、且或语义与空状态复用,配方 13→14 条"}
|
||||
{"ts": "2026-10-26T22:05:40+08:00", "id": "g249", "result": "done", "commit": "2c34870", "summary": "doctor 升到 11 项:usage↔dispatch 双向覆盖、check 链覆盖全部门禁步骤、CLI 清单声明命令都有实现;当场抓到 usage 漏 recipes 的真问题"}
|
||||
{"ts": "2026-10-26T22:07:53+08:00", "id": "g250", "result": "done", "commit": "b6229e2", "summary": "新增 ua audit:motion 动效兜底门禁(扫描 infinite 动画是否被 reduced-motion 关掉),当场补掉转圈与不确定态进度两处漏网,门禁链升到 11 步"}
|
||||
{"ts": "2026-10-26T22:08:59+08:00", "id": "g251", "result": "done", "commit": "4b57f11", "summary": "ua tokens 增加 --unused:按「皮肤样式表 + 令牌自身派生」算消费面,列出闲置令牌(当前 3 个:surface-3 / text-secondary / font-size-xl),只报不删"}
|
||||
{"ts": "2026-10-26T22:09:57+08:00", "id": "g252", "result": "done", "commit": "80ffee5", "summary": "MCP get_component 增加 format:md,与 ua info --md 共用渲染;tools/call 对字符串结果原样返回,Agent 直接拿到可粘贴 Markdown"}
|
||||
{"ts": "2026-10-26T22:11:44+08:00", "id": "g253", "result": "done", "commit": "d23d861", "summary": "文档站每个组件卡加「复制参数表」按钮,前端按同一口径生成 Markdown(竖线转义 + 围栏),无剪贴板权限时退回 execCommand"}
|
||||
{"ts":"2026-10-26T22:14:54+08:00","id":"g254","result":"done","commit":"43af4cf","summary":"TablePlugin 增 settle 钩子:派生后把越界页码收敛回内核 state,换源缩水不再滞留旧页码、数据恢复也不跳页;smoke 26→31 条"}
|
||||
{"ts":"2026-10-26T22:17:26+08:00","id":"g255","result":"done","commit":"48efb44","summary":"audit:motion 增加第二类必查「悬停/按压位移」;ui.css 用 ua- 类通配一条规则把全站 14 处位移在减少动效下归零(颜色/阴影过渡保留),负向自检确认报 8 处未归零"}
|
||||
{"ts":"2026-10-26T22:19:20+08:00","id":"g256","result":"done","commit":"036e044","summary":"分页条抽成 tablePager 并加每页条数下拉(select 自绘、候选自动补当前值),首末页按钮置灰;dataTable 回落到 199 行"}
|
||||
{"ts":"2026-10-26T22:20:13+08:00","id":"g257","result":"done","commit":"364ac80","summary":"分页条改 flex-wrap 折行 + gap 两值(行距紧一档),页码信息与页大小下拉用 auto 外边距分组,窄屏不再横向溢出"}
|
||||
{"ts":"2026-10-26T22:21:05+08:00","id":"g258","result":"done","commit":"3036f26","summary":"select 增加 ariaLabel(缺省不落属性),表格分页条传「每页条数」,补 manifest 参数表与步骤"}
|
||||
{"ts":"2026-10-26T22:21:54+08:00","id":"g259","result":"done","commit":"11a22f9","summary":"DataTableProps 增加 pageSizes 候选并透传到分页条(缺省 5/10/20,当前值自动补);dataTable 控制在 200 行内"}
|
||||
{"ts":"2026-10-26T22:22:38+08:00","id":"g260","result":"done","commit":"07a5275","summary":"新增配方 15:表格分页与独立分页器共用 page 信号 + pageSizes 候选,写清写回顺序与越界收敛四条要点"}
|
||||
{"ts":"2026-10-26T22:23:22+08:00","id":"g261","result":"done","commit":"047b6c5","summary":"inputTag 删除 chip 后焦点交回输入框,连续删多个标签不再每次丢焦点"}
|
||||
{"ts":"2026-10-26T22:24:07+08:00","id":"g262","result":"done","commit":"62d8107","summary":"audit:contrast 增两条非文本配对(焦点描边 × 页面底/表面,阈值 3),配对表 16→18 并同步四处文档表述"}
|
||||
{"ts":"2026-10-26T22:25:45+08:00","id":"g263","result":"done","commit":"f8bf00d","summary":"inputTag 支持多值粘贴(逗号/换行/制表符拆分后逐个入列),单值粘贴仍走默认行为;分隔符统一为顶部 SEP 常量"}
|
||||
{"ts":"2026-10-26T22:26:53+08:00","id":"g264","result":"done","commit":"fe9f1fb","summary":"数据表格新增 emptyText 自定义空状态文案(有筛选词时仍显示关键词+清除按钮),emptyRow 加可选参数不新增调用行"}
|
||||
{"ts":"2026-10-26T22:48:11+08:00","id":"g265","result":"done","commit":"71cff84","summary":"新增布局骨架组件 layout(76→77):四区一次成型 + 侧栏可折叠 + 页头吸顶"}
|
||||
{"ts":"2026-10-26T22:50:11+08:00","id":"g266","result":"done","commit":"90bd60e","summary":"新增 JSON 树查看组件 jsonViewer(77→78):按层折叠 + 类型着色 + 计数摘要"}
|
||||
{"ts":"2026-10-26T22:51:24+08:00","id":"g267","result":"done","commit":"cf44880","summary":"补 Windows 强制色高对比兜底:结构补真边框 + 选中态走 Highlight + 占位走 GrayText"}
|
||||
{"ts":"2026-10-26T22:52:36+08:00","id":"g268","result":"done","commit":"9380304","summary":"verify 增加孤儿演示反向校验:demo 里每个 section 必须被某组件 demo 认领"}
|
||||
{"ts":"2026-10-26T22:53:49+08:00","id":"g269","result":"done","commit":"368d47c","summary":"新增文本差异对比组件 diffView(78→79):剪前后缀 + LCS 对齐 + 双通道着色"}
|
||||
{"ts":"2026-10-26T22:54:56+08:00","id":"g270","result":"done","commit":"b8f90b8","summary":"新增代码块组件 codeBlock(79→80):行号不进剪贴板 + 头部标题 + 一键复制"}
|
||||
{"ts":"2026-10-26T22:56:38+08:00","id":"g271","result":"done","commit":"1680c4e","summary":"新增手写签名板组件 signaturePad(80→81):DPR 位图 + 指针跟手 + 快照保笔迹"}
|
||||
{"ts":"2026-10-26T22:58:03+08:00","id":"g272","result":"done","commit":"b6c2afb","summary":"视觉抽查清单扩到十一段(新增五类新组件抽查位),总数下限改为段数推导"}
|
||||
{"ts":"2026-10-26T22:59:07+08:00","id":"g273","result":"done","commit":"d6e4319","summary":"新增区域加载遮罩组件 loading(81→82):延迟出场 + 内容留位 + aria-busy"}
|
||||
{"ts":"2026-10-26T22:59:58+08:00","id":"g274","result":"done","commit":"b93e8b0","summary":"补三条组合配方:后台页骨架 / 表格刷新遮罩 / 变更审阅面板"}
|
||||
{"ts":"2026-10-26T23:00:45+08:00","id":"g275","result":"done","commit":"b454d5f","summary":"新增相对时间组件 relativeTime(82→83):自动刷新 + 超阈值转绝对日期 + 脏数据兜底"}
|
||||
{"ts":"2026-10-26T23:02:16+08:00","id":"g276","result":"done","commit":"eac7d4b","summary":"新增迷你趋势线组件 sparkline(83→84):归一折线 + 面积 + 末点,颜色走 currentColor"}
|
||||
{"ts":"2026-10-26T23:03:29+08:00","id":"g277","result":"done","commit":"b5576c7","summary":"新增日历热力图组件 heatmap(84→85):一周一列 + 四档浓度 + 末列对齐本周"}
|
||||
{"ts":"2026-10-26T23:04:20+08:00","id":"g278","result":"done","commit":"c36b507","summary":"热力图图例与网格尺寸同源(删掉样式表第二份宽高),抽查清单加 heatmap / sparkline 至十三段"}
|
||||
{"ts":"2026-10-26T23:05:23+08:00","id":"g279","result":"done","commit":"ef58a41","summary":"diffView 增加 ignoreWhitespace:比对键归一行内空白、展示仍用原文,统计条标注口径"}
|
||||
{"ts":"2026-10-26T23:06:22+08:00","id":"g280","result":"done","commit":"865477c","summary":"formItem 增加 visible:隐藏即清错误态并退出同步 / 异步校验"}
|
||||
{"ts":"2026-10-26T23:07:50+08:00","id":"g281","result":"done","commit":"82e69df","summary":"抽出 diffKit 纯逻辑 + 皮肤冒烟 8 条断言并入 check 链,smoke 变三段"}
|
||||
{"ts":"2026-10-26T23:08:29+08:00","id":"g282","result":"done","commit":"c98f98e","summary":"codeBlock 增加 wrap:折行时保留缩进、行号仍贴首行,缺省行为不变"}
|
||||
{"ts":"2026-10-26T23:09:41+08:00","id":"g283","result":"done","commit":"0c57015","summary":"减少动效补第三类:旋转/缩放过渡用通配 transition-property 收窄(35 处),audit:motion 增第三段"}
|
||||
{"ts":"2026-10-26T23:12:35+08:00","id":"g284","result":"done","commit":"c4384be","summary":"新增 progressRing 环形进度(SVG 双圆 dasharray 画弧 + 中心百分比 + aria-valuenow),组件数 85→86"}
|
||||
{"ts":"2026-10-26T23:14:31+08:00","id":"g285","result":"done","commit":"d1b213f","summary":"pagination.total 支持信号,新增钳制 effect:越界页码写回 page 并回调 onChange,demo 加总量筛选实例"}
|
||||
{"ts":"2026-10-26T23:16:30+08:00","id":"g286","result":"done","commit":"85812ab","summary":"新增 noticeBar 单行通知条(语义色 + 符号 + 跑马灯 + 关闭,减少动效停成静态),组件数 86→87"}
|
||||
{"ts":"2026-10-26T23:17:28+08:00","id":"g287","result":"done","commit":"f388464","summary":"noticeBar 关闭先淡出再隐藏(减少动效只淡出),窄屏改换行停跑马灯,demo 文案据实修正"}
|
||||
{"ts":"2026-10-26T23:18:19+08:00","id":"g288","result":"done","commit":"003dae3","summary":"verify 的 steps 下限 3→4 并在报错里点明四步口径,badge/divider/kbd/highlight 补真实第四条,AGENTS 两处同步"}
|
||||
{"ts":"2026-10-26T23:19:03+08:00","id":"g289","result":"done","commit":"8dc7201","summary":"tooltip 出场期间挂 scroll(捕获)/resize 监听,rAF 节流重算翻转,收起即摘"}
|
||||
{"ts":"2026-10-26T23:20:06+08:00","id":"g290","result":"done","commit":"9e72607","summary":"position.ts 新增 trackShift(捕获 scroll + resize、rAF 节流、可撤销帧),tooltip 改用它去掉内联重复,select 展开期间接上"}
|
||||
{"ts":"2026-10-26T23:20:55+08:00","id":"g291","result":"done","commit":"f88e57f","summary":"autoComplete/cascader/colorPicker/datePicker/timePicker/menu/multiSelect 七处弹层统一接 trackShift,收起即摘监听"}
|
||||
{"ts":"2026-10-26T23:21:30+08:00","id":"g292","result":"done","commit":"346d514","summary":"toast 满 4 条后最早一条改调 dismiss()(淡出后移除),与手动关闭同一条路径"}
|
||||
{"ts":"2026-10-26T23:22:14+08:00","id":"g293","result":"done","commit":"beca3d8","summary":"AGENTS 强制约定补第 7 条:浮层打开算一次 + 展开期 trackShift + 收起摘监听"}
|
||||
{"ts": "2026-10-26T23:32:40+08:00", "id": "g294", "result": "done", "commit": "3914d5d", "summary": "dataTable 加「列」面板(勾选显隐、末列锁死)+ 受控 hiddenColumns;桥接抽成 tableBridge,导出跟随可见列"}
|
||||
{"ts": "2026-10-26T23:36:02+08:00", "id": "g295", "result": "done", "commit": "3533459", "summary": "新增 ua audit:surface 逐档验表面阶梯;surface-2/3 改从 surface 派生,悬停底亮度比 1.008→1.123"}
|
||||
{"ts": "2026-10-26T23:37:21+08:00", "id": "g296", "result": "done", "commit": "2c0a760", "summary": "form 校验失败必定中止(判据改错误数),焦点三级退让到触发器/字段容器,自绘控件字段不再漏过"}
|
||||
{"ts": "2026-10-26T23:38:48+08:00", "id": "g297", "result": "done", "commit": "2a7fce7", "summary": "新增 ua loop 诊断命令(锁心跳/留痕/待办/选题建议),接力实例不必再翻 sqlite 或靠人记口径"}
|
||||
{"ts": "2026-10-26T23:39:40+08:00", "id": "g298", "result": "done", "commit": "e53161a", "summary": "死令牌归位:四处按下态改 surface-3(按下≠悬停),结果页大标题启用 font-size-xl,tokens --unused 归零"}
|
||||
{"ts": "2026-10-26T23:40:33+08:00", "id": "g299", "result": "done", "commit": "9d44199", "summary": "视觉抽查清单扩到十六段(补命令面板 / 通知条 / 环形进度),下限随之 39→48 条"}
|
||||
{"ts":"2026-10-27T00:26:00+08:00","id":"g300","result":"done","commit":"c013998","summary":"toast 悬停暂停生效:指针落到卡片本体、pause/resume 钳制剩余、键盘聚焦同口径"}
|
||||
{"ts":"2026-10-27T00:29:06+08:00","id":"g301","result":"done","commit":"ed3b863","summary":"新增 sortableList(88 组件):整行可拖 + Alt+↑↓ 键盘搬位,顺序写回信号,仅消费令牌"}
|
||||
{"ts":"2026-10-27T00:30:36+08:00","id":"g302","result":"done","commit":"9cb953e","summary":"表头 Shift+点击接上内核多列排序,标记带优先级序号"}
|
||||
{"ts":"2026-10-27T00:32:15+08:00","id":"g303","result":"done","commit":"468b177","summary":"拖拽行去掉整行透明度,改抬升投影 + 虚线边,正文对比回到 12"}
|
||||
{"ts":"2026-10-27T00:33:10+08:00","id":"g304","result":"done","commit":"0851de9","summary":"配方 19:可排序检查单,补上句柄式组件的拼装样例"}
|
||||
{"ts":"2026-10-27T00:34:25+08:00","id":"g305","result":"done","commit":"08f14ce","summary":"新增 masonry 瀑布流(89 组件):多列布局,列宽自适应或固定列数"}
|
||||
{"ts":"2026-10-27T00:35:35+08:00","id":"g306","result":"done","commit":"0ce51eb","summary":"audit:surface 增两档淡底可见度校验,暗色弱档降到 2% 即拦下"}
|
||||
{"ts":"2026-10-27T00:36:37+08:00","id":"g307","result":"done","commit":"6dfc2f8","summary":"新增 --ua-z-raised 且拖拽行用上,投影不再被后续行遮住"}
|
||||
{"ts":"2026-10-27T00:37:46+08:00","id":"g308","result":"done","commit":"f1916ef","summary":"sortableList 贴边自动滚:拖到容器上下沿按帧滚动,松手即停"}
|
||||
{"ts":"2026-10-27T00:39:08+08:00","id":"g309","result":"done","commit":"ae105b6","summary":"新增 ua iter done/add:留痕与补条目由 CLI 保证字段、时区与状态,删掉两个临时脚本"}
|
||||
{"ts":"2026-10-27T00:40:53+08:00","id":"g310","result":"done","commit":"be169b3","summary":"multiSelect 新增 selectAll:三态全选行 + 只作用于过滤结果 + 到 max 补满即停 + 键盘可达"}
|
||||
{"ts":"2026-10-27T00:41:42+08:00","id":"g311","result":"done","commit":"f794475","summary":"骨架文本高度改用字号×行高计算,与正文行盒等高,表格加载完成不再跳高"}
|
||||
{"ts":"2026-10-27T00:43:13+08:00","id":"g312","result":"done","commit":"c966550","summary":"dataTable 新增 emptyAction:真没数据时才渲染操作,有筛选词仍给清除筛选"}
|
||||
{"ts":"2026-10-27T00:43:55+08:00","id":"g313","result":"done","commit":"f873baf","summary":"禁用态置灰 + aria-disabled:光标回默认、文字退次级、悬停与按下不再反馈"}
|
||||
{"ts":"2026-10-27T00:44:38+08:00","id":"g314","result":"done","commit":"a9ffbae","summary":"清单补 sortableList / masonry 两段共 8 条,REQUIRED 16→18、下限自动推到 54"}
|
||||
{"ts":"2026-10-27T00:46:37+08:00","id":"g315","result":"done","commit":"aed5c75","summary":"dataTable 新增 renderDetail:展开列 + 凹陷底详情行,按 rowKey 记展开态,全选框同时归位进 th"}
|
||||
{"ts":"2026-10-27T00:47:18+08:00","id":"g316","result":"done","commit":"6c73315","summary":"ua loop 文本与 --json 增 nextId:自补条目前先看清下一个编号,省掉撞号 rebase"}
|
||||
{"ts":"2026-10-27T00:55:54+08:00","id":"g317","result":"done","commit":"1a77f76","summary":"全局 ::selection 统一为 accent 30% 淡底(--ua-alpha-line 派生,零新令牌),亮暗主题选区与焦点环同族"}
|
||||
{"ts":"2026-10-27T00:59:40+08:00","id":"g318","result":"done","commit":"184406f","summary":"recipes.md 新增配方 20:stepper+formItem 句柄拼分步向导,校验拦在步间、回跳不绕过校验"}
|
||||
{"ts":"2026-10-27T01:03:39+08:00","id":"g319","result":"done","commit":"dcf470c","summary":"ua info 逗号批量:--md 片段以水平线分隔,--json 多值数组单值对象保持兼容,坏 id 报序号"}
|
||||
{"ts":"2026-10-27T01:05:52+08:00","id":"g320","result":"done","commit":"7056b62","summary":"配方 21:未读数单一来源派生,静态快照组件统一 effect+replaceChildren 接信号,badge 语义交给外层 button"}
|
||||
{"ts":"2026-10-27T01:08:42+08:00","id":"g321","result":"done","commit":"0e7aca6","summary":"17 类纯交互控件 user-select:none 全局收口,ua- 类 tap-highlight 透明;内容型组件保持可复制"}
|
||||
{"ts":"2026-10-27T01:10:49+08:00","id":"g322","result":"done","commit":"6beaf74","summary":"get_component name 逗号批量(json 数组 / md 水平线分块),解析抽 resolveEntry 供 CLI 与 MCP 共用,坏 id 报序号"}
|
||||
{"ts":"2026-10-27T01:13:27+08:00","id":"g323","result":"done","commit":"f842107","summary":"renderDocs 注入 RECIPES 数据:details 折叠 + 组件 chips + 代码与要点,导航补配方锚点,搜索态隐藏"}
|
||||
{"ts":"2026-10-27T01:16:37+08:00","id":"g324","result":"done","commit":"8e358c4","summary":"setColumns 与 setRows 对称:引用未变短路,失效 sorting/filters 按 key 剔除,分页选择不动;smoke 补 6 断言"}
|
||||
{"ts":"2026-10-27T01:18:12+08:00","id":"g325","result":"done","commit":"513304f","summary":"img error 监听就地替换为首字占位,role/aria/尺寸/底色不变;manifest 补兜底步骤"}
|
||||
{"ts":"2026-10-27T01:53:59+08:00","id":"g326","result":"done","commit":"2f93dcb","summary":"同义词表 72 组 206 词,search 与 MCP 共用 fallbackBySynonym,verify 校验目标存在与一词一组"}
|
||||
{"ts":"2026-10-27T01:56:17+08:00","id":"g327","result":"done","commit":"0a5665e","summary":"StepperItem.status=error 红点感叹号 + role=img 读屏,失败不参与进度与回跳;clickable 时整条可点(点按钮/点文字各回调一次)"}
|
||||
{"ts":"2026-10-27T01:58:06+08:00","id":"g328","result":"done","commit":"2d93480","summary":"语义三色淡底令牌化 3 组亮暗分档,15 处手写混色归零;audit:surface 新增三档语义淡底(四主题 1.13–1.30 达标)"}
|
||||
{"ts":"2026-10-27T02:01:08+08:00","id":"g329","result":"done","commit":"0768a34","summary":"editable 新增(组件 89→90):静止态文字按钮、Enter 提交/Esc 取消不落值/失焦提交、validate 拦下时留编辑态标红、disabled 布尔与信号双口径"}
|
||||
{"ts":"2026-10-27T02:03:10+08:00","id":"g330","result":"done","commit":"20290f6","summary":"TableColumn.render 取代 format 决定单元格内容,排序筛选仍按原值;格内点击拦冒泡不触发 onRowClick;demo 城市列改标签渲染"}
|
||||
{"ts":"2026-10-27T02:03:47+08:00","id":"g331","result":"done","commit":"05720aa","summary":"配方 22:dataTable 列 render 包 editable,草稿信号与数据源分离,要点写清整表换源副作用与 format/render 分工"}
|
||||
{"ts":"2026-10-27T02:05:31+08:00","id":"g332","result":"done","commit":"4b37399","summary":"theme() 下沉:参数>记忆>系统三档 + media 监听,system 档不落盘;技能站删掉二十行样板改用一行调用"}
|
||||
{"ts":"2026-10-27T02:06:32+08:00","id":"g333","result":"done","commit":"925a029","summary":"verify 反向查 iter 提交必有留痕(负向自检红、回滚恢复绿),历史 341 条零违规;AGENTS 与成功串同步"}
|
||||
{"ts":"2026-10-27T02:07:38+08:00","id":"g334","result":"done","commit":"d7a8b0a","summary":"对比度配对 18→24(正文与语义色 × 三块语义淡底),抓到警告文字 4.12 不达标,亮色 warning 加深到 8a5d15(5.01);tokens 双源同步"}
|
||||
{"ts":"2026-10-27T02:09:25+08:00","id":"g335","result":"done","commit":"19d5e90","summary":"textField/textarea/inputNumber/slider 增 ariaLabel(缺省沿用通用名),区间滑块自动拼上下限;四份清单文档同步"}
|
||||
{"ts":"2026-10-27T02:10:09+08:00","id":"g336","result":"done","commit":"da09e29","summary":"全选框与行复选框补可访问名(选择第 N 行),选中行加 aria-selected;文件仍 198 行"}
|
||||
{"ts":"2026-10-27T02:13:15+08:00","id":"g337","result":"done","commit":"1d25c8a","summary":"新增可访问名门禁:verify 扫描 combobox/slider/range 控件,补齐 commandPalette 搜索框 aria-label"}
|
||||
{"ts":"2026-10-27T02:14:56+08:00","id":"g338","result":"done","commit":"28972a0","summary":"toast 容器补 aria-live=polite 兜底播报,error 级别 role=alert 抢占"}
|
||||
{"ts":"2026-10-27T02:17:29+08:00","id":"g339","result":"done","commit":"e5943aa","summary":"四方向 45° 菱形箭头继承气泡底色,全令牌零新增字面量"}
|
||||
{"ts":"2026-10-27T02:19:48+08:00","id":"g340","result":"done","commit":"21b98ad","summary":"verify 新增 toast live 检查(负向自检红/恢复绿),aria-live 与 alert 抢apost不再可回退"}
|
||||
{"ts":"2026-10-27T02:22:54+08:00","id":"g341","result":"done","commit":"5d02c81","summary":"formItem 错误区与 hint 各带唯一 id,paint 时把 aria-describedby 落到控件(报错优先、无错指向 hint、都没有则摘掉),verify 补三条断言钉住"}
|
||||
{"ts":"2026-10-27T02:24:43+08:00","id":"g342","result":"done","commit":"a2b545a","summary":"formItem 句柄新增 reset(初始值快照 + dispatch 事件同步信号 + 清错误态),FormItemProps 增 reset 钩子给自绘控件,form 重置改 doReset 逐项复位并收汇总条,verify 补 checkFormReset,demo 加重置演示"}
|
||||
{"ts":"2026-10-27T02:25:59+08:00","id":"g343","result":"done","commit":"d5e1602","summary":"ui.css 把 .ua-invalid 拆成描红常驻 + :focus-visible 危险环两条规则,verify 补 checkInvalidRing(常驻环即红、缺聚焦环也红)"}
|
||||
{"ts":"2026-10-27T02:27:40+08:00","id":"g344","result":"done","commit":"b288652","summary":"formItem 落笔派发 ua:field-validated,form 逐字段订阅实时重算错误数(quiet 防自激,0 项自动隐藏),规则求值下沉 formRules.ts(form.ts 190→176 行),verify 补 checkFormSummary"}
|
||||
{"ts":"2026-10-27T02:28:56+08:00","id":"g345","result":"done","commit":"911ff29","summary":"提交按钮接 signal loading(busy true/false),删掉手写 submitBtn.disabled 只留 loading 一条路,verify 补 checkFormBusy 三条断言并刷新成功文案"}
|
||||
{"ts":"2026-10-27T02:30:18+08:00","id":"g346","result":"done","commit":"1f292e1","summary":"ITERATION.md 新增 form 五条抽查(边框/焦点环/过渡/暗色/阴影),verify 的 REQUIRED 加 form 并把段数下限抬到 57 条,AGENTS 文案同步十九段"}
|
||||
{"ts":"2026-10-27T02:31:56+08:00","id":"g347","result":"done","commit":"9172287","summary":"ua iter done 先取当前分支,非 qa/probe 直接退出码 2 并说明后果(--force 可绕过),ITERATION.md 留痕步骤写明护栏;负向自检在 stable 上被拒"}
|
||||
{"ts":"2026-10-27T02:33:03+08:00","id":"g348","result":"done","commit":"3a46b24","summary":"只有一个原生控件时补派生 id 并让 label.for 指过去,自绘下拉与单选组改在容器/首个可聚焦元素写 aria-labelledby 指向 label id,verify 补 checkFormLabel 三条断言"}
|
||||
{"ts":"2026-10-27T02:34:06+08:00","id":"g349","result":"done","commit":"a0e4eeb","summary":"五处容器补中文可访问名(输入建议/提及建议/选项列表×2/分段选择),verify 新增 checkRoleNames 按花括号深度圈定 h(...) 入参校验 listbox 与 radiogroup 的名字"}
|
||||
{"ts":"2026-10-27T02:35:27+08:00","id":"g350","result":"done","commit":"20a98d8","summary":"DataTableProps 新增 caption 渲染为视觉隐藏 caption(零新增 CSS),manifest 文档与 demo 用户表接上,verify 新增 checkTableCaption 校验渲染与入口两处"}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "视觉漂移基线:各类硬编码视觉字面量的允许上限,任一类超出即 ua audit:visual 退出码 1;迁移后用 --baseline 重写",
|
||||
"file": "packages/skin/ui/ui.css",
|
||||
"commit": "9e85ef3",
|
||||
"generatedAt": "2026-10-26T13:36:51.222Z",
|
||||
"lines": 5241,
|
||||
"total": 0,
|
||||
"counts": {
|
||||
"font-size-literal": 0,
|
||||
"duration-literal": 0,
|
||||
"shadow-literal": 0,
|
||||
"radius-literal": 0,
|
||||
"focus-ring-variant": 0,
|
||||
"space-literal": 0,
|
||||
"control-h-literal": 0,
|
||||
"weight-literal": 0,
|
||||
"leading-literal": 0,
|
||||
"z-literal": 0,
|
||||
"border-literal": 0,
|
||||
"mix-literal": 0,
|
||||
"translate-literal": 0,
|
||||
"outline-literal": 0
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -17,6 +17,11 @@ tools/* 工程层:只在构建期读各包 ua.manifest.json / tokens.jso
|
||||
不 import 任何运行时代码
|
||||
```
|
||||
|
||||
浮层定位是皮肤层内部的横切关注点,统一走 `packages/skin/ui/src/position.ts`:
|
||||
- `flip` / `flipSide`:按视口几何判定上下(四向)翻转,只在**打开那一刻**算一次;
|
||||
- `trackShift(onShift)`:展开期间的位移跟随——捕获阶段监听 scroll(容器滚动不冒泡)与 resize,rAF 节流,返回释放函数;
|
||||
- 各弹层(tooltip / select / autoComplete / cascader / colorPicker / datePicker / timePicker / menu / multiSelect)一律「打开算一次 + 展开期间 trackShift + 收起调释放函数」,只写「打开算一次」的版本会在用户滚动后把弹层压到视口边缘。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
@@ -34,16 +39,24 @@ ui-arch-mvp/
|
||||
│ │ ├── table-core/ # 表格状态内核
|
||||
│ │ └── frame/ # 信号 + hyperscript 微框架
|
||||
│ ├── skin/ # 皮肤层(DOM 组件,可发布)
|
||||
│ │ └── ui/ # 27 个组件 + ui.css
|
||||
│ │ └── ui/ # 90 个组件 + ui.css
|
||||
│ ├── design/ # 资产层(可发布)
|
||||
│ │ └── tokens/ # --ua-* 设计令牌
|
||||
│ └── tools/ # 工程层(private,不发布)
|
||||
│ └── cli/ # ua 命令行 + MCP 注册表服务
|
||||
├── scripts/ # 工程脚本:gitea.mjs(git 推送封装)、gitea-api.mjs(Gitea REST API)
|
||||
├── ARCHITECTURE.md # 本文件:结构规范
|
||||
├── AGENTS.md # Agent 接入手册(兼项目入口文档)
|
||||
├── CODING.md # 编码规范
|
||||
├── COMMITTING.md # 提交信息规范(commit-lint 机器校验)
|
||||
├── ITERATION.md # 无人值守自我迭代协议(自动化 Agent 执行)
|
||||
├── pnpm-workspace.yaml # pnpm 工作区定义
|
||||
├── pnpm-lock.yaml # 依赖锁定(入库,保证依赖树可复现)
|
||||
├── llms-full.txt # Agent 全量文档(ua docs 生成)
|
||||
└── .ua/index.json + llms.txt # 注册表索引(ua index 生成,提交入库)
|
||||
└── .ua/ # 迭代元数据
|
||||
├── index.json # 注册表索引(ua index 生成,提交入库)
|
||||
├── backlog.json # 迭代待办清单(open/done)
|
||||
└── iterations.log # 每轮迭代留痕(lint-iterations 机器校验)
|
||||
```
|
||||
|
||||
## 包规范(每包强制)
|
||||
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
# 变更日志
|
||||
|
||||
> 由 `ua changelog` 依据 git 提交史生成,请勿手改;条目按提交时间倒序。
|
||||
|
||||
## 迭代
|
||||
|
||||
- `20a98d8` iter(g350): 数据表格补 caption 可访问名
|
||||
- `a0e4eeb` iter(g349): listbox 与 radiogroup 容器补可访问名
|
||||
- `3a46b24` iter(g348): 表单标签与控件建立 for / aria-labelledby 关联
|
||||
- `9172287` iter(g347): iter done 加分支护栏
|
||||
- `1f292e1` iter(g346): 视觉抽查清单补 form 段
|
||||
- `911ff29` iter(g345): 提交按钮补忙碌态与 aria-busy
|
||||
- `b288652` iter(g344): 错误汇总条随字段校验实时重算
|
||||
- `d5e1602` iter(g343): 校验红环改为聚焦时亮
|
||||
- `a2b545a` iter(g342): 重置按钮逐项复位字段与错误态
|
||||
- `5d02c81` iter(g341): 表单错误文案补 aria-describedby 关联
|
||||
- `21b98ad` iter(g340): verify 固化 toast live 门禁
|
||||
- `e5943aa` iter(g339): tooltip 补指向箭头
|
||||
- `28972a0` iter(g338): toast 读屏可达与抢占播报
|
||||
- `1d25c8a` iter(g337): 可访问名纳入 verify 门禁
|
||||
- `da09e29` iter(g336): 表格行选择补读屏语义
|
||||
- `19d5e90` iter(g335): 输入类组件补可访问名
|
||||
- `d7a8b0a` iter(g334): 语义淡底文字纳入对比度门禁
|
||||
- `925a029` iter(g333): 留痕门禁补反向校验
|
||||
- `4b37399` iter(g332): 主题工具下沉到组件库
|
||||
- `05720aa` iter(g331): 新增配方 22 表格行内编辑
|
||||
- `20290f6` iter(g330): 表格列支持自定义单元格渲染
|
||||
- `0768a34` iter(g329): 新增 editable 行内编辑
|
||||
- `2d93480` iter(g328): 语义三色淡底补令牌并纳入表面阶梯
|
||||
- `0a5665e` iter(g327): stepper 补失败态与整条回跳
|
||||
- `2f93dcb` iter(g326): ua search 补同义词回退
|
||||
- `513304f` iter(g325): avatar 破图自动回退文字头像,不再露浏览器碎图标
|
||||
- `8e358c4` iter(g324): table-core 补 setColumns,换列后失效排序与筛选剔除
|
||||
- `f842107` iter(g323): 文档站收录组合配方区,折叠展开即看骨架与要点
|
||||
- `6beaf74` iter(g322): MCP get_component 支持逗号批量与 CLI 解析口径合一
|
||||
- `0e7aca6` iter(g321): 交互控件防误选与移动端点按灰闪清理
|
||||
- `7056b62` iter(g320): 补配方 21 示范消息中心的角标与通知流
|
||||
- `dcf470c` iter(g319): ua info 支持逗号分隔批量查询,一次拿全多个组件
|
||||
- `184406f` iter(g318): 补配方 20 示范分步表单向导的步间校验
|
||||
- `1a77f76` iter(g317): 文字选区统一为主题色淡底,暗色下不再突兀
|
||||
- `6c73315` iter(g316): ua loop 给出下一个可用编号
|
||||
- `aed5c75` iter(g315): 表格支持行展开详情
|
||||
- `a9ffbae` iter(g314): 抽查清单补拖拽排序与瀑布流两段
|
||||
- `f873baf` iter(g313): sortableList 禁用态补视觉线索
|
||||
- `c966550` iter(g312): 表格空状态补操作插槽
|
||||
- `f794475` iter(g311): 骨架文本行高对齐正文行盒
|
||||
- `be169b3` iter(g310): multiSelect 补全选与半选
|
||||
- `ae105b6` iter(g309): 留痕流程收敛为 ua iter 子命令
|
||||
- `f1916ef` iter(g308): sortableList 拖到边缘自动滚动
|
||||
- `6dfc2f8` iter(g307): 拖拽行补层内抬升 z 令牌
|
||||
- `0ce51eb` iter(g306): 淡底浓度纳入表面审计
|
||||
- `08f14ce` iter(g305): 新增瀑布流布局 masonry
|
||||
- `0851de9` iter(g304): 配方补可排序检查单(句柄式拼装)
|
||||
- `468b177` iter(g303): 拖拽行改用抬升投影,不再压正文对比
|
||||
- `9cb953e` iter(g302): 表头接上多列排序(Shift 叠加)
|
||||
- `ed3b863` iter(g301): 新增拖拽排序列表 sortableList
|
||||
- `c013998` iter(g300): 修正 toast 悬停暂停从未生效
|
||||
- `9d44199` iter(g299): 视觉抽查清单补齐三个新组件
|
||||
- `e53161a` iter(g298): 按下态改用三阶表面并启用最大字号档
|
||||
- `2a7fce7` iter(g297): 新增迭代循环诊断命令
|
||||
- `2c0a760` iter(g296): 修正表单校验失败仍提交
|
||||
- `3533459` iter(g295): 新增表面阶梯审计并修正悬停底
|
||||
- `3914d5d` iter(g294): 数据表格支持列显隐面板
|
||||
- `beca3d8` iter(g293): 浮层定位口径写入强制约定
|
||||
- `346d514` iter(g292): toast 溢出改走退场
|
||||
- `f88e57f` iter(g291): 下拉族七处弹层接上滚动跟随
|
||||
- `9e72607` iter(g290): 抽出 trackShift 并接到下拉弹层
|
||||
- `8dc7201` iter(g289): 气泡跟随滚动重算翻转
|
||||
- `003dae3` iter(g288): 文档步骤下限提到 4 条
|
||||
- `f388464` iter(g287): 通知条关闭改为淡出退场
|
||||
- `85812ab` iter(g286): 新增 noticeBar 单行通知条组件
|
||||
- `d1b213f` iter(g285): 分页总量支持信号并钳回越界页码
|
||||
- `c4384be` iter(g284): 新增 progressRing 环形进度组件
|
||||
- `0c57015` iter(g283): 补旋转缩放过渡的减少动效兜底
|
||||
- `c98f98e` iter(g282): 代码块支持长行折行
|
||||
- `82e69df` iter(g281): 抽出差异纯逻辑并加皮肤冒烟
|
||||
- `865477c` iter(g280): 表单项支持条件显示
|
||||
- `ef58a41` iter(g279): diffView 增加忽略行内空白模式
|
||||
- `c36b507` iter(g278): 热力图图例与网格尺寸同源
|
||||
- `b5576c7` iter(g277): 新增日历热力图组件
|
||||
- `eac7d4b` iter(g276): 新增迷你趋势线组件
|
||||
- `b454d5f` iter(g275): 新增相对时间组件
|
||||
- `b93e8b0` iter(g274): 补三条新组件的组合配方
|
||||
- `d6e4319` iter(g273): 新增区域加载遮罩组件
|
||||
- `b6c2afb` iter(g272): 视觉抽查清单扩到十一个组件
|
||||
- `1680c4e` iter(g271): 新增手写签名板组件
|
||||
- `b8f90b8` iter(g270): 新增代码块组件
|
||||
- `368d47c` iter(g269): 新增文本差异对比组件
|
||||
- `9380304` iter(g268): verify 增加孤儿演示区块反向校验
|
||||
- `cf44880` iter(g267): 补 Windows 强制色高对比兜底
|
||||
- `90bd60e` iter(g266): 新增 JSON 树查看组件
|
||||
- `71cff84` iter(g265): 新增布局骨架组件 layout
|
||||
- `fe9f1fb` iter(g264): 数据表格可自定义空状态文案
|
||||
- `f8bf00d` iter(g263): 标签输入支持多值粘贴
|
||||
- `62d8107` iter(g262): 对比度门禁增加非文本配对
|
||||
- `047b6c5` iter(g261): 删除标签后焦点归还输入框
|
||||
- `07a5275` iter(g260): 补表格分页联动配方
|
||||
- `11a22f9` iter(g259): 数据表格暴露每页条数候选
|
||||
- `3036f26` iter(g258): select 增加可访问名参数
|
||||
- `364ac80` iter(g257): 分页条窄屏自动换行
|
||||
- `036e044` iter(g256): 抽出分页条并加每页条数切换
|
||||
- `48efb44` iter(g255): 减少动效下归零全站悬停位移
|
||||
- `43af4cf` iter(g254): 派生后页码越界回写内核状态
|
||||
- `d23d861` iter(g253): 文档站组件卡支持一键复制 Markdown 参数表
|
||||
- `80ffee5` iter(g252): MCP get_component 增加 format=md 直出 Markdown
|
||||
- `4b57f11` iter(g251): ua tokens 增加 --unused 反向查闲置令牌
|
||||
- `b6229e2` iter(g250): 新增动效兜底门禁,补上转圈与不确定进度的减少动效降级
|
||||
- `2c34870` iter(g249): doctor 增加命令面与门禁链自洽自检
|
||||
- `0715131` iter(g248): 补配方 14 标签筛选条联动表格
|
||||
- `42ea31d` iter(g247): 表格支持整体换数据源,dataTable 可接信号数据
|
||||
- `c5238c6` iter(g246): ua info 增加 --md,输出可直接粘贴的 Markdown 片段
|
||||
- `6e52cc6` iter(g245): 补高对比偏好令牌,对比度门禁扩到四套主题
|
||||
- `ea51493` iter(g244): 新增 inputTag 标签输入组件,回车成标签并支持满额禁输
|
||||
- `f396a55` iter(g243): 文档站与演示页外壳配色改为从令牌派生,消除暗色强调色漂移
|
||||
- `29d2d8d` iter(g242): 新增令牌对比度门禁,修掉暗色主按钮与淡底强调字不达标
|
||||
- `69c9cfe` iter(g241): verify 增加演示锚点大写防护,防大写锚点被静默吞掉误报缺段
|
||||
- `5940519` iter(g240): 按钮 loading 态,转圈禁点支持布尔与信号动态同步
|
||||
- `5333896` iter(g239): 焦点描边令牌化,outline 收敛为三档复合令牌并纳入审计第十四类
|
||||
- `2262719` iter(g238): 新增 highlight 关键词高亮,命中片段包 mark 并随信号实时联动(75 组件)
|
||||
- `fc8c5ad` iter(g237): 新增 kbd 键盘按键标记,键帽样式支持单键与组合键(74 组件)
|
||||
- `d504553` iter(g236): 新增 countdown 倒计时组件,秒级递减自动停表并带句柄控制(73 组件)
|
||||
- `5443aad` iter(g235): 新增 copyButton 复制按钮,剪贴板写入带已复制反馈与降级(72 组件)
|
||||
- `8740ab0` iter(g234): 新增 pinInput 验证码输入组件,N 格数字输入支持进格回删移格与粘贴分发(71 组件)
|
||||
- `0eb67c4` iter(g233): 位移量令牌化,transform 位移收敛为 --ua-translate-* 七档并纳入审计口径
|
||||
- `61f07e4` iter(g232): doctor 补 git 身份检查
|
||||
- `cd15c70` iter(g231): search 覆盖配方域三层
|
||||
- `0d3c166` iter(g230): demo 挂页级命令面板跳转
|
||||
- `36c787a` iter(g229): 混色浓度令牌化并入视觉棘轮
|
||||
- `a692cab` iter(g228): 新增 ua doctor 环境自检命令
|
||||
- `87bdc03` iter(g227): 打开后焦点进入时列
|
||||
- `93a7c67` iter(g226): 通用约定补单一来源规则
|
||||
- `9c783cd` iter(g225): 时分两列补分界与列宽
|
||||
- `63351dc` iter(g224): 收起态上下键微调时间
|
||||
- `126da05` iter(g223): 非档值按最近档对齐
|
||||
- `3067c63` iter(g222): 时间选择支持高度档位
|
||||
- `92bc8bb` iter(g221): verify 校验滚动容器覆盖
|
||||
- `1ac8bae` iter(g220): 时间列并入滚动条统一规则
|
||||
- `f042c66` iter(g219): verify 增加重复关键帧检测
|
||||
- `ed2dd15` iter(g218): 未选值时两列停在此刻
|
||||
- `34985ff` iter(g217): 补 frame 内核冒烟并修复 effect 注销
|
||||
- `17da561` iter(g216): 时间选择支持清空
|
||||
- `0fb9198` iter(g215): 新增日期加时间组合配方
|
||||
- `a607a40` iter(g214): 浮层入场动效收敛为统一 keyframes
|
||||
- `3505449` iter(g213): 新增 timePicker 时间选择组件
|
||||
- `6ccc341` iter(g212): 页签溢出时自动把激活页签拉回可视区
|
||||
- `fd2e951` iter(g211): verify 增加令牌双源一致性校验
|
||||
- `053f389` iter(g210): 滚动条令牌化并适配暗色主题
|
||||
- `e0cf55e` iter(g209): 演示页补齐新参数现场演示
|
||||
- `9180e2c` iter(g208): multiSelect 支持一键清空
|
||||
- `4eaa177` iter(g207): progress 支持自定义进度文本
|
||||
- `f763f63` iter(g206): rate 支持再次点击清除评分
|
||||
- `6ee86ac` iter(g205): textField 支持密码显隐切换
|
||||
- `5bc854b` iter(g204): textarea 支持内容自适应高度
|
||||
- `df51478` iter(g203): 文档站亮暗切换与 demo 主题参数联动
|
||||
- `f332500` iter(g202): 修正按下态可见性并归并 accent 混 bg 变体
|
||||
- `e64f72b` iter(g201): 补全点击型控件键盘焦点环
|
||||
- `91aa304` iter(g200): 编排浮层分层入场与弹层子项交错
|
||||
- `bfb9178` iter(g199): 新增主按钮内高光与卡片边框减负
|
||||
- `2996b87` iter(g198): 全控件按下态补齐悬停反馈闭环
|
||||
- `8148317` iter(g197): accent 淡底五档收敛为两档语义令牌
|
||||
- `9ef8637` iter(g196): 中性色阶地基补表面文字遮罩中间档令牌
|
||||
- `8a0a907` iter(g195): 内距字面值归零视觉基线清零
|
||||
- `1476a40` iter(g194): 审计 json 输出命中明细
|
||||
- `e64097f` iter(g193): 阴影字面值归零补描边族令牌
|
||||
- `68bfbdd` iter(g192): 动效时长字面值归零补循环档
|
||||
- `bb28f86` iter(g191): 焦点环变体归零补语义环令牌
|
||||
- `a67a9fe` iter(g190): 字号字面值归零补展示与缩放档
|
||||
- `b83cf59` iter(g189): 补命令面板组合配方
|
||||
- `7e47a54` iter(g188): 新增命令面板组件
|
||||
- `5908202` iter(g186): 固化视觉回归抽查清单
|
||||
- `999f3f6` iter(g185): 分割线统一改走细分边框令牌
|
||||
- `2d81907` iter(g184): 层级字面值归零
|
||||
- `fe79725` iter(g183): 字重行高令牌化
|
||||
- `9ee04d7` iter(g182): 暗色阴影与焦点环适配
|
||||
- `85f009d` iter(g181): 控件高度统一三档
|
||||
- `90ed48c` iter(g180): 内距字面值令牌化
|
||||
- `7b4adb0` iter(g179): 弹层阴影语义化归档
|
||||
- `20ff9d4` iter(g187): 新增虚拟列表组件
|
||||
- `9218933` iter(g178): 校验配方是否认出组件
|
||||
- `c6fd8b8` iter(g177): 补充配方查询子命令
|
||||
- `cc9ec02` iter(g176): 支持按参数说明检索组件
|
||||
- `cbce645` iter(g175): 确认气泡支持异步确认
|
||||
- `2316445` iter(g174): 时间线支持双侧交替
|
||||
- `ea4e781` iter(g173): 上传图片显示缩略图
|
||||
- `250601d` iter(g172): 列表命令支持 JSON 输出
|
||||
- `6a605ba` iter(g171): 刷新变更日志与配方目录
|
||||
- `a754bd6` iter(g170): 分页器支持极简模式与单页隐藏
|
||||
- `bd98fc8` iter(g169): 表单字段支持跨列
|
||||
- `879be29` iter(g168): 补配方 11 数据看板筛选区
|
||||
- `ff94429` iter(g167): 弹窗支持内置页脚与异步确认
|
||||
- `a80644c` iter(g166): 下拉选项支持分组标题
|
||||
- `804ab3b` iter(g165): 步骤条支持竖向排列
|
||||
- `b533903` iter(g164): 描述列表支持竖排与标题操作区
|
||||
|
||||
## 功能
|
||||
|
||||
- `f125369` feat(cli): 新增视觉漂移审计门禁与幂等迁移脚本
|
||||
- `5a1d5ba` feat(tokens): 扩充视觉令牌到八类并统一焦点环与动效
|
||||
|
||||
## 修复
|
||||
|
||||
- `2971f9a` fix(ui): 输入附加块撑满组高消除上下色缝
|
||||
- `106bbca` fix(ui): 收敛激活态描边、修正内层圆角溢出并替换 emoji 图标
|
||||
- `0821286` fix(ui): 归位 reduced-motion 兜底并收敛分层入场延迟
|
||||
- `83763d4` fix(ui): 输入类悬停规则排除聚焦态避免覆盖聚焦边框
|
||||
- `a3943ec` fix(cli): 视觉审计在样式表有未提交改动时归因到工作区
|
||||
|
||||
## 文档
|
||||
|
||||
- `573d3aa` docs(changelog): 收录 g265-g275 迭代记录
|
||||
- `d048717` docs(iteration): 选题规则增加域轮换与视觉欠账优先
|
||||
|
||||
## 维护
|
||||
|
||||
- `9777ccb` chore(backlog): 记录 g350 完成
|
||||
- `e7f7137` chore(backlog): 记录 g349 完成
|
||||
- `0a6beb8` chore(backlog): 记录 g348 完成
|
||||
- `840025b` chore(backlog): 记录 g347 完成
|
||||
- `0e1c3c9` chore(backlog): 记录 g346 完成
|
||||
- `046f0bf` chore(backlog): 记录 g345 完成
|
||||
- `e55097f` chore(backlog): 记录 g344 完成
|
||||
- `7ac36ad` chore(backlog): 记录 g343 完成
|
||||
- `d0ef012` chore(backlog): 记录 g342 完成
|
||||
- `b4c8d52` chore(backlog): 记录 g341 完成
|
||||
- `f6e1d30` chore(backlog): 记录 g340 完成
|
||||
- `4619c88` chore(backlog): 记录 g339 完成
|
||||
- `1bc0883` chore(backlog): 记录 g338 完成
|
||||
- `ea80319` chore(backlog): 记录 g337 完成
|
||||
- `c3791c5` chore(backlog): 记录 g336 完成
|
||||
- `74dacdc` chore(backlog): 记录 g335 完成
|
||||
- `dcf3614` chore(backlog): 记录 g334 完成
|
||||
- `0c86afd` chore(backlog): 记录 g333 完成
|
||||
- `db49d10` chore(backlog): 记录 g332 完成
|
||||
- `817d94a` chore(backlog): 修正 g332 域名为 tune
|
||||
- `3e6c325` chore(backlog): 记录 g331 完成
|
||||
- `52c3015` chore(backlog): 记录 g330 完成
|
||||
- `ffb4f6c` chore(backlog): 记录 g329 完成
|
||||
- `309cb79` chore(backlog): 记录 g328 完成
|
||||
- `04433ee` chore(backlog): 记录 g327 完成
|
||||
- `8d154da` chore(backlog): 记录 g326 完成
|
||||
- `e2c979e` chore(meta): 同步索引与速览时间戳
|
||||
- `110fc54` chore(changelog): 刷新变更日志
|
||||
- `faa167a` chore(backlog): 记录 g325 完成
|
||||
- `4359f50` chore(backlog): 记录 g324 完成
|
||||
- `766fe76` chore(backlog): 记录 g323 完成
|
||||
- `3a637c6` chore(backlog): 记录 g322 完成
|
||||
- `a76cc53` chore(backlog): 记录 g321 完成
|
||||
- `25b4d89` chore(backlog): 记录 g320 完成
|
||||
- `9cd6d14` chore(backlog): 记录 g319 完成
|
||||
- `77d1c72` chore(backlog): 记录 g318 完成
|
||||
- `c45e942` chore(backlog): 记录 g317 完成
|
||||
- `a384350` chore(changelog): 刷新变更日志(收录最近 400 条)
|
||||
- `d10d75d` chore(backlog): 记录 g316 完成
|
||||
- `0fe8a01` chore(backlog): 记录 g315 完成
|
||||
- `2750063` chore(backlog): 记录 g314 完成
|
||||
- `b62812c` chore(backlog): 记录 g313 完成
|
||||
- `2c9c0c1` chore(backlog): 记录 g312 完成
|
||||
- `79bf7d6` chore(backlog): 记录 g311 完成
|
||||
- `0c5bf36` chore(backlog): 记录 g310 完成
|
||||
- `57d3520` chore(backlog): 记录 g309 完成
|
||||
- `0be2287` chore(backlog): 记录 g308 完成
|
||||
- `8c88d6c` chore(backlog): 记录 g307 完成
|
||||
- `205a204` chore(backlog): 记录 g306 完成
|
||||
- `94c7818` chore(backlog): 记录 g305 完成
|
||||
- `adcd0f7` chore(backlog): 记录 g304 完成
|
||||
- `b24da59` chore(backlog): 记录 g303 完成
|
||||
- `d8e759e` chore(backlog): 记录 g302 完成
|
||||
- `ddd6f93` chore(backlog): 记录 g301 完成
|
||||
- `b9c592a` chore(backlog): 记录 g300 完成
|
||||
- `a8ff91c` chore(changelog): 刷新变更日志至 400 提交
|
||||
- `23d3ce7` chore(backlog): 记录 g299 完成
|
||||
- `2c1fc46` chore(backlog): 记录 g298 完成
|
||||
- `a2ba3d2` chore(backlog): 记录 g297 完成
|
||||
- `308a7ee` chore(backlog): 记录 g296 完成
|
||||
- `8dd31bd` chore(backlog): 记录 g295 完成
|
||||
- `df40eb0` chore(backlog): 记录 g294 完成
|
||||
- `abba348` chore(backlog): 修正 g293 留痕指向
|
||||
- `d2829ba` chore(backlog): 记录 g293 完成
|
||||
- `77c4ed8` chore(agents): 浮层定位口径写入强制约定
|
||||
- `94d222f` chore(backlog): 记录 g292 完成
|
||||
- `e0de7df` chore(backlog): 记录 g291 完成
|
||||
- `a093c07` chore(backlog): 记录 g290 完成
|
||||
- `da166af` chore(backlog): 记录 g289 完成
|
||||
- `d6105d9` chore(backlog): 记录 g288 完成
|
||||
- `e109a9a` chore(backlog): 记录 g287 完成
|
||||
- `09cccb2` chore(backlog): 记录 g286 完成
|
||||
- `c4cf0af` chore(backlog): 记录 g285 完成
|
||||
- `56ad087` chore(backlog): 记录 g284 完成
|
||||
- `f20f673` chore(backlog): 记录 g283 完成
|
||||
- `d0e408f` chore(backlog): 记录 g282 完成
|
||||
- `cdf68b4` chore(backlog): 记录 g281 完成
|
||||
- `65c8faa` chore(backlog): 记录 g280 完成
|
||||
- `802dbab` chore(backlog): 记录 g279 完成
|
||||
- `242624c` chore(backlog): 记录 g278 完成
|
||||
- `414c61a` chore(backlog): 记录 g277 完成
|
||||
- `b37b23c` chore(backlog): 修正 g276 备注被命令替换吞掉的一句
|
||||
- `8db2e8e` chore(backlog): 记录 g276 完成
|
||||
- `72432e8` chore(backlog): 记录 g275 完成
|
||||
- `fa84a39` chore(backlog): 记录 g274 完成
|
||||
- `2b8deb5` chore(backlog): 记录 g273 完成
|
||||
- `1cb9d7a` chore(backlog): 记录 g272 完成
|
||||
- `2c08c11` chore(backlog): 记录 g271 完成
|
||||
- `6e048c1` chore(backlog): 记录 g270 完成
|
||||
- `02d7eb3` chore(backlog): 记录 g269 完成
|
||||
- `dccf79b` chore(backlog): 记录 g268 完成
|
||||
- `8fc4ec6` chore(backlog): 记录 g267 完成
|
||||
- `a7b3489` chore(backlog): 记录 g266 完成
|
||||
- `e2fb02f` chore(iterations): 修正 g265 留痕时区格式
|
||||
- `6238012` chore(backlog): 记录 g265 完成
|
||||
- `6172691` chore(backlog): 记录 g264 完成
|
||||
- `80671f8` chore(backlog): 记录 g263 完成
|
||||
- `854f425` chore(changelog): 收录 g254-g262 迭代记录
|
||||
- `2ab472c` chore(backlog): 记录 g262 完成
|
||||
- `67a9c23` chore(backlog): 记录 g261 完成
|
||||
- `5863571` chore(backlog): 记录 g260 完成
|
||||
- `79cc552` chore(backlog): 记录 g259 完成
|
||||
- `ee39b60` chore(backlog): 记录 g258 完成
|
||||
- `9a1a6f9` chore(backlog): 记录 g257 完成
|
||||
- `5771651` chore(backlog): 记录 g256 完成
|
||||
- `8e97e99` chore(backlog): 记录 g255 完成
|
||||
- `6437132` chore(backlog): 记录 g254 完成
|
||||
- `dec3a9f` chore(changelog): 刷新变更日志至当前提交
|
||||
- `ad87db7` chore(backlog): 记录 g253 完成
|
||||
- `d489a3d` chore(backlog): 记录 g252 完成
|
||||
- `eb9c606` chore(backlog): 记录 g251 完成
|
||||
- `33cb04f` chore(backlog): 记录 g250 完成
|
||||
- `cd20b0c` chore(backlog): 记录 g249 完成
|
||||
- `3873195` chore(backlog): 记录 g248 完成
|
||||
- `64289b5` chore(backlog): 记录 g247 完成
|
||||
- `19741b5` chore(backlog): 记录 g246 完成
|
||||
- `ab2bd00` chore(backlog): 记录 g245 完成
|
||||
- `26c9867` chore(backlog): 记录 g244 完成
|
||||
- `cd8a8e9` chore(backlog): 记录 g243 完成
|
||||
- `aa27a16` chore(backlog): 记录 g242 完成
|
||||
- `aa7be50` chore(backlog): 记录 g241 完成
|
||||
- `9aa71e7` chore(backlog): 记录 g240 完成
|
||||
- `1ce98bd` chore(backlog): 记录 g239 完成
|
||||
- `9e85ef3` chore(backlog): 记录 g238 完成
|
||||
- `5f150dc` chore(backlog): 记录 g237 完成
|
||||
- `55980c5` chore(backlog): 记录 g236 完成
|
||||
- `671c874` chore(backlog): 记录 g235 完成
|
||||
- `7e5e9f5` chore(backlog): 记录 g234 完成
|
||||
- `45e56d0` chore(backlog): 记录 g233 完成
|
||||
- `39f5304` chore(backlog): 修正 g232 留痕哈希
|
||||
- `be9d45e` chore(changelog): 刷新变更日志至 g232
|
||||
- `5f38d8a` chore(backlog): 记录 g232 完成
|
||||
- `94c8b86` chore(backlog): 记录 g231 完成
|
||||
- `051baf0` chore(backlog): 记录 g230 完成
|
||||
- `4a571af` chore(backlog): 记录 g229 完成
|
||||
- `7579170` chore(backlog): 记录 g228 完成
|
||||
- `b19ea5d` chore(backlog): 补记 g220 完成
|
||||
- `4b11321` chore(backlog): 记录 g227 完成
|
||||
- `11abb36` chore(backlog): 记录 g226 完成
|
||||
- `fa247fc` chore(backlog): 记录 g225 完成
|
||||
- `33a7a0b` chore(docs): 同步索引产物
|
||||
- `11ab8a1` chore(backlog): 记录 g224 完成
|
||||
- `c5826b7` chore(backlog): 记录 g223 完成
|
||||
- `063b6cb` chore(backlog): 记录 g222 完成
|
||||
- `467de41` chore(backlog): 记录 g221 完成
|
||||
- `0c978c9` chore(docs): 同步配方 13 的索引产物
|
||||
- `5c80346` chore(backlog): 记录 g219 完成
|
||||
- `5172abd` chore(backlog): 记录 g218 完成
|
||||
- `39a5d0d` chore(backlog): 记录 g217 完成
|
||||
- `d7ad799` chore(backlog): 记录 g216 完成
|
||||
- `050534a` chore(backlog): 记录 g215 完成
|
||||
- `756a7e8` chore(backlog): 记录 g214 完成
|
||||
- `1dca269` chore(backlog): 记录 g213 完成
|
||||
- `3b0888d` chore(backlog): 记录 g212 完成
|
||||
- `7fa60fb` chore(backlog): 记录 g211 完成
|
||||
- `82fa8c8` chore(backlog): 记录 g210 完成
|
||||
- `5a1d166` chore(backlog): 记录 g209 完成
|
||||
- `b30dfe3` chore(backlog): 记录 g208 完成
|
||||
- `b91c195` chore(backlog): 记录 g207 完成
|
||||
- `e1e39bb` chore(backlog): 记录 g206 完成
|
||||
- `dae6353` chore(backlog): 记录 g205 完成
|
||||
- `5e14fc0` chore(backlog): 记录 g204 完成
|
||||
- `197d57b` chore(backlog): 记录 g203 完成
|
||||
- `387f9f2` chore(backlog): 记录 g202 完成
|
||||
- `6c4796c` chore(backlog): 更正 g200/g201 留痕口径至复核定稿
|
||||
- `e4351c4` chore(backlog): 补录 g199-g201 审美提升第二批留痕
|
||||
- `ae1ff24` chore(backlog): 增补 g202 按下态可见性修正条目
|
||||
- `bc32fe9` chore(backlog): 补录 g196-g198 审美提升首批留痕
|
||||
- `fb69321` chore(backlog): 记录 g195 完成
|
||||
- `4f2ee02` chore(backlog): 记录 g194 完成
|
||||
- `0ebd6da` chore(backlog): 记录 g193 完成
|
||||
- `8fffaf5` chore(backlog): 记录 g192 完成
|
||||
- `0e996e4` chore(backlog): 记录 g191 完成
|
||||
- `654a9dd` chore(backlog): 记录 g190 完成
|
||||
- `43d35fc` chore(backlog): 记录 g189 完成
|
||||
- `529455c` chore(backlog): 记录 g188 完成
|
||||
- `276a46c` chore(backlog): 记录 g186 完成
|
||||
- `85ae90a` chore(backlog): 记录 g185 完成
|
||||
- `ecd24d5` chore(backlog): 记录 g184 完成
|
||||
- `551b2f6` chore(backlog): 记录 g183 完成
|
||||
- `089527f` chore(backlog): 记录 g182 完成
|
||||
- `03df0d1` chore(backlog): 记录 g181 完成
|
||||
- `1289b54` chore(backlog): 记录 g180 完成
|
||||
- `320dac3` chore(backlog): 记录 g179 完成
|
||||
- `a9a084c` chore(backlog): 记录 g187 完成
|
||||
- `ee6d844` chore(cli): 写入视觉迁移后的漂移基线
|
||||
- `a1e8384` chore(backlog): 记录 g178 完成
|
||||
- `77afc69` chore(backlog): 记录 g177 完成
|
||||
- `ee75881` chore(backlog): 记录 g176 完成
|
||||
- `31650b1` chore(backlog): 记录 g175 完成
|
||||
- `66c39e0` chore(backlog): 记录 g174 完成
|
||||
- `7084fe1` chore(backlog): 记录 g173 完成
|
||||
- `2c17696` chore(backlog): 记录 g172 完成
|
||||
- `a071cb0` chore(backlog): 修正 g171 留痕提交哈希
|
||||
- `dd55fee` chore(backlog): 记录 g171 完成
|
||||
- `8c2ff7b` chore(changelog): 刷新变更日志并补配方目录
|
||||
- `3804759` chore(backlog): 记录 g170 完成
|
||||
- `8c9541d` chore(backlog): 记录 g169 完成
|
||||
- `adc94ca` chore(backlog): 记录 g168 完成
|
||||
- `649ebf3` chore(backlog): 记录 g167 完成
|
||||
- `b6a6827` chore(backlog): 记录 g166 完成
|
||||
- `e1b7de6` chore(backlog): 记录 g165 完成
|
||||
- `dddbccd` chore(backlog): 记录 g164 完成
|
||||
- `bb1625a` chore(backlog): 记录 g163 完成
|
||||
@@ -66,4 +66,5 @@ let cached: RowModel | null = null; // memo:缓存产物
|
||||
## 7. 落地方式
|
||||
|
||||
- 新代码必须符合本规范;旧代码顺手迁移,不强制一次性全量重写。
|
||||
- 后续可做成 `ua style` 检查项:导出缺注释、接口字段缺注释、类型散落实现文件、闭包状态未集中顶部。
|
||||
- 已落地为 `ua style` 检查项:导出缺注释、接口字段缺注释、行注释非中文、分层包 src 内单文件超过 200 行(`types.ts` / `index.ts` 豁免)。
|
||||
- 仍靠人工守的:类型散落实现文件、闭包状态未集中顶部。
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- 应用:`demo` / `docs`
|
||||
- 元数据:`backlog` / `repo` / `protocol`
|
||||
- `iter` 提交的 scope 固定为条目 id:`b02`、`c01`、`d29` …
|
||||
- **scope 强制**:除 Merge/Revert 自动信息外,所有 type 必须带 scope;`ua commit-lint` 已机器校验,裸 type(`chore:` / `docs:`)一律拒绝
|
||||
|
||||
## subject 规则
|
||||
|
||||
|
||||
+167
-4
@@ -10,6 +10,23 @@
|
||||
- **时间预算**:单次唤起最多连续迭代约 50 分钟,到点在完成当前轮后停止(防止与下次唤起重叠)。**预算判断必须基于客观时钟**:点火时用 `date +%s` 记录起点并写入 `.ua/loop.lock`,每轮开始时重新取 `date +%s` 与起点求差,差值 ≥ 2700 秒(45 分钟)即到点;**禁止凭感觉估算时长**——LLM 无内部时钟,自估时长系统性偏高(实测自报 40+ 分钟而实际约 30 分钟)。
|
||||
- **熔断**:同一 backlog 条目连续 2 轮失败 → 停止循环,锁照常释放,等待下次唤起。
|
||||
- **重启语义**:锁心跳超过 15 分钟视为循环中断(崩溃/超时),调度器删旧锁、重新点火。
|
||||
- **诊断入口**:拿不准该不该抢锁、上一条做完没有,先跑 `ua loop`(锁心跳 / 上次留痕 / backlog 待办与视觉欠账 / 下一条选题建议与下一个可用编号一次给全);它只读不写,`--json` 给脚本用,判据与本文的 15 分钟口径同源,不要另写一套。
|
||||
- **调度器约束(踩过的坑,勿重复)**:本地调度器只支持 `HOURLY` 及以上粒度(`DAILY/WEEKLY/MONTHLY/YEARLY`),**`FREQ=MINUTELY` / `FREQ=SECONDLY` 不受支持**——写入后 `next_run_at` 会变成 `NULL`,首跑之后永久静默(实测 10/23–10/26 停摆三天)。因此「高频监护」不能靠加密 rrule 实现,只能靠**单次唤起内的连续循环**;心跳间隔 = 1 小时,循环预算 45–50 分钟,两者共同保证覆盖面。任何对自动化的改动(改 prompt、改 rrule、暂停恢复)都必须回查 `next_run_at` 非空,改 prompt 会把状态重置为 `PAUSED`,需再单独置 `ACTIVE`。
|
||||
- **有效性自检**:每轮迭代首步除同步仓库外,检查 `.ua/loop.lock` 与 `automation_runtime_state.last_run_at`;若发现上次运行距今超过 2 小时(说明调度中断),在留痕 `summary` 里注明「调度中断后恢复」,便于周报统计停摆时长。
|
||||
|
||||
## 多实例错位接力
|
||||
|
||||
单条自动化的占空比受平台语义限制:**`next_run_at` = 本次运行结束时刻 + `INTERVAL`,不是整点对齐**(实测 12:46:50 结束 → 下次 13:46:50)。`INTERVAL` 下限 1 小时,45 分钟预算下单条仅 `45/(45+60) ≈ 43%` 覆盖面,即「干 45 分、歇 60 分」。因此用**两条同构自动化错位 30 分钟**补齐空档:
|
||||
|
||||
| 实例 | 锚点(错位) | 角色 |
|
||||
|---|---|---|
|
||||
| A `无人值守迭代循环` | 整点链(当前 13:46:50 → 逐轮顺延) | 主循环 |
|
||||
| B `…B(错位接力)` | A 之后 30 分钟(当前 14:16:50) | 补位循环 |
|
||||
|
||||
- **接力靠 `missed` 队列自动完成**:到点时若另一实例仍持锁,该次被记为 `missed` 并保留 `missedScheduledAt`,待当前实例释放锁后**立即补跑**(实测间隔 9 秒)。无需人工干预,也不占用额外配置。
|
||||
- **抢锁失败是正常行为**:看到 15 分钟内新鲜锁的实例直接回复「循环存活,未干预」退出——不计失败、不写留痕、不改 backlog。
|
||||
- **同一把锁、同一份 backlog**:多实例共享 `.ua/loop.lock` 与 `qa/*` 分支流水线,任何时刻只有一个实例在写,冲突由锁而非分支解决。
|
||||
- **调整排期的方法**:改 `validFrom`(`next = max(now, validFrom) + INTERVAL`),改完必须回查 `next_run_at` 非空且落在期望时刻;改 prompt 会把状态重置为 `PAUSED`,须再单独置 `ACTIVE`。
|
||||
|
||||
## 分支流水线
|
||||
|
||||
@@ -26,12 +43,12 @@ qa/probe ──门禁通过──▶ qa/stable ──人工审阅──▶ main
|
||||
## 每轮迭代流程(自动化 Agent 执行)
|
||||
|
||||
1. **同步**:`git fetch --all --prune`,切到 `qa/stable` 并 `git pull`;从其最新提交强制重建 `qa/probe` 并切换过去。
|
||||
2. **选题**:读 `.ua/backlog.json`,取第一条 `status === "open"` 的条目;若全部关闭,按《选题规则》自行补充一条新点子后继续。
|
||||
2. **选题**:读 `.ua/backlog.json`,先取 `status === "open"` 且 `domain === "visual"` 的条目(有多条取编号最小的一条);没有视觉欠账时再按数组顺序取第一条 `status === "open"` 的条目;若全部关闭,按《选题规则》自行补充一条新点子后继续。
|
||||
3. **实现**:按 `CODING.md` 编码规范实现,保持低侵入,单文件 < 200 行,涉及组件需同步 manifest/docs/llms-full.txt。
|
||||
4. **门禁**:跑 `npm run check`(build → style → typecheck → smoke → index)。
|
||||
- **通过**:提交到 `qa/probe`(commit message 以 `iter:` 开头,注明 backlog 条目 id),推送 `qa/probe`,fast-forward 合并到 `qa/stable` 并推送;将 backlog 条目 `status` 置为 `done` 并随迭代提交。
|
||||
4. **门禁**:跑 `npm run check`(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion)。
|
||||
- **通过**:提交到 `qa/probe`,提交信息按 `COMMITTING.md` 规范(`iter(<条目id>): <中文祈使句>`,元数据变更用 `chore(backlog): 记录 <id> 完成`;commit-msg 钩子自动校验),推送 `qa/probe`,fast-forward 合并到 `qa/stable` 并推送;将 backlog 条目 `status` 置为 `done` 并随迭代提交。
|
||||
- **失败**:尝试修复一次;仍失败则放弃本轮——`qa/probe` 直接重置回 `qa/stable`,backlog 条目在 `notes` 里追加失败原因、`status` 保持 `open`,提交并推送该元数据变更(仅改 `.ua/backlog.json`,不碰代码)。
|
||||
5. **留痕**:在 `.ua/iterations.log` 追加一行 JSON,**字段统一**:`{"ts":"本地ISO时间","id":"条目id","result":"done|aborted","commit":"短哈希","summary":"一句话"}`。硬性规范:`ts` 必须来自 `date` 命令实测(本地时间,带 +08:00 偏移,**禁止手写或用 UTC 换算**);时长结论必须来自两次时间查询之差,禁止使用「大约/接近」等估算词;并发实例格式不得自创变体。随本轮提交。
|
||||
5. **留痕**:在 `.ua/iterations.log` 追加一行 JSON,**字段统一**:`{"ts":"本地ISO时间","id":"条目id","result":"done|aborted","commit":"短哈希","summary":"一句话"}`。硬性规范:`ts` 必须来自 `date` 命令实测(本地时间,带 +08:00 偏移,**禁止手写或用 UTC 换算**);时长结论必须来自两次时间查询之差,禁止使用「大约/接近」等估算词;并发实例格式不得自创变体。随本轮提交。`ua lint-iterations` 会把每行 `commit` 拿到 git 历史里回查:哈希必须真实存在、主题必须指向同一条目(`iter(<id>)`,早期无 scope 的 `iter: <id>` 也认),伪造或抄错哈希直接判违规。用 `ua iter done <id> <commit> <summary>` 一次写完(`ts` 由本机时钟生成);它只肯在 `qa/probe` 上执行,分支不对直接退出——留痕写下去之后才发现分支错了,等于把这一轮记在了别人头上(历史上多次把提交直落 `qa/stable`,probe 远端原地不动)。
|
||||
6. **边界**:不改 `main` 分支保护、不删分支、不动 `.workbuddy/`、不重写历史、不 force push 到 `qa/stable` 与 `main`。
|
||||
|
||||
## 选题规则(自主补点子时)
|
||||
@@ -40,6 +57,152 @@ qa/probe ──门禁通过──▶ qa/stable ──人工审阅──▶ main
|
||||
- 单轮改动范围可控:新增文件 + 少量既有文件修改,避免跨包大规模重构。
|
||||
- 每条点子必须写清 `title / motivation / acceptance`(验收标准,可被 `npm run check` 与人工检查验证)。
|
||||
|
||||
### 域轮换与视觉欠账优先
|
||||
|
||||
`backlog.json` 的条目带可选字段 `domain`:`new`(新增组件)/ `tune`(既有组件打磨)/ `visual`(视觉地基与观感)/ `tooling`(门禁、CLI、文档、配方)。选题按下面三条排队,保证视觉欠账会被持续消化,而不是永远排在「加新组件」后面:
|
||||
|
||||
1. **域轮换**:连续 3 轮迭代中,至少 1 轮的条目 `domain` 为 `visual`。若已连续 3 轮都不是 visual,下一轮必须从 open 的 visual 条目里选。
|
||||
2. **视觉欠账优先**:只要存在 `status === "open"` 且 `domain === "visual"` 的条目,它的优先级高于 `domain === "new"` 的条目——先清视觉欠账,再加新组件(视觉地基不牢,新组件只是把漂移再复制一份)。
|
||||
3. **新增组件的入库门槛**:`domain === "new"` 的条目,验收标准里必须写明「样式只消费 `--ua-*` 令牌,禁止新增硬编码阴影/时长/字号」,否则该条不得入库。视觉类改进的验收标准要能落到 `npm run audit:visual` 的计数上(涨了就是没做完),涉及颜色令牌的还要落到 `npm run audit:contrast`(对比度跌破阈值即没做完)。
|
||||
|
||||
## 视觉回归抽查清单(人工,视觉类条目收尾时逐条过)
|
||||
|
||||
视觉改动只能靠人眼验收,凭记忆看一遍必然漏项。本节把十三个代表组件(六个高频 + 容器 / 数据展示 / 图表类各取代表)的观感要点固化成可勾选项,视觉类条目(`domain === "visual"`)提交前照单过一遍,勾不上的就是没做完。
|
||||
|
||||
- **格式约定**:三条横线以下是清单本体,每个组件一个 `### <组件id>` 段,每条以 `- [ ] 【维度】` 开头;维度取值固定五类:`边框` / `阴影` / `过渡` / `焦点环` / `暗色`。
|
||||
- **门禁口径**:`ua verify` 校验本节存在、六个组件齐全、每个组件 ≥3 条、合计 ≥18 条、五个维度全覆盖;条目改写或删漏直接报错。
|
||||
- **与自动门禁的分工**:`npm run audit:visual` 管「字面量有没有回流成硬编码」,本节管「令牌用对了但观感对不对」——两者都过才算一轮视觉改动完成。
|
||||
|
||||
---
|
||||
|
||||
### button
|
||||
|
||||
- [ ] 【边框】次按钮与描边按钮的 1px 描边在页面底与卡片底上亮度一致,不出现某个按钮偏黑
|
||||
- [ ] 【阴影】hover 只上浮 1–2px 且走 elevation 令牌,没有「影子 + 边框」双层叠加
|
||||
- [ ] 【过渡】hover 上浮与按下回缩能看出先后,时长不超过 `--ua-duration-fast`
|
||||
- [ ] 【焦点环】键盘 Tab 到按钮时环完整贴边不被容器裁切,与描边按钮的描边不重影
|
||||
- [ ] 【暗色】暗色下主按钮文字与底色对比仍够,描边按钮的边不消失
|
||||
|
||||
### textField
|
||||
|
||||
- [ ] 【边框】默认 / hover / 聚焦三态只变色不变粗细(1px 始终 1px)
|
||||
- [ ] 【焦点环】聚焦是光环外扩而非描边变粗;带前后缀时环包住整组而不只是输入框
|
||||
- [ ] 【过渡】聚焦与失焦的边框色过渡在 `--ua-duration-fast` 内完成,无闪烁感
|
||||
- [ ] 【暗色】暗色下占位文字(placeholder)与输入文字都可读,边框仍可见
|
||||
|
||||
### select
|
||||
|
||||
- [ ] 【边框】触发器与相邻 textField 同高同粗细,并排时基线不歪
|
||||
- [ ] 【阴影】下拉弹层用 `--ua-shadow-2` 档,出现在滚动容器内时边缘不被裁成硬边
|
||||
- [ ] 【过渡】弹层进出各一次(位移 + 淡入),关闭后不残留一帧
|
||||
- [ ] 【焦点环】键盘 ↑↓ 唤出弹层时焦点环留在触发器上,不跳到 body
|
||||
|
||||
### form
|
||||
|
||||
- [ ] 【边框】校验失败只把边框描红、不常驻光环:未聚焦的失败字段与正常字段粗细一致,只有颜色不同
|
||||
- [ ] 【焦点环】失败字段获得焦点时才亮危险色光环,且不与已描红的边框重影成双线
|
||||
- [ ] 【过渡】改好一项后错误文案退场、汇总条数字跟着减,字段高度不跳(错误区为空时不留白)
|
||||
- [ ] 【暗色】暗色下错误文案与红边在表面底上都读得清,不被 danger 淡底吃掉
|
||||
- [ ] 【阴影】提交按钮转圈期间投影不叠加,loading 与 hover 同时成立也不出现双层阴影
|
||||
|
||||
### modal
|
||||
|
||||
- [ ] 【阴影】弹窗用 `--ua-shadow-4`,与遮罩形成明确层级,不糊成一片
|
||||
- [ ] 【边框】页头 / 页脚分割线用 `--ua-border-subtle`,观感与卡片页脚一致
|
||||
- [ ] 【过渡】弹入有轻微缩放位移,Esc 关闭后无残影、无布局跳动
|
||||
- [ ] 【焦点环】打开后焦点落在弹窗内首个可聚焦元素,Tab 不逃出弹窗
|
||||
- [ ] 【暗色】暗色下弹窗表面与遮罩可区分,标题与正文层级仍清楚
|
||||
|
||||
### dataTable
|
||||
|
||||
- [ ] 【边框】行分割线用 `--ua-border-subtle`,连续 20 行不显重,表头下沿不发黑
|
||||
- [ ] 【阴影】粘性表头滚动透底无效(背景实色 + 底部 1px 细线贴合)
|
||||
- [ ] 【过渡】行 hover 与选中底色过渡柔和,选中行不抢表头与首列的视线
|
||||
- [ ] 【暗色】暗色下斑马纹可辨但不突兀,选中行与 hover 行仍能区分
|
||||
|
||||
### toast
|
||||
|
||||
- [ ] 【阴影】通知用最高档投影,堆叠三条时前后层级清楚
|
||||
- [ ] 【边框】通知卡片描边不抢语义色图标,警示色不溢出到边框
|
||||
- [ ] 【过渡】进场 / 出场各一次,堆叠时其余条目平滑让位,不出现跳位
|
||||
- [ ] 【暗色】四种语义色(info / success / warning / error)在暗底上图标与文字都不糊
|
||||
|
||||
### layout
|
||||
|
||||
- [ ] 【边框】页头细线与侧栏描边各只一道,两者交汇处不出现双线加深
|
||||
- [ ] 【过渡】侧栏折叠展开展收干净,内容区不出现横向抖动或文字重排闪烁
|
||||
- [ ] 【暗色】暗色下页头、侧栏、内容区三层表面能分出层次,不糊成同一块灰
|
||||
|
||||
### jsonViewer
|
||||
|
||||
- [ ] 【边框】嵌套层级的左侧竖线与容器外框同色同粗细,深三层不显重
|
||||
- [ ] 【过渡】展开 / 收起即时切换,箭头旋转不与行高变化打架(不推动下方内容跳动)
|
||||
- [ ] 【暗色】四种叶子类型(字符串 / 数字 / 布尔 / null)在凹陷底色上都能分辨,不互相撞色
|
||||
- [ ] 【焦点环】Tab 到折叠开关时环贴住整行左缘,不被容器 overflow 裁掉
|
||||
|
||||
### diffView
|
||||
|
||||
- [ ] 【边框】统计条下沿与差异区之间只一条细线,与代码块头部观感一致
|
||||
- [ ] 【过渡】滚动长差异时行底色不渗出双行号列,行号列保持静止
|
||||
- [ ] 【暗色】增行为绿、删行为红的底色在暗底上仍可辨,文字对比不被底色吃掉
|
||||
|
||||
### codeBlock
|
||||
|
||||
- [ ] 【边框】头部与代码区之间一条细线,悬停整行提亮不出界、不盖住行号
|
||||
- [ ] 【过渡】悬停提亮瞬时完成,连续快速移动不留下残影行
|
||||
- [ ] 【暗色】行号(faint)与代码正文在凹陷底上对比都够,行号不糊到底色里
|
||||
|
||||
### signaturePad
|
||||
|
||||
- [ ] 【边框】画布与底部工具条之间一条细线,四角圆角裁切笔迹不溢出
|
||||
- [ ] 【过渡】「清空」在未落笔时变为不可点,禁用态与可用态能一眼分清
|
||||
- [ ] 【暗色】暗色下笔迹(走 text 令牌)在画布表面上清晰可见,不发灰
|
||||
|
||||
### heatmap
|
||||
|
||||
- [ ] 【边框】五个色阶块与网格格子同尺寸同圆角,图例看起来像「网格的一小段」而不是另做的一排
|
||||
- [ ] 【过渡】空窗周(零档)与有数据的列并排时,凹陷底与页面底能区分,不出现「缺了一块」的错觉
|
||||
- [ ] 【暗色】暗色下四档浓度仍能依次分辨(相邻两档不糊成一档),最高档不刺眼
|
||||
|
||||
### sparkline
|
||||
|
||||
- [ ] 【边框】折线不被 viewBox 边缘裁掉,末点圆点完整可见(内缩留白够)
|
||||
- [ ] 【过渡】数值全等时折线落在中线而不是贴顶或贴底(除零保护生效)
|
||||
- [ ] 【暗色】暗色下面积填充比折线淡一档,不压过主数值文字
|
||||
|
||||
### commandPalette
|
||||
|
||||
- [ ] 【阴影】命令面板用最高档投影 + 遮罩,与底下的内容明确分层,不糊成一块
|
||||
- [ ] 【过渡】打开时面板轻缩放位移入场,输入过滤结果切换不跳行高
|
||||
- [ ] 【焦点环】输入框始终持有焦点,↑↓ 移动只改选中行、光环留在输入框上
|
||||
- [ ] 【暗色】暗色下选中行淡色底与未选中行可辨,快捷键角标(kbd)仍读得清
|
||||
|
||||
### noticeBar
|
||||
|
||||
- [ ] 【边框】左侧语义色只出现在图标与细竖条上,卡片描边不被语义色染透
|
||||
- [ ] 【过渡】关闭走淡出退场,收起后下方内容平滑上移、不砸下来
|
||||
- [ ] 【暗色】四种语义色(info / success / warning / danger)在暗底上图标与正文都不糊
|
||||
|
||||
### progressRing
|
||||
|
||||
- [ ] 【边框】轨道环与进度环同宽同圆角端点,交接处不出现缺口或重叠
|
||||
- [ ] 【过渡】进度变化时环长平滑过渡,不确定态转圈期间中心文字不抖
|
||||
- [ ] 【暗色】暗色下轨道环仍可见(不被页面底吃掉),完成态语义色不刺眼
|
||||
- [ ] 【焦点环】可聚焦(带 aria 进度语义)时焦点环贴住整个圆环外缘,不被容器裁掉
|
||||
### sortableList
|
||||
|
||||
- [ ] 【边框】普通行 1px 描边与卡片描边同色同粗细;拖拽行换成虚线后视觉粗细不变
|
||||
- [ ] 【阴影】拖拽行用 2 档投影抬升,且压过后面的行——投影不被相邻行的底色切掉半截
|
||||
- [ ] 【过渡】悬停底色与把手转色的时长一致,放下后没有残留过渡在跑
|
||||
- [ ] 【焦点环】Tab 进来时环贴住整行、不被容器裁切;禁用态下既不显示环也不给悬停反馈
|
||||
- [ ] 【暗色】暗色下虚线 accent 边与 tint 底都能辨认,行内正文没有被淡底吃掉
|
||||
|
||||
### masonry
|
||||
|
||||
- [ ] 【边框】卡片不被列边界拆成两截,同一列上下两张的圆角都完整
|
||||
- [ ] 【过渡】窗口变宽加列时卡片按序重排,没有「半张卡片」跳到隔壁列
|
||||
- [ ] 【暗色】暗色下列间留白与卡片底能区分,横向不糊成一片
|
||||
|
||||
## 人工介入点
|
||||
|
||||
- 审阅 `qa/stable` 的提交,确认质量后合入 `main`(PR 或本地 merge 后推送)。
|
||||
|
||||
@@ -6,18 +6,27 @@
|
||||
|
||||
```bash
|
||||
pnpm install # 包间依赖用 workspace:*,npm 不支持
|
||||
npm run check # 门禁一条龙:build → style → typecheck → smoke → index → verify
|
||||
npm run check # 门禁一条龙:build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion → audit:surface
|
||||
npm run demo # 打开演示:apps/demo/dist/demo.html
|
||||
npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt
|
||||
npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移)
|
||||
```
|
||||
|
||||
## 组件(共 30 个)
|
||||
## 组件(共 90 个)
|
||||
|
||||
<!-- ua:components -->
|
||||
accordion · alert · avatar · badge · breadcrumb · button · card · checkbox · dataTable · drawer · form · inputNumber · menu · modal · pagination · popover · progress · radioGroup · select · skeleton · slider · spinner · stepper · switchButton · tabs · tag · textField · textarea · toast · tooltip
|
||||
accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · codeBlock · colorPicker · commandPalette · copyButton · countdown · dataTable · datePicker · descriptions · diffView · editable · divider · drawer · empty · floatButton · form · grid · heatmap · highlight · image · inputNumber · inputTag · jsonViewer · kbd · layout · link · list · loading · masonry · menu · modal · mentions · multiSelect · noticeBar · notification · pagination · pinInput · popover · popconfirm · progress · progressRing · radioGroup · rate · relativeTime · result · segmented · select · signaturePad · skeleton · slider · sortableList · space · sparkline · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · timePicker · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark
|
||||
<!-- /ua:components -->
|
||||
|
||||
分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / form / formItem)、反馈(alert / toast / spinner / progress / skeleton / badge)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper)、数据(dataTable / tag / avatar / card)。
|
||||
分组:表单(button / copyButton / signaturePad / textField / editable / inputNumber / inputTag / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / timePicker / pinInput / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / loading / toast / notification / noticeBar / result / spinner / progress / progressRing / skeleton / badge / empty / statistic / countdown)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / jsonViewer / diffView / codeBlock / relativeTime / sparkline / heatmap / descriptions / card / list / sortableList / kbd / highlight / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / layout / link / space / divider / splitter / backTop / affix / tour / masonry / watermark)。
|
||||
|
||||
## 可访问性约定
|
||||
|
||||
- 对比度:令牌层 18 组「前景色 × 底色」在亮 / 暗 × 常规 / 高对比四套主题下都不低于 WCAG 阈值(正文 7、常规 4.5、禁用 2.6、焦点描边等非文本 3),`npm run audit:contrast` 是这条约定的门禁;系统开启「提高对比度」时边框与弱文字自动加深一档
|
||||
- 键盘焦点:所有库内元素统一 `outline: 2px solid var(--ua-color-accent)`(focus-visible 专属,鼠标点击不出现),组件自绘的 box-shadow 光环与它叠加不冲突。
|
||||
- 关键交互都有键盘等价:Tab 进焦点、Enter/空格 激活、↑↓/Home/End 在列表类组件内移动、Esc 逐层关闭(内层先关,不连带外层)。
|
||||
- 高对比模式:除「提高对比度」偏好外,Windows 强制色(`@media (forced-colors: active)`)下也有兜底——浮层与控件改回真边框(不再靠阴影)、选中态改用系统 Highlight 高亮底、占位与骨架走 GrayText,避免高对比下「控件隐形」。
|
||||
- 语义与播报:modal/drawer 带 `role="dialog" aria-modal`、toast/notification/empty/result 带 `role="status"/"alert"`,切换类组件用 `aria-selected`/`aria-expanded` 同步状态。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,7 +34,7 @@ accordion · alert · avatar · badge · breadcrumb · button · card · checkbo
|
||||
|---|---|
|
||||
| `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) |
|
||||
| `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) |
|
||||
| `packages/skin/ui` | @ui-arch/ui:30 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
||||
| `packages/skin/ui` | @ui-arch/ui:90 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) |
|
||||
| `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) |
|
||||
| `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server |
|
||||
| `apps/demo` | 交互演示(esbuild 打包成单文件) |
|
||||
@@ -33,15 +42,15 @@ accordion · alert · avatar · badge · breadcrumb · button · card · checkbo
|
||||
|
||||
## 三道门禁
|
||||
|
||||
1. `ua style`:注释规范——导出必须有 `/*** 作用 ***/`、接口字段行尾注释、行注释用中文。
|
||||
2. `ua verify`:清单 ↔ 文档 ↔ 演示一致(id 唯一、exports 对应、props 带 n/t/d、steps ≥ 3、demo 锚点存在),皮肤样式表禁止 `#hex` 硬编码颜色。
|
||||
1. `ua style`:注释规范——导出必须有 `/*** 作用 ***/`、接口字段行尾注释、行注释用中文,且分层包 src 内单文件 ≤ 200 行(`types.ts` / `index.ts` 豁免)。
|
||||
2. `ua verify`:清单 ↔ 文档 ↔ 演示一致(id 唯一、exports 对应、props 带 n/t/d、steps ≥ 3、demo 锚点存在),皮肤样式表禁止 `#hex` 硬编码颜色,`var(--ua-*)` 必须引用 tokens.css 已定义或组件自设的变量。
|
||||
3. `ua index`:刷新 `.ua/index.json` / `llms.txt`,供 MCP 与 Agent 查询。
|
||||
|
||||
## Agent 接入
|
||||
|
||||
- 静态:`llms.txt`(速览)/ **`llms-full.txt`(全量:逐组件参数 / 回调 / 手把手 / 令牌)**
|
||||
- 命令行:`node packages/tools/cli/bin/ua.js list | info <name> | docs | verify | mcp`
|
||||
- MCP:在 `~/.workbuddy/mcp.json` 注册 `ua mcp`,可用 `list_components` / `get_component` / `search_components`
|
||||
- 命令行:`node packages/tools/cli/bin/ua.js list | search <关键词> | info <包名|组件id> | docs | tokens | verify | mcp`
|
||||
- MCP:在 `~/.workbuddy/mcp.json` 注册 `ua mcp`,可用 `list_components` / `get_component` / `search_components` / `get_tokens` / `list_recipes` / `get_recipe`
|
||||
|
||||
规范见 `AGENTS.md`(接入手册)、`ARCHITECTURE.md`(分层与依赖方向)、`CODING.md`(编码规范)、`ITERATION.md`(自动迭代协议)。
|
||||
|
||||
|
||||
+13
-3
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { shellPalette } from '../../packages/tools/cli/lib/shell-palette.mjs'; // 外壳配色与文档站同源:从 tokens.json 派生
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..', '..'); // apps/demo 上两级 = 仓库根
|
||||
@@ -21,6 +22,8 @@ const result = await esbuild.build({
|
||||
format: 'iife',
|
||||
target: 'es2020',
|
||||
charset: 'utf8', // 中文保留原文,不转义为 \uXXXX
|
||||
minifyWhitespace: true, // 保守压缩:去空白与多余括号,保留标识符,产物仍可读可调
|
||||
minifySyntax: true,
|
||||
write: false,
|
||||
legalComments: 'none',
|
||||
alias: {
|
||||
@@ -32,6 +35,9 @@ const result = await esbuild.build({
|
||||
|
||||
const js = result.outputFiles[0].text.replace(/<\/script>/gi, '<\\/script>'); // 防提前闭合
|
||||
const css = [read('packages/design/tokens/tokens.css'), read('packages/skin/ui/ui.css')].join('\n');
|
||||
// 外壳 chrome 配色从 tokens.json 派生:手写值时改暗色 accent 会让页面外壳与组件各显示一种蓝
|
||||
const tokensPath = path.join(root, 'packages/design/tokens/tokens.json');
|
||||
const shell = shellPalette(fs.existsSync(tokensPath) ? JSON.parse(fs.readFileSync(tokensPath, 'utf8')) : null);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
@@ -41,21 +47,25 @@ const html = `<!DOCTYPE html>
|
||||
<title>@ui-arch 组件库 · 现场演示</title>
|
||||
<style>
|
||||
${css}
|
||||
:root{--page-bg:#ffffff;--page-text:#1c1e21;--page-muted:#6b7280;--page-border:rgba(0,0,0,.14)}
|
||||
@media (prefers-color-scheme:dark){:root{--page-bg:#101216;--page-text:#e7e9ec;--page-muted:#888780;--page-border:rgba(255,255,255,.15)}}
|
||||
:root{--page-bg:${shell.bg};--page-text:${shell.text};--page-muted:${shell.muted};--page-border:${shell.border}}
|
||||
:root[data-theme=dark]{--page-bg:${shell.darkBg};--page-text:${shell.darkText};--page-muted:${shell.darkMuted};--page-border:${shell.darkBorder}}@media (prefers-color-scheme:dark){:root:not([data-theme]){--page-bg:${shell.darkBg};--page-text:${shell.darkText};--page-muted:${shell.darkMuted};--page-border:${shell.darkBorder}}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--page-bg);color:var(--page-text);font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
|
||||
.shell{max-width:1160px;margin:0 auto;padding:36px 20px 80px;display:grid;grid-template-columns:190px minmax(0,1fr);gap:36px}
|
||||
.page{max-width:900px;margin:0;padding:0}
|
||||
.toc{position:sticky;top:24px;align-self:start;max-height:calc(100vh - 48px);overflow:auto;font-size:13px}
|
||||
.toc-title{color:var(--page-muted);font-size:11px;letter-spacing:.08em;margin-bottom:8px}
|
||||
.toc-search{width:100%;box-sizing:border-box;margin-bottom:8px;padding:4px 8px;border:1px solid var(--ua-color-border);border-radius:6px;background:var(--ua-color-bg);color:var(--page-text);font-size:12px}
|
||||
.toc-search:focus{outline:none;border-color:var(--ua-color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--ua-color-accent) 18%,transparent)}
|
||||
.toc-link[hidden]{display:none}
|
||||
.toc-empty{color:var(--page-muted);font-size:12px;padding:4px 8px;margin:0}
|
||||
.toc-link{display:block;padding:3px 8px;border-left:2px solid transparent;border-radius:0 4px 4px 0;color:var(--page-muted);text-decoration:none;transition:color .15s,background .15s,border-color .15s}
|
||||
.toc-link:hover{color:var(--page-text);background:rgba(127,127,127,.08)}
|
||||
.toc-link.is-active{color:var(--ua-color-accent);border-left-color:var(--ua-color-accent);background:color-mix(in srgb,var(--ua-color-accent) 10%,transparent)}
|
||||
@media (max-width:820px){.shell{grid-template-columns:1fr;gap:0}.toc{display:none}}
|
||||
html{scroll-behavior:smooth}
|
||||
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
||||
header{margin-bottom:30px}
|
||||
header{margin-bottom:30px;display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
||||
h1{font-size:20px;font-weight:500;margin:0}
|
||||
.sub{color:var(--page-muted);font-size:13px;margin:8px 0 0}
|
||||
.sec{margin-bottom:40px}
|
||||
|
||||
+1591
-41
File diff suppressed because it is too large
Load Diff
+2617
-89
File diff suppressed because it is too large
Load Diff
@@ -45,39 +45,101 @@
|
||||
## @ui-arch/ui (v0.1.0)
|
||||
|
||||
- 类型: skin-dom · 目录: packages/skin/ui
|
||||
- 描述: DOM 皮肤组件库:button / textField / textarea / slider / checkbox / switch / select / menu / radioGroup / accordion / breadcrumb / card / badge / tag / avatar / alert / toast / tooltip / popover / skeleton / spinner / progress / tabs / pagination / modal / drawer / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。
|
||||
- 描述: DOM 皮肤组件库:表单 / 反馈 / 浮层 / 导航 / 数据展示,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌(组件全量见 api.exports 或刚体清单)
|
||||
- API:
|
||||
- button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用
|
||||
- textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- inputNumber(props):数字输入,−/+ 按钮 + 键盘 ↑↓ 调节,value 信号双向绑定,min/max/step 超界自动夹取
|
||||
- textarea(props):多行输入,value 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- button(props):按钮,variant 三档视觉强度,disabled / loading 支持布尔或信号动态同步(loading 转圈并禁点),onClick 点击回调
|
||||
- textField(props):文本输入,value 信号双向绑定,disabled 支持布尔或信号,onInput 输入回调;prefix/suffix 可选渲染为输入框内侧附加块,clearable 开启后有值即显一键清空按钮,maxCount 设原生上限并实时显示字数
|
||||
- inputNumber(props):数字输入,−/+ 按钮与键盘 ↑↓ 调节(长按连发步进),min/max/step 夹取,precision 小数位归一;prefix/suffix 可选渲染为整组前后附加块
|
||||
- textarea(props):多行输入,value 信号双向绑定,disabled 支持布尔或信号,rows 控行数;prefix/suffix 可选渲染为上下附加块,maxCount 显示实时字数并设原生上限
|
||||
- slider(props):滑块,value 信号双向绑定,min/max/step 可配,原生 range 承载键盘交互,右侧可显示当前值
|
||||
- checkbox(props):复选框,checked 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- switchButton(props):开关,checked 信号双向绑定,disabled 支持布尔或信号禁用
|
||||
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画,disabled 支持布尔或信号禁用
|
||||
- menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭
|
||||
- checkbox(props):复选框,checked 信号双向绑定,indeterminate 半选态(读屏播报 mixed),disabled 支持布尔或信号禁用
|
||||
- switchButton(props):开关,checked 信号双向绑定,loading 加载锁(滑块转圈 + aria-busy),disabled 支持布尔或信号禁用
|
||||
- select(props):自绘下拉选择(非原生 select),value 信号双向绑定,点外部 / Esc 关闭,选项弹层带微动画,disabled 支持布尔或信号禁用,空间不足自动翻转,键入字母快跳选项;searchable 开启弹层搜索框,clearable 有值时给清空按钮
|
||||
- menu(props):下拉菜单,触发器 + 自绘弹层,条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部 / Esc 关闭,空间不足自动翻转,键入字母快跳菜单项
|
||||
- flip(trigger, panel, preferred):浮层上下翻转判定,按触发器与面板的视口几何决定 preferred 方位放不下时是否翻转,返回实际生效方位 'top' | 'bottom'
|
||||
- radioGroup(props):单选组,options + value 信号双向绑定,disabled 整组禁用
|
||||
- accordion(props):折叠面板,标题按钮 + grid 行高过渡,单开(手风琴)或多开,支持禁用面板与 active 信号受控
|
||||
- breadcrumb(props):面包屑,nav + ol 语义结构,末项为当前页(aria-current),有 href 走原生链接、无 href 交给 onNavigate
|
||||
- card(props, ...children):卡片容器
|
||||
- badge(props):徽标,variant = default | success | warning | danger
|
||||
- tag(props):可关闭标签,variant 五档语义色,closable + onClose 支持从数据源移除(纯展示请用 badge)
|
||||
- avatar(props):头像,有 src 渲染图片(alt 用名称)、无 src 用首字占位,size 可调 + 四档语义底色
|
||||
- avatar(props):头像,有 src 渲染图片(alt 用名称)、无 src 用首字占位,src 加载失败自动回退文字头像,size 可调 + 四档语义底色
|
||||
- alert(props):提示条,variant 四档语义色 + 可选 title 与 closable 关闭按钮,role=alert 读屏可播报
|
||||
- toast.info/success/warning/error(message, duration?):消息提示工厂,右上角堆叠,默认 3s 自动消失,出入场微动画;showToast(props) 为完整入参版本(closable 显示关闭按钮,同屏最多 4 条)
|
||||
- tooltip(props, trigger):悬浮提示,包裹触发器,hover / focus 延迟 150ms 出场,移出 / 失焦 / Esc 收起
|
||||
- popover(props, trigger):可交互气泡卡片,包裹触发器,默认点击开合 + 点外部 / Esc 收起,content 可放按钮等交互元素
|
||||
- toast.info/success/warning/error(message, duration?):消息提示工厂,右上角堆叠,默认 3s 自动消失(悬停暂停、移出续时),出入场微动画;showToast(props) 为完整入参版本(closable 显示关闭按钮,同屏最多 4 条)
|
||||
- tooltip(props, trigger):包裹式悬浮提示,hover / 键盘聚焦延迟出场,Esc 收起;触摸屏按下即出、抬手延迟 1.2s 收起,气泡 pointer-events: none 不挡点击
|
||||
- popover(props, trigger):可交互气泡卡片,包裹触发器,默认点击开合 + 点外部 / Esc 收起,content 可放按钮等交互元素;视口一侧空间不足时自动翻转
|
||||
- skeleton(props):骨架屏占位,variant = text | rect | circle,text 支持 rows 多行,扫光微动画
|
||||
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,键盘可达
|
||||
- pagination(props):独立分页器,page 信号双向绑定,页码过多折叠省略号,首末页禁用翻页,jumpable 时末尾可输入页码直接跳转,键盘可达;pageSizes 可切每页条数
|
||||
- spinner(props):加载指示器,size 控制直径
|
||||
- progress(props):进度条,value 信号驱动 0-100(满格切 success 色);不传 value 退化为不确定态无限往复
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签
|
||||
- tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签;页签项 closable 时带关闭按钮(聚焦后 Delete 同样可关),关闭只回调 onClose 由调用方摘数据
|
||||
- stepper(props):步骤条,done/current/upcoming 三态,active 信号双向绑定,clickable 时已完成步骤可回跳
|
||||
- rate(props):评分星标,value 信号双向绑定,hover 预览移出还原,键盘方向键调节,max 可配,allowHalf 开启半星(左半区 0.5 分)
|
||||
- empty(props):空状态占位,title/description 可选,action 放操作区
|
||||
- divider(props):分隔线,水平/垂直两种方向,可带居中文案标签,dashed 切虚线
|
||||
- space(props):间距容器,flex gap 统一子元素间距,direction / size / align / justify / wrap / block 可调
|
||||
- modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭,size 三档宽度,footer 提供底部操作区
|
||||
- drawer(props, ...children):抽屉,open 信号驱动侧滑面板,placement 左右可配、width 可调、footer 操作区,遮罩 / × / Esc 关闭
|
||||
- form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit
|
||||
- formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄
|
||||
- dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态;loading 为真时数据行换骨架行且筛选/翻页禁用,内核为 table-core
|
||||
- image(props):图片,缩略图点击 / 回车开遮罩大图预览(Esc 或点击遮罩关闭),width 控制缩略宽,加载失败自动落占位(fallback 自定义文案)
|
||||
- timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点
|
||||
- segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用
|
||||
- descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列
|
||||
- statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值
|
||||
- list(props):列表,header/footer 外壳 + 条目 avatar/title/description/extra,onItemClick 让条目可点击,items 为空回落空状态
|
||||
- carousel(props):轮播,items 横排位移,指示点/箭头/←→ 键切换,autoplay 毫秒自动前进且悬停暂停,index 信号可外部受控
|
||||
- tree(props):树形,递归节点 + 箭头展开/收起 + 点选节点,键盘 Enter 选中、→ 展开、← 收起,禁用节点不响应
|
||||
- upload(props):文件选择,点击或拖拽入列,清单可逐个移除,onChange 回传文件数组(不内置上传请求)
|
||||
- notification(props):右下角通知卡片(标题 + 描述 + 操作区),返回 { el, close } 句柄,缺省常驻需手动关闭
|
||||
- result(props):结果页,status 四档语义色 + 标题 + 说明 + 操作区,用于提交完成/校验失败等终态
|
||||
- transfer(props):穿梭框,左右两栏勾选后整体搬运,targetKeys 信号双向绑定,两栏各自搜索与全选(含半选),禁用条目不参与搬运也不可勾选
|
||||
- cascader(props):级联选择,多列联动面板(悬停下钻 / 点击提交),value 为路径数组信号,changeOnSelect 允许停在中间层,全键盘可操作
|
||||
- splitter(props):可拖拽分栏容器,分隔条支持指针拖拽 / 方向键微调 / 双击复位,sizes 为百分比数组信号
|
||||
- autoComplete(props):输入联想,输入即过滤候选、↑↓ 环形高亮 / Enter 选中 / Esc 收起,options 可传函数做远程联想
|
||||
- datePicker(props):日期选择,单月网格 + 翻月 / 今天快捷键,value 为 YYYY-MM-DD 信号,min/max 闭区间限制,方向键逐日逐周可导航
|
||||
- backTop(props):返回顶部悬浮按钮,越过阈值才浮现,外环 conic-gradient 显示已滚比例,点击平滑回顶
|
||||
- colorPicker(props):颜色选择,预设色板(缺省语义令牌色)+ 原生取色器 + 手输 CSS 颜色串,value 为颜色串信号
|
||||
- tour(props):引导漫游,返回 { el, start, close } 句柄;聚光框按 target 选择器定位,卡片自动选方位,Esc / 跳过走 onClose
|
||||
- watermark(props, ...children):内容容器 + 平铺水印覆盖层,canvas 生成单元、颜色取自令牌,覆盖层不挡交互
|
||||
- checkboxGroup(props):多选组,value 为数组信号,min/max 用禁用边界项实现(不抛错),checkAll 支持半选且只统计未禁用项
|
||||
- affix(props, ...children):固钉,滚过 offset 后内容转 fixed 贴顶、外层保留占位高度,固定后宽度随容器自适应
|
||||
- multiSelect(props):多选下拉,标签式回显 + 可搜索弹层,点选不收起便于连续多选,max 到限后未选项禁用
|
||||
- anchor(props):锚点导航,竖排区块链接,滚动时按目标位置高亮当前区块(aria-current 同步),点击按 offset 偏移量平滑定位并回写 hash;container 可指定内滚容器
|
||||
- popconfirm(props, trigger):二次确认气泡,挂在任意触发器上,就地确认危险操作;确定后先收起再回调,取消 / 点外部 / Esc 走 onCancel,展开即聚焦「取消」
|
||||
- calendar(props):月历,整月网格内嵌页面(排期 / 值班 / 日程),每日可带 marker 标记文案,方向键漫游 + Enter 选中,翻月走 onMonthChange
|
||||
- typography(props):排版文本,variant 决定语义(标题出 h1-h5 / 段落出 p / 行内出 span),支持语义色、装饰位、对齐、多行截断与复制按钮
|
||||
- grid(props, ...children):栅格容器,columns 固定列数或 min 走 auto-fit 自适应列宽,gutter 统一列间距,窄屏自动降为单列
|
||||
- floatButton(props):悬浮操作按钮,固定停靠左下 / 右下角,label 缺省为纯图标圆形,badge 走角标,tooltip 兼作 aria-label
|
||||
- mentions(props):@提及输入,textarea 内键入 @ 弹候选(名字 / 关键词过滤),↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层,选中按光标替换并写回 value
|
||||
- treeSelect(props):树形选择,按钮触发弹层并复用 tree(搜索 / 多选 / 级联),单选选中即收起、多选保持展开,Esc 与点外收起
|
||||
- link(props):链接,站内跳转与站外跳转统一入口,external 自动补 ↗ 与 rel=noopener noreferrer 并给读屏播报
|
||||
- avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名
|
||||
- virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄
|
||||
- commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄
|
||||
- timePicker(props):时间选择,时 / 分两列弹层,value 信号双向绑定 HH:mm,分钟可配步长,Esc 收起
|
||||
- pinInput(props):验证码输入,N 格单字符数字输入,输入自动进格、退格回删、方向键移格、粘贴自动分发,value 信号双向绑定,mask 可密文显示
|
||||
- copyButton(props):复制按钮,点击把 text 写入剪贴板并短暂显示已复制,text 可传函数取实时值,clipboard API 不可用时降级 execCommand
|
||||
- countdown(props):倒计时,秒级递减到 0 自动停表并触发 onFinish,缺省 mm:ss 展示,返回句柄 start/stop/reset
|
||||
- kbd(props):键盘按键标记,键帽样式展示单个按键或组合键(多键以 + 连接),用于快捷键提示
|
||||
- highlight(props):关键词高亮,命中片段包 mark(大小写不敏感全部命中),text / keyword 支持信号响应式刷新
|
||||
- inputTag(props):标签输入,输入后回车 / 逗号成标签、chip 可单个删除、空框退格删末个,value 信号双向绑定,max 满额自动禁输
|
||||
- layout(props, ...children):布局骨架,页头 / 侧栏 / 内容 / 页脚四区一次成型,侧栏可折叠(collapsed 信号双向绑定)
|
||||
- jsonViewer(props):JSON 树查看,按层折叠、分支带计数摘要、叶子按类型着色,只读不编辑
|
||||
- diffView(props):文本差异对比,行级 LCS 对齐 + 增删着色 + 双行号列,顶部带 +N / −N 统计
|
||||
- codeBlock(props):代码块,等宽多行 + 行号列 + 标题行 + 一键复制原文,只展示不高亮
|
||||
- signaturePad(props):手写签名板,指针绘制 + DPR 位图,返回 handle(el / clear / toDataURL / isEmpty)
|
||||
- loading(props, ...children):区域加载遮罩,内容原地降透明度 + 转圈文案,带延迟出场避免快请求闪烁
|
||||
- relativeTime(props):把时间戳渲染成「3 分钟前」并自动刷新,超阈值改显示绝对日期,title 恒给精确值
|
||||
- sparkline(props):迷你趋势线,把数值序列压成折线(可填充 + 末点标记),无坐标轴无交互
|
||||
- heatmap(props):日历热力图,按周列 × 星期行铺格子、四档浓度表示当天数量,悬停看明细
|
||||
- progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘
|
||||
- noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报
|
||||
- sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄
|
||||
- masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列
|
||||
- editable(props):行内编辑,静止态是文字、点一下就地变输入框,Enter 提交 / Esc 取消 / 失焦提交
|
||||
- theme(options):主题工具(非组件),URL 参数 > 本地记忆 > 跟随系统,写 html[data-theme] 换令牌,返回 { mode, dark, set, toggle, dispose }
|
||||
- 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')`
|
||||
- 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号
|
||||
- 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调
|
||||
@@ -89,14 +151,50 @@
|
||||
- 类型: tooling · 目录: packages/tools/cli
|
||||
- 描述: ua 命令行与 MCP 注册表服务:把各包 ua.manifest.json 汇总成机器可读索引(.ua/index.json)、Agent 全量文档(llms-full.txt)与人类文档站,并提供清单一致性门禁。
|
||||
- API:
|
||||
- ua doctor:11 项环境与自洽预检(node / 依赖 / git 钩子与身份 / .ua 元数据可解析 / usage 串 ↔ dispatch 一致 / check 链覆盖全部门禁步骤 / CLI 清单声明的命令都有实现),任一失败退出码 1
|
||||
- ua index:扫描 packages/ 两层目录的 ua.manifest.json,生成 .ua/index.json 与 llms.txt
|
||||
- ua list:列出注册表内所有包与组件数
|
||||
- ua info <name>:查看单个包的清单详情(api / 约定 / 文档步骤)
|
||||
- ua list:列出注册表内所有包与组件数;--json 输出「包 + 组件 id」结构化数组,--components 逐组件展开
|
||||
- ua info <包名|组件id>:包名返回摘要,组件 id 返回 props 表 + 步骤 + 示例;--json 输出原始清单,--md 直接给可粘贴的 Markdown 片段(标题 + 参数表 + 用法 + 示例)
|
||||
- ua tokens [关键词] [--json|--unused]:打印 --ua-* 令牌清单(颜色带暗色值);--unused 反向列出皮肤包与令牌自身都没引用的令牌,用来收口令牌面
|
||||
- ua docs:生成人类文档站 apps/docs/index.html + demo.html 与 Agent 全量文档 llms-full.txt
|
||||
- ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1
|
||||
- ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)
|
||||
- ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components
|
||||
- 用法: `node packages/tools/cli/bin/ua.js <index|list|info <name>|docs|style|verify|mcp>;仓库内等价于 npm run index / docs / style / verify`
|
||||
- ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在 / 视觉回归抽查清单六组件各 ≥3 条且五维度齐全)
|
||||
- ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1
|
||||
- ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里各类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环/内距/控件高度/字重/行高/层级/边框色)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数与每类命中行号(line / prop / value / hint,Agent 可直接定位)
|
||||
- ua audit:contrast [--json|--list]:对比度门禁,把令牌里 18 组「前景色 × 底色」配对按 WCAG 公式算成对比度(求值支持 var() 与 color-mix,半透明底先压到页面底色),亮/暗 × 常规/高对比四套分算,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6 / 非文本 3)退出码 1;--json 出逐对明细,--list 只列配对表
|
||||
- ua audit:motion [--json]:动效兜底门禁,两类必查——① 带 infinite 的动画逐个核对是否在 prefers-reduced-motion 块里 animation: none;② 悬停 / 按压的位移(translate)是否在减少动效下归零(认 ua- 类通配的全局规则)。缺兜底退出码 1(--json 出逐条明细)
|
||||
- ua audit:surface [--json|--list]:表面阶梯门禁,逐档核对底色对底色的亮度比(页面底 → 表面 → 悬停底 → 按下底 ≥1.04、凹陷底 ≥1.02),外加两档半透明淡底压在表面上的可见度(选中底 ≥1.04、悬停底 ≥1.04),亮/暗 × 常规/高对比四套主题各算一遍,不达标退出码 1;对比度只管文字读不读得清,管不到「悬停有没有反馈」(实测过 1.008 的悬停底)
|
||||
- ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe;get_component 支持 format: md 直接取可粘贴的 Markdown 片段
|
||||
- ua loop [--json]:迭代循环诊断,一眼看清锁心跳(是否 15 分钟内)、上次留痕、backlog 待办与视觉欠账数,并给出本轮该不该抢锁、下一条该选谁、以及自补条目可用的下一个编号;多实例错位接力时不必翻调度器数据库;--json 里的 nextId 可直接给脚本用
|
||||
- ua iter done <id> <commit> <summary> [notes]:一轮迭代收尾——backlog 条目置 done、按固定字段顺序追加 .ua/iterations.log 一行(ts 由本机时钟生成带 +08:00,commit 校验 7-40 位哈希);ua iter add <条目json文件> 补一条自选题(id 唯一 + 五字段齐全校验)
|
||||
- 用法: `node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id[,组件id…]> [--json|--md]|docs|tokens [--json|--unused]|style|verify|commit-lint|audit:visual|audit:contrast|audit:motion|doctor|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual / audit:contrast / audit:motion / doctor`
|
||||
- 约定: 零依赖:只用 node 标准库,Node >= 18
|
||||
- 约定: 清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生
|
||||
- 约定: 门禁只增不改:新增校验必须能被人手复现,报错要指出文件与字段
|
||||
|
||||
## 组合配方(跨组件拼装)
|
||||
|
||||
> 全文见 `packages/tools/cli/recipes.md` 或 llms-full.txt;MCP 用 `list_recipes` / `get_recipe` 取骨架与要点。
|
||||
|
||||
- 配方 1:表单校验 + 提交反馈(2 条要点)
|
||||
- 配方 2:危险操作二次确认(2 条要点)
|
||||
- 配方 3:表格筛选 + 分页 + 批量操作(3 条要点)
|
||||
- 配方 4:长列表数据加载与空态(2 条要点)
|
||||
- 配方 5:数据表 + 独立分页器 + 导出(4 条要点)
|
||||
- 配方 6:长表单编辑页(吸底操作条 + 提交锁定)(4 条要点)
|
||||
- 配方 7:筛选栏(层级归属 + 日期区间)(3 条要点)
|
||||
- 配方 8:评论输入(@ 提及 + 参与人头像组)(3 条要点)
|
||||
- 配方 9:详情页排版(栅格 + 排版文本 + 描述列表)(3 条要点)
|
||||
- 配方 10:暗色主题切换(只切 data-theme,不改令牌名)(3 条要点)
|
||||
- 配方 11:数据看板筛选区(区间滑块 + 日期预设 + 分组下拉)(3 条要点)
|
||||
- 配方 12:命令面板(⌘K 全局操作入口)(6 条要点)
|
||||
- 配方 13:预约时点(日期 + 时间两段拼装)(5 条要点)
|
||||
- 配方 14:标签筛选条 → 表格联动(5 条要点)
|
||||
- 配方 15:表格分页与独立分页器联动(每页条数 + 受控页码)(4 条要点)
|
||||
- 配方 16:后台页骨架(3 条要点)
|
||||
- 配方 17:表格刷新遮罩(3 条要点)
|
||||
- 配方 18:变更审阅面板(3 条要点)
|
||||
- 配方 19:可排序的检查单(拖拽 + 键盘搬位 + 顺序回写)(5 条要点)
|
||||
- 配方 20:分步表单向导(步进条 + 按步校验 + 完成反馈)(7 条要点)
|
||||
- 配方 21:消息中心(铃铛角标 + 抽屉通知流)(6 条要点)
|
||||
- 配方 22:表格里就地改数据(editable + dataTable)(6 条要点)
|
||||
|
||||
+12
-3
@@ -7,14 +7,23 @@
|
||||
"scripts": {
|
||||
"build": "tsc -p packages/core/table-core/tsconfig.json && tsc -p packages/core/frame/tsconfig.json && tsc -p packages/skin/ui/tsconfig.json",
|
||||
"typecheck": "tsc -p packages/core/table-core/tsconfig.json --noEmit && tsc -p packages/core/frame/tsconfig.json --noEmit && tsc -p packages/skin/ui/tsconfig.json --noEmit && tsc -p apps/demo/tsconfig.json",
|
||||
"smoke": "node packages/core/table-core/scripts/smoke.mjs",
|
||||
"smoke": "node packages/core/frame/scripts/smoke.mjs && node packages/core/table-core/scripts/smoke.mjs && node packages/skin/ui/scripts/smoke.mjs",
|
||||
"demo": "node apps/demo/build.mjs",
|
||||
"style": "node packages/tools/cli/bin/ua.js style",
|
||||
"index": "node packages/tools/cli/bin/ua.js index",
|
||||
"docs": "node packages/tools/cli/bin/ua.js index && node packages/tools/cli/bin/ua.js docs",
|
||||
"check": "npm run build && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify",
|
||||
"check": "npm run build && npm run demo && npm run style && npm run typecheck && npm run smoke && npm run index && npm run verify && npm run lint-iterations && npm run audit:visual && npm run audit:contrast && npm run audit:motion && npm run audit:surface",
|
||||
"ua": "node packages/tools/cli/bin/ua.js",
|
||||
"verify": "node packages/tools/cli/bin/ua.js verify"
|
||||
"verify": "node packages/tools/cli/bin/ua.js verify",
|
||||
"lint-iterations": "node packages/tools/cli/bin/ua.js lint-iterations",
|
||||
"audit:visual": "node packages/tools/cli/bin/ua.js audit:visual",
|
||||
"visual:migrate": "node scripts/visual-migrate.mjs",
|
||||
"changelog": "node packages/tools/cli/bin/ua.js changelog",
|
||||
"changelog:check": "node packages/tools/cli/bin/ua.js changelog --check",
|
||||
"sync-counts": "node packages/tools/cli/bin/ua.js sync-counts",
|
||||
"audit:contrast": "node packages/tools/cli/bin/ua.js audit:contrast",
|
||||
"audit:motion": "node packages/tools/cli/bin/ua.js audit:motion",
|
||||
"audit:surface": "node packages/tools/cli/bin/ua.js audit:surface"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*** 作用:@ui-arch/frame 内核冒烟——信号 / 副作用 / 计算值的语义断言(纯 JS,无需 DOM;跑 npm run smoke 执行) ***/
|
||||
import { computed, effect, signal } from '../dist/signal.js';
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
/*** 断言:失败也继续跑完,最后统一给退出码(否则第一个红就看不到后面) ***/
|
||||
const ok = (label, cond) => {
|
||||
if (cond) {
|
||||
pass += 1;
|
||||
console.log(`ok - ${label}`);
|
||||
return;
|
||||
}
|
||||
fail += 1;
|
||||
console.error(`NOT OK - ${label}`);
|
||||
};
|
||||
|
||||
// —— 1. 信号的读写与同值短路 ——
|
||||
const count = signal(0);
|
||||
let hits = 0;
|
||||
effect(() => {
|
||||
count();
|
||||
hits += 1;
|
||||
});
|
||||
ok('effect 创建时立即执行一次', hits === 1);
|
||||
count(1);
|
||||
ok('写值后重跑', hits === 2);
|
||||
count(1);
|
||||
ok('写入相同值不触发(Object.is 短路)', hits === 2);
|
||||
|
||||
// —— 2. 注销后不再重跑 ——
|
||||
const live = signal('a');
|
||||
let liveHits = 0;
|
||||
const dispose = effect(() => {
|
||||
live();
|
||||
liveHits += 1;
|
||||
});
|
||||
live('b');
|
||||
ok('注销前正常重跑', liveHits === 2);
|
||||
dispose();
|
||||
live('c');
|
||||
ok('注销后写值不再重跑(dispose 必须真的摘订阅)', liveHits === 2);
|
||||
|
||||
// —— 3. 条件分支重追踪:上一轮读过、这一轮不读的信号不该再唤它 ——
|
||||
const flag = signal(true);
|
||||
const left = signal(0);
|
||||
const right = signal(0);
|
||||
let branchHits = 0;
|
||||
effect(() => {
|
||||
branchHits += 1;
|
||||
if (flag()) left();
|
||||
else right();
|
||||
});
|
||||
const afterInit = branchHits;
|
||||
flag(false);
|
||||
ok('切分支本身触发一次重跑', branchHits === afterInit + 1);
|
||||
const afterSwitch = branchHits;
|
||||
left(9);
|
||||
ok('切走后旧的依赖不再唤它(重追踪要清旧订阅)', branchHits === afterSwitch);
|
||||
right(9);
|
||||
ok('切走后新依赖仍唤它', branchHits === afterSwitch + 1);
|
||||
|
||||
// —— 4. computed:派生、链式、同值短路 ——
|
||||
const price = signal(10);
|
||||
const qty = signal(2);
|
||||
const total = computed(() => price() * qty());
|
||||
ok('computed 首次读即得派生值', total() === 20);
|
||||
price(3);
|
||||
ok('源变化后 computed 立即跟着变', total() === 6);
|
||||
let derivedHits = 0;
|
||||
effect(() => {
|
||||
total();
|
||||
derivedHits += 1;
|
||||
});
|
||||
const derivedBase = derivedHits;
|
||||
qty(3);
|
||||
ok('computed 变更会唤上层 effect(链式依赖成立)', derivedHits === derivedBase + 1);
|
||||
ok('链式计算值正确', total() === 9);
|
||||
|
||||
// —— 5. 多订阅者:扇出与互不影响 ——
|
||||
const fan = signal(0);
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
const disposeA = effect(() => {
|
||||
fan();
|
||||
a += 1;
|
||||
});
|
||||
effect(() => {
|
||||
fan();
|
||||
b += 1;
|
||||
});
|
||||
fan(1);
|
||||
ok('一次写值唤全部订阅者', a === 2 && b === 2);
|
||||
disposeA();
|
||||
fan(2);
|
||||
ok('注销一个订阅者不影响另一个', a === 2 && b === 3);
|
||||
|
||||
console.log(`\n${fail === 0 ? 'SMOKE OK' : 'SMOKE FAILED'}(${pass} 通过 / ${fail} 失败)`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -1,5 +1,7 @@
|
||||
// 当前正在收集依赖的 effect;null 表示非追踪态
|
||||
let active: (() => void) | null = null;
|
||||
// 当前 effect 已订阅到的「信号订阅集」集合:重跑前据此清旧订阅,注销时据此摘干净
|
||||
let activeDeps: Set<Set<() => void>> | null = null;
|
||||
|
||||
/*** 响应式信号:无参调用读值并收集依赖,传参写值并触发订阅 ***/
|
||||
export interface Signal<T> {
|
||||
@@ -13,7 +15,10 @@ export function signal<T>(initial: T): Signal<T> {
|
||||
const subs = new Set<() => void>();
|
||||
const read = (...args: unknown[]) => {
|
||||
if (args.length === 0) {
|
||||
if (active) subs.add(active); // 依赖收集
|
||||
if (active) {
|
||||
subs.add(active); // 依赖收集
|
||||
activeDeps?.add(subs); // 同时记下「我订阅了哪个集」,注销时才能摘干净
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const v = args[0] as T;
|
||||
@@ -24,20 +29,30 @@ export function signal<T>(initial: T): Signal<T> {
|
||||
return read as Signal<T>;
|
||||
}
|
||||
|
||||
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑 ***/
|
||||
/*** 副作用:立即执行一次并追踪内部读取的信号,依赖变化自动重跑;返回注销函数 ***/
|
||||
export function effect(fn: () => void): () => void {
|
||||
const deps = new Set<Set<() => void>>(); // 本 effect 订阅到的所有信号订阅集
|
||||
let stopped = false; // 注销标志:注销后即使还在某个订阅集里也不再执行
|
||||
const run = () => {
|
||||
if (stopped) return; // 卫语句:注销后不再跑
|
||||
for (const subs of deps) subs.delete(run); // 清上一轮订阅:条件分支换掉后的旧依赖不该再唤它
|
||||
deps.clear();
|
||||
const prev = active;
|
||||
const prevDeps = activeDeps;
|
||||
active = run;
|
||||
activeDeps = deps;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
active = prev;
|
||||
activeDeps = prevDeps;
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
/* 演示级实现:不做依赖清理 */
|
||||
stopped = true;
|
||||
for (const subs of deps) subs.delete(run); // 真摘订阅:否则卸载后的组件仍会被信号唤醒
|
||||
deps.clear();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,4 +80,172 @@ const before = table.getVisibleRows(); // memo 基线
|
||||
table.dispatch({ type: 'clearSelection' }); // 无关状态变更
|
||||
assert(table.getVisibleRows() === before, 'derive stages memoized across unrelated state changes');
|
||||
|
||||
// —— 补充不变量 ——
|
||||
const fresh = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'name', header: 'Name', sortable: true }],
|
||||
rowKey: 'id',
|
||||
plugins: [paginationPlugin()],
|
||||
initialState: { pagination: { page: 2, pageSize: 2 } },
|
||||
});
|
||||
assert(
|
||||
fresh.getPageInfo().page === 2 && fresh.getPageInfo().pageSize === 2,
|
||||
'getPageInfo 无需先取可见行即返回 initialState 生效值',
|
||||
);
|
||||
assert(fresh.getPageInfo().totalPages === 3, 'getPageInfo 的 totalPages 由上游 total 推导');
|
||||
|
||||
const marks = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }],
|
||||
rowKey: 'id',
|
||||
plugins: [filterPlugin(), paginationPlugin(), selectionPlugin()],
|
||||
initialState: { pagination: { page: 1, pageSize: 2 } },
|
||||
});
|
||||
marks.selectAllVisible(); // 选中第 1 页的 id 1 / 2
|
||||
marks.dispatch({ type: 'setFilter', key: 'city', value: 'Beijing' }); // 可见行整体换掉
|
||||
assert(
|
||||
JSON.stringify(marks.getState().selection) === JSON.stringify([1, 2]),
|
||||
'selection 以 rowKey 记账,筛选换掉可见行后仍保留',
|
||||
);
|
||||
|
||||
const toggle = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'age', header: 'Age', sortable: true }],
|
||||
rowKey: 'id',
|
||||
plugins: [sortPlugin()],
|
||||
});
|
||||
toggle.dispatch({ type: 'toggleSort', key: 'age' });
|
||||
toggle.dispatch({ type: 'toggleSort', key: 'age' });
|
||||
toggle.dispatch({ type: 'toggleSort', key: 'age' });
|
||||
assert(toggle.getState().sorting.length === 0, '同一列连点三次回到无序(asc → desc → 取消)');
|
||||
|
||||
const multi = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'city', header: 'City' }, { key: 'age', header: 'Age', sortable: true }],
|
||||
rowKey: 'id',
|
||||
plugins: [sortPlugin({ multi: true })],
|
||||
});
|
||||
multi.dispatch({ type: 'toggleSort', key: 'city', multi: true });
|
||||
multi.dispatch({ type: 'toggleSort', key: 'age', multi: true });
|
||||
assert(multi.getState().sorting.length === 2, 'multi 排序保留两列规则');
|
||||
assert(
|
||||
JSON.stringify(multi.getVisibleRows().rows.map((r) => r.id)) === JSON.stringify([4, 2, 5, 3, 1]),
|
||||
'多列排序按规则顺序生效(city 升序,同城内 age 升序:Beijing 4→2、Shanghai 5→3)',
|
||||
);
|
||||
|
||||
const sizing = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'name', header: 'Name' }],
|
||||
rowKey: 'id',
|
||||
plugins: [paginationPlugin()],
|
||||
initialState: { pagination: { page: 1, pageSize: 2 } },
|
||||
});
|
||||
sizing.dispatch({ type: 'setPage', page: 2 });
|
||||
sizing.dispatch({ type: 'setPageSize', pageSize: 3 });
|
||||
assert(sizing.getPageInfo().page === 1 && sizing.getPageInfo().pageSize === 3, '改页大小回到第 1 页');
|
||||
|
||||
sizing.dispatch({ type: 'toggleSort', key: 'name' });
|
||||
sizing.dispatch({ type: 'reset' });
|
||||
assert(
|
||||
sizing.getState().sorting.length === 0 &&
|
||||
sizing.getState().filters.length === 0 &&
|
||||
sizing.getState().pagination.page === 1 &&
|
||||
sizing.getState().pagination.pageSize === 20,
|
||||
'reset 回到默认态(排序/筛选清空,分页回 1/20)',
|
||||
);
|
||||
|
||||
// getAllRows:过筛选 + 排序但跳过分页,供导出用
|
||||
const all = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }],
|
||||
rowKey: 'id',
|
||||
plugins: [filterPlugin(), sortPlugin(), paginationPlugin()],
|
||||
initialState: { pagination: { page: 1, pageSize: 2 } },
|
||||
});
|
||||
assert(all.getVisibleRows().rows.length === 2, 'getVisibleRows 受分页约束只给当页');
|
||||
all.dispatch({ type: 'setFilter', key: 'city', value: 'Beijing' });
|
||||
assert(all.getAllRows().rows.length === 2 && all.getVisibleRows().rows.length === 2, 'getAllRows 跳过分页但与筛选一致(Beijing 2 行)');
|
||||
all.dispatch({ type: 'toggleSort', key: 'age' });
|
||||
assert(
|
||||
JSON.stringify(all.getAllRows().rows.map((r) => r.id)) === JSON.stringify([4, 2]),
|
||||
'getAllRows 仍按排序规则排(age 升序 4→2)',
|
||||
);
|
||||
assert(all.getPageInfo().totalPages === 1, 'getAllRows 不影响分页快照(2 行 / 每页 2 = 1 页)');
|
||||
|
||||
// setRows:整体换数据源,选择状态按 rowKey 剔除失效行,页码越界由插件收敛
|
||||
const swapped = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'name', header: 'Name' }],
|
||||
rowKey: 'id',
|
||||
plugins: [selectionPlugin(), paginationPlugin()],
|
||||
initialState: { pagination: { page: 1, pageSize: 2 } },
|
||||
});
|
||||
swapped.dispatch({ type: 'toggleSelect', key: 1 }); // 选中两行,后面验证失效行被剔除
|
||||
swapped.dispatch({ type: 'toggleSelect', key: 4 });
|
||||
assert(swapped.isRowSelected(1) && swapped.isRowSelected(4), 'setRows 前:两行已选');
|
||||
swapped.setRows(users.filter((u) => u.id !== 4));
|
||||
assert(swapped.getVisibleRows().total === 4, 'setRows 后:total 跟随新数据(5 行 → 4 行)');
|
||||
assert(swapped.isRowSelected(1) && !swapped.isRowSelected(4), 'setRows 后:失效行键从选择里剔除,有效行保留');
|
||||
assert(swapped.getState().pagination.page === 1, 'setRows 不动页码(用户翻页意图不被打断)');
|
||||
const sameRows = users.filter((u) => u.id !== 4);
|
||||
let notified = 0;
|
||||
swapped.subscribe(() => (notified += 1));
|
||||
swapped.setRows(sameRows);
|
||||
assert(notified === 1 && swapped.getVisibleRows().total === 4, 'setRows 换引用:通知一次并重算 total');
|
||||
|
||||
// 页码收敛:数据缩水后越界页码范围回生效页,state 与视图保持一致(不再滞留旧页码)
|
||||
const clamp = createTable({
|
||||
data: users,
|
||||
columns: [{ key: 'name', header: 'Name' }],
|
||||
rowKey: 'id',
|
||||
plugins: [paginationPlugin()],
|
||||
initialState: { pagination: { page: 3, pageSize: 2 } },
|
||||
});
|
||||
assert(clamp.getVisibleRows().rows.length === 1 && clamp.getPageInfo().page === 3, '收敛前:5 行 / 每页 2 = 3 页,停在第 3 页');
|
||||
clamp.setRows(users.slice(0, 1)); // 只剩 1 行 → 总页数 1
|
||||
assert(clamp.getPageInfo().page === 1 && clamp.getState().pagination.page === 1, '数据缩水后:越界页码回写为 1(state 与生效页一致)');
|
||||
clamp.setRows(users); // 数据恢复 5 行
|
||||
assert(clamp.getVisibleRows().rows.length === 2 && clamp.getState().pagination.page === 1, '数据恢复后:仍停第 1 页,不记忆已失效的旧页码');
|
||||
clamp.dispatch({ type: 'setPage', page: 9 }); // 显式跳到越界页
|
||||
assert(clamp.getVisibleRows().rows.length === 1 && clamp.getState().pagination.page === 3, '跳越界页:派生钳制后回写为末页 3');
|
||||
assert(clamp.getPageInfo().totalPages === 3, '收敛不改分页快照语义(totalPages 仍按 total 推算)');
|
||||
|
||||
// setColumns:动态换列,失效的排序 / 筛选按 key 剔除,accessor 跟随新列集
|
||||
const colsTable = createTable({
|
||||
data: users,
|
||||
columns: [
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'age', header: 'Age', sortable: true, accessor: (row) => row.age },
|
||||
{ key: 'city', header: 'City' },
|
||||
],
|
||||
rowKey: 'id',
|
||||
plugins: [filterPlugin(), sortPlugin(), paginationPlugin(), selectionPlugin()],
|
||||
initialState: {
|
||||
sorting: [{ key: 'age', dir: 'desc' }],
|
||||
filters: [{ key: 'city', value: 'Beijing' }],
|
||||
},
|
||||
});
|
||||
assert(colsTable.getVisibleRows().total === 2, 'setColumns 前:city 筛选生效(Beijing 2 行)');
|
||||
colsTable.setColumns([
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'score', header: 'Score', accessor: (row) => 100 - row.age }, // 换列集:city 列没了,age 列换成派生 score
|
||||
]);
|
||||
assert(
|
||||
colsTable.getState().sorting.length === 0 && colsTable.getState().filters.length === 0,
|
||||
'setColumns 后:指向不存在列的排序 / 筛选整体剔除(age 列已换成 score)',
|
||||
);
|
||||
assert(colsTable.getVisibleRows().total === 5, 'setColumns 后:失效筛选被剔除,total 回到全量 5 行');
|
||||
assert(colsTable.getColumns().length === 2 && colsTable.getColumns()[1].key === 'score', 'setColumns 后:getColumns 返回新列集');
|
||||
assert(
|
||||
colsTable.getCellValue(users[0], 'score') === 65,
|
||||
'setColumns 后:getCellValue 跟随新列 accessor(score = 100 - age)',
|
||||
);
|
||||
const sameCols = colsTable.getColumns();
|
||||
let colNotified = 0;
|
||||
colsTable.subscribe(() => {
|
||||
colNotified += 1;
|
||||
});
|
||||
colsTable.setColumns(sameCols);
|
||||
assert(colNotified === 0, 'setColumns 引用未变:短路不通知');
|
||||
|
||||
console.log('SMOKE OK');
|
||||
|
||||
@@ -13,7 +13,9 @@ import type {
|
||||
export function createTable<T>(
|
||||
options: TableOptions<T>,
|
||||
): TableInstance<T> & Record<string, unknown> {
|
||||
const { data, columns, plugins = [], onStateChange } = options;
|
||||
let data = options.data; // 可变:setRows 整体换数据源(筛选结果、轮询刷新),派生管线照旧跑
|
||||
let cols = options.columns; // 可变:setColumns 动态换列(按角色 / 视图切换列集),失效的排序 / 筛选按 key 剔除
|
||||
const { plugins = [], onStateChange } = options;
|
||||
|
||||
const rowKey = options.rowKey ?? ('id' as keyof T & string); // 默认行标识
|
||||
const getKey: (row: T) => RowKey =
|
||||
@@ -63,7 +65,7 @@ export function createTable<T>(
|
||||
|
||||
/*** 取单元格值:优先列 accessor,缺省取 row[key] ***/
|
||||
function getCellValue(row: T, key: string): unknown {
|
||||
const col = columns.find((c) => c.key === key);
|
||||
const col = cols.find((c) => c.key === key);
|
||||
if (col?.accessor) return col.accessor(row);
|
||||
return (row as Record<string, unknown>)[key];
|
||||
}
|
||||
@@ -80,21 +82,63 @@ export function createTable<T>(
|
||||
return cachedBase;
|
||||
}
|
||||
|
||||
/*** 可见行模型:依序过插件 derive(filter → sort → paginate) ***/
|
||||
function getVisibleRows(): RowModel<T> {
|
||||
/*** 可见行模型:依序过插件 derive(filter → sort → paginate);paged=false 时跳过分页阶段取全量派生行
|
||||
* 派生末尾跑一轮 settle:插件可把派生期算出的生效值回写自己的状态切片(如页码越界钳制),
|
||||
* 保证 state 与视图一致;回写只走 onStateChange、不广播订阅,避免在读取中重入。 ***/
|
||||
function getVisibleRows(paged = true): RowModel<T> {
|
||||
ctx.paged = paged; // 由插件自行判断是否裁剪,核心不认插件细节
|
||||
let model = baseModel();
|
||||
for (const p of plugins) {
|
||||
if (p.derive) model = p.derive(model, state, ctx);
|
||||
}
|
||||
ctx.paged = true; // 还原缺省,避免影响后续调用
|
||||
let settled = false;
|
||||
for (const p of plugins) {
|
||||
if (!p.settle) continue;
|
||||
const next = p.settle(state);
|
||||
if (next !== state) { state = next; settled = true; }
|
||||
}
|
||||
if (settled) onStateChange?.(state); // 状态被收敛:回调通知外部,订阅方靠本次读取结果自更新
|
||||
return model;
|
||||
}
|
||||
|
||||
/*** 整体替换数据源:引用未变或内容长度与行键全同则不动(避免无意义重算),变了就通知订阅方
|
||||
* 选择状态按 rowKey 存活:新数据里不再存在的行键会从 selection 里剔除,避免批量操作命中幽灵行。 ***/
|
||||
function setRows(next: T[]): void {
|
||||
if (next === data) return;
|
||||
data = next;
|
||||
const keys = new Set(next.map(getKey));
|
||||
const kept = state.selection.filter((key) => keys.has(key));
|
||||
if (kept.length !== state.selection.length) {
|
||||
state = { ...state, selection: kept }; // 只动选择:排序 / 筛选 / 页码保持用户原意
|
||||
}
|
||||
onStateChange?.(state);
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
/*** 整体替换列定义:引用未变则不动;换列后指向不存在列的排序 / 筛选剔除(列都没了,规则失去语义),分页 / 选择与列集无关、保持不动。 ***/
|
||||
function setColumns(next: typeof cols): void {
|
||||
if (next === cols) return;
|
||||
cols = next;
|
||||
const keys = new Set(next.map((c) => c.key));
|
||||
const sorting = state.sorting.filter((s) => keys.has(s.key));
|
||||
const filters = state.filters.filter((f) => keys.has(f.key));
|
||||
if (sorting.length !== state.sorting.length || filters.length !== state.filters.length) {
|
||||
state = { ...state, sorting, filters }; // 只动失效规则:页码 / 选择保持
|
||||
}
|
||||
onStateChange?.(state);
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
const instance: TableInstance<T> = {
|
||||
getState: () => state,
|
||||
dispatch,
|
||||
subscribe,
|
||||
setRows,
|
||||
setColumns,
|
||||
getVisibleRows,
|
||||
getColumns: () => columns,
|
||||
getAllRows: () => getVisibleRows(false), // 筛选 + 排序后、未分页的全量行(导出等场景)
|
||||
getColumns: () => cols,
|
||||
getCellValue,
|
||||
isRowSelected: (key) => state.selection.includes(key),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Action,
|
||||
DeriveContext,
|
||||
RowModel,
|
||||
TableInstance,
|
||||
TablePlugin,
|
||||
@@ -19,6 +20,7 @@ export function paginationPlugin<T = unknown>(): TablePlugin<T> {
|
||||
let lastPage: { page: number; pageSize: number } | null = null;
|
||||
let cached: RowModel<T> | null = null;
|
||||
let pageInfo: PageInfo = { page: 1, pageSize: 20, totalPages: 1 };
|
||||
let fresh = false; // 快照是否来自「按当前 state 完整派生」,settle 只认新鲜快照
|
||||
|
||||
return {
|
||||
name: 'pagination',
|
||||
@@ -32,7 +34,8 @@ export function paginationPlugin<T = unknown>(): TablePlugin<T> {
|
||||
return state;
|
||||
}
|
||||
},
|
||||
derive(model: RowModel<T>, state: TableState): RowModel<T> {
|
||||
derive(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T> {
|
||||
if (ctx.paged === false) { fresh = false; return model; } // 不分页:原样放行上游模型(导出全量行)
|
||||
if (model === lastInput && state.pagination === lastPage && cached) return cached;
|
||||
const { page, pageSize } = state.pagination;
|
||||
const totalPages = Math.max(1, Math.ceil(model.total / pageSize));
|
||||
@@ -46,15 +49,24 @@ export function paginationPlugin<T = unknown>(): TablePlugin<T> {
|
||||
pageInfo = { page: safePage, pageSize, totalPages };
|
||||
lastInput = model;
|
||||
lastPage = state.pagination;
|
||||
fresh = true;
|
||||
return cached;
|
||||
},
|
||||
settle(state: TableState): TableState {
|
||||
// 只把「当前 state 派生出的生效页」回写;快照过期(未按当前 state 派生)时不动,避免写入错误页码
|
||||
if (!fresh || state.pagination !== lastPage || pageInfo.page === state.pagination.page) return state;
|
||||
return { ...state, pagination: { ...state.pagination, page: pageInfo.page } };
|
||||
},
|
||||
api(table: TableInstance<T>) {
|
||||
return {
|
||||
setPage: (page: number) => table.dispatch({ type: 'setPage', page }),
|
||||
nextPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page + 1 }),
|
||||
prevPage: () => table.dispatch({ type: 'setPage', page: pageInfo.page - 1 }),
|
||||
setPageSize: (pageSize: number) => table.dispatch({ type: 'setPageSize', pageSize }),
|
||||
getPageInfo: (): PageInfo => ({ ...pageInfo }),
|
||||
getPageInfo: (): PageInfo => {
|
||||
table.getVisibleRows(); // 先跑一遍派生(内部有 memo),保证快照对应当前 state
|
||||
return { ...pageInfo };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,11 +41,13 @@ export function sortPlugin<T = unknown>(
|
||||
case 'setSorting':
|
||||
return { ...state, sorting: action.sorting };
|
||||
case 'toggleSort': {
|
||||
const rules = action.multi ? [...state.sorting] : []; // 非 multi 直接单列重置
|
||||
const idx = rules.findIndex((r) => r.key === action.key);
|
||||
if (idx === -1) rules.push({ key: action.key, dir: initialDir });
|
||||
else if (rules[idx].dir === 'asc') rules[idx] = { key: action.key, dir: 'desc' };
|
||||
else rules.splice(idx, 1); // desc 再点即取消
|
||||
const idx = state.sorting.findIndex((r) => r.key === action.key); // 本列当前方向的位置
|
||||
// 非 multi 只保留本列(单选语义),multi 保留全部列;不能一味清空,否则本列永远停在 asc
|
||||
const rules = action.multi ? [...state.sorting] : idx === -1 ? [] : [state.sorting[idx]];
|
||||
const at = rules.findIndex((r) => r.key === action.key);
|
||||
if (at === -1) rules.push({ key: action.key, dir: initialDir });
|
||||
else if (rules[at].dir === 'asc') rules[at] = { key: action.key, dir: 'desc' };
|
||||
else rules.splice(at, 1); // desc 再点即取消
|
||||
return { ...state, sorting: rules };
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -61,15 +61,17 @@ export interface RowModel<T = unknown> {
|
||||
|
||||
/*** 派生上下文:核心注入给插件的取值能力 ***/
|
||||
export interface DeriveContext<T = unknown> {
|
||||
paged?: boolean; // 是否分页裁剪;false 时 pagination 插件原样放行(导出全量行用)
|
||||
getCellValue(row: T, key: string): unknown; // 单元格取值(走列 accessor)
|
||||
getKey(row: T): RowKey; // 行标识取值
|
||||
}
|
||||
|
||||
/*** 插件三钩子:patchState 改状态、derive 插入数据管线、api 向实例注入方法 ***/
|
||||
/*** 插件四钩子:patchState 改状态、derive 插入数据管线、settle 派生后收敛自身状态、api 向实例注入方法 ***/
|
||||
export interface TablePlugin<T = unknown> {
|
||||
name: string; // 插件名,诊断用
|
||||
patchState?(state: TableState, action: Action): TableState; // 处理自己关心的动作,无关动作原样返回
|
||||
derive?(model: RowModel<T>, state: TableState, ctx: DeriveContext<T>): RowModel<T>; // 插入派生管线阶段
|
||||
settle?(state: TableState): TableState; // 派生后回写修正(如页码越界钳制);无需修正须原样返回同一引用
|
||||
api?(table: TableInstance<T>): Record<string, unknown>; // 向实例注入便捷方法
|
||||
}
|
||||
|
||||
@@ -88,7 +90,10 @@ export interface TableInstance<T = unknown> {
|
||||
getState(): TableState; // 当前状态快照
|
||||
dispatch(action: Action): void; // 派发动作,唯一变更入口
|
||||
subscribe(fn: () => void): () => void; // 订阅变更,返回取消函数
|
||||
setRows(rows: T[]): void; // 整体换数据源(筛选结果 / 轮询刷新),选择状态按 rowKey 剔除失效行
|
||||
setColumns(columns: ColumnDef<T>[]): void; // 整体换列定义(按角色 / 视图切换列集),失效的排序 / 筛选按 key 剔除
|
||||
getVisibleRows(): RowModel<T>; // 可见行模型(过完整管线)
|
||||
getAllRows(): RowModel<T>; // 筛选 + 排序后、未分页的全量行(导出用)
|
||||
getColumns(): ColumnDef<T>[]; // 列定义
|
||||
getCellValue(row: T, key: string): unknown; // 单元格取值
|
||||
isRowSelected(key: RowKey): boolean; // 行是否已选
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"变更状态:只能 dispatch action,如 table.dispatch({ type: 'toggleSort', key: 'name' });状态不可变",
|
||||
"订阅刷新:table.subscribe(fn),fn 里重新 getVisibleRows() 并交给渲染层",
|
||||
"分页信息:api.getPageInfo() 返回 { page, pageSize, totalPages };越界页码自动钳制",
|
||||
"扩展能力:实现 TablePlugin(derive / patchState / api 三钩子)传入 plugins 即插入管线"
|
||||
"扩展能力:实现 TablePlugin(derive / patchState / settle / api 四钩子)传入 plugins 即插入管线"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,216 @@
|
||||
/* @ui-arch design tokens - light theme defaults + dark theme overrides */
|
||||
/* @ui-arch design tokens - light theme defaults + dark theme overrides
|
||||
分组:颜色 / 混色浓度 / 圆角 / 间距 / 字号 / 字距 / 字重与行高 / 边框 / 阴影 / 动效 / 焦点环 / 控件高度 / 层级
|
||||
约定:皮肤包(packages/skin/ui/ui.css)只能消费 var(--ua-*),禁止硬编码颜色、阴影、时长、字号;
|
||||
本文件是令牌唯一来源,ua tokens / llms-full.txt / MCP get_tokens 全部从这里派生。 */
|
||||
:root {
|
||||
--ua-color-bg: #ffffff;
|
||||
--ua-color-surface: #f6f7f9;
|
||||
--ua-color-text: #1c1e21;
|
||||
--ua-color-text-muted: #6b7280;
|
||||
--ua-color-border: rgba(0, 0, 0, 0.15);
|
||||
--ua-color-accent: #185fa5;
|
||||
--ua-color-accent-contrast: #ffffff;
|
||||
--ua-color-danger: #a32d2d;
|
||||
--ua-color-success: #0f6e56;
|
||||
--ua-color-warning: #9a6b1f;
|
||||
/* ---- 颜色:语义色,暗色主题逐个覆盖 ---- */
|
||||
--ua-color-bg: #ffffff; /* 页面底色:弹层、卡片之外的最底层 */
|
||||
--ua-color-surface: #f6f7f9; /* 表面色:卡片 / 表头 / 次级容器 */
|
||||
--ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-surface)); /* 表面二阶:悬停底、次级容器——从「表面」而非页底派生,卡片 / 弹层之上的悬停才看得出变化 */
|
||||
--ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 14%, var(--ua-color-surface)); /* 表面三阶:按下底、最深容器——与二阶拉开一档,按下比悬停更实 */
|
||||
--ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg)); /* 凹陷区:输入框 / 代码块底 */
|
||||
--ua-color-text: #1c1e21; /* 主文本色:正文与标题共用 */
|
||||
--ua-color-text-muted: #676d78; /* 辅助文本色:说明、占位、次要信息(比原值略深:凹陷底上原值仅 4.39,过不了 AA 4.5) */
|
||||
--ua-color-text-secondary: #4b5158; /* 次级文本色:介于主文本与辅助文本之间 */
|
||||
--ua-color-text-faint: #9aa0a8; /* 三级文本色:禁用、极弱说明 */
|
||||
--ua-color-overlay: rgba(0, 0, 0, 0.45); /* 遮罩:抽屉 / 弹窗 / 命令面板等半透明底 */
|
||||
--ua-color-overlay-strong: rgba(0, 0, 0, 0.6); /* 强遮罩:大图预览等需要更强聚焦的浮层 */
|
||||
--ua-color-border: rgba(0, 0, 0, 0.15); /* 基础边框色:输入框 / 卡片 / 分割线 */
|
||||
--ua-color-scrollbar: rgba(0, 0, 0, 0.22); /* 滚动条拇指色:比边框重一档,白底上看得清又不抢内容 */
|
||||
--ua-color-scrollbar-hover: rgba(0, 0, 0, 0.38); /* 滚动条拇指悬停色:加深表示可拖拽 */
|
||||
--ua-color-highlight: rgba(255, 255, 255, 0.5); /* 内高光白:主按钮等实色控件顶缘的 1px 高光(暗色必须降档,否则刺眼) */
|
||||
--ua-color-accent: #185fa5; /* 强调色:主操作、选中态、焦点环底色 */
|
||||
--ua-color-accent-contrast: #ffffff; /* 强调色上的文字色:保证主按钮对比度 */
|
||||
--ua-color-accent-soft: color-mix(in srgb, var(--ua-color-accent) 72%, var(--ua-color-bg)); /* 柔和强调色:细线 / 指示条 / 内嵌焦点环用,纯 accent 做 2px 实线会发刺 */
|
||||
--ua-color-tint-accent: color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-soft), transparent); /* 语义淡底标准档:选中底、激活项底 */
|
||||
--ua-color-tint-accent-weak: color-mix(in srgb, var(--ua-color-accent) 5%, transparent); /* 语义淡底弱档:悬停底、极弱强调底 */
|
||||
--ua-color-danger: #a32d2d; /* 危险色:错误、删除、校验失败 */
|
||||
--ua-color-success: #0f6e56; /* 成功色:完成、通过、正向结果 */
|
||||
--ua-color-warning: #8a5d15; /* 警告色:提醒、需注意但不阻断(原 #9a6b1f 压在警告淡底上只有 4.12,AA 不过,加深到此值后 5.01) */
|
||||
--ua-color-tint-danger: color-mix(in srgb, var(--ua-color-danger) var(--ua-alpha-soft), transparent); /* 语义淡底(危险):错误提示条、失败步骤底、删除按钮悬停底 */
|
||||
--ua-color-tint-success: color-mix(in srgb, var(--ua-color-success) var(--ua-alpha-soft), transparent); /* 语义淡底(成功):通过提示、完成态条底 */
|
||||
--ua-color-tint-warning: color-mix(in srgb, var(--ua-color-warning) var(--ua-alpha-soft), transparent); /* 语义淡底(警告):需注意但不断言的提示底 */
|
||||
|
||||
--ua-radius-sm: 4px;
|
||||
--ua-radius-md: 8px;
|
||||
--ua-radius-lg: 12px;
|
||||
/* ---- 混色浓度:color-mix 里「混入多少」的档位,百分比亮暗通用(近档已就近收敛:12→10、16/20/24→18、40→45、65/80→70) ---- */
|
||||
--ua-alpha-soft: 10%; /* 淡底档:选中底、激活项底、语义色淡底 */
|
||||
--ua-alpha-tint: 18%; /* 弱强调档:hover 淡底、警示淡底、白色细纹 */
|
||||
--ua-alpha-line: 30%; /* 描边调和档:边框色里混入文本色加深 */
|
||||
--ua-alpha-mid: 45%; /* 中浓档:半透虚线边、淡化滚动条底 */
|
||||
--ua-alpha-strong: 55%; /* 强调档:语义色图标底、强调描边 */
|
||||
--ua-alpha-heavy: 70%; /* 重档:渐变端点、近实色的弱化描边 */
|
||||
|
||||
--ua-space-1: 4px;
|
||||
--ua-space-2: 8px;
|
||||
--ua-space-3: 12px;
|
||||
/* ---- 圆角:三档,控件用小、卡片用中、弹层用大 ---- */
|
||||
--ua-radius-sm: 4px; /* 小圆角:按钮 / 输入框 / 标签等控件 */
|
||||
--ua-radius-md: 8px; /* 中圆角:卡片 / 面板 / 分组容器 */
|
||||
--ua-radius-lg: 12px; /* 大圆角:弹窗 / 抽屉等浮层外壳 */
|
||||
--ua-radius-md-inset: calc(var(--ua-radius-md) - 1px); /* 内衬圆角:贴边的内层块(输入附加块等)与外层弧线对齐,减去 1px 边框 */
|
||||
|
||||
/* ---- 间距:4px 基准四档 + 表格单元格复合值 ---- */
|
||||
--ua-space-1: 4px; /* 间距 1 档:图标与文字之间、紧邻元素 */
|
||||
--ua-space-2: 8px; /* 间距 2 档:控件内边距、同组元素间隔 */
|
||||
--ua-space-3: 12px; /* 间距 3 档:表单行内边距、卡片内边距 */
|
||||
--ua-space-4: 16px;
|
||||
--ua-space-table-cell: var(--ua-space-2) var(--ua-space-3);
|
||||
--ua-space-05: 2px; /* 半步:微型内距(紧凑徽标内衬、选中点偏移) */
|
||||
--ua-space-15: 6px; /* 一档半:紧凑控件内距(小按钮 / 标签) */
|
||||
--ua-space-25: 10px; /* 二档半:标准控件内距(介于 8 与 12 之间的既有主流值) */
|
||||
--ua-space-35: 14px; /* 三档半:宽松行内距(列表行 / 菜单行) */ /* 间距 4 档:区块内边距、卡片间距 */
|
||||
--ua-space-table-cell: var(--ua-space-2) var(--ua-space-3); /* 表格单元格内边距:上下 2 档 / 左右 3 档 */
|
||||
--ua-space-6: 24px; /* 六档:空状态整行留白等大面积竖直呼吸(比 4 档再松一档,不进半步梯子) */
|
||||
--ua-space-indent: 22px; /* 层级缩进:树 / 时间线每级缩进量与图标列宽度对齐,不参与常规内距梯子 */
|
||||
|
||||
--ua-font-size-sm: 12px;
|
||||
--ua-font-size-md: 13px;
|
||||
--ua-font-size-lg: 14px;
|
||||
/* ---- 字号:五档主轴 + 三档展示字号 + 两档相对缩放(10/11px 统一归入 xs,避免单独成档) ---- */
|
||||
--ua-font-size-xs: 11px; /* 最小字号:角标 / 辅助说明 / 密排表格 */
|
||||
--ua-font-size-sm: 12px; /* 小字号:次级文本、表头、提示 */
|
||||
--ua-font-size-md: 13px; /* 正文字号:控件文本与默认正文 */
|
||||
--ua-font-size-lg: 14px; /* 大字号:强调正文、卡片标题 */
|
||||
--ua-font-size-xl: 16px; /* 次标题字号:分组标题、弹窗标题 */
|
||||
--ua-font-size-2xl: 18px; /* 标题字号:页面区块标题 */
|
||||
--ua-font-size-3xl: 22px; /* 图标字号:评分星、开关内嵌图标等由字符撑起的图形 */
|
||||
--ua-font-size-4xl: 26px; /* 指标字号:统计卡片的主数值 */
|
||||
--ua-font-size-5xl: 34px; /* 插画字号:空状态、结果页的大号字符图标 */
|
||||
--ua-font-size-scale-md: 0.92em; /* 相对缩放档(相对父级):代码片段等需跟上下文缩放的文本 */
|
||||
--ua-font-size-scale-sm: 0.85em; /* 相对缩放小档:行内小图标随文字一起缩,不建绝对像素 */
|
||||
|
||||
/* ---- 字距:两档,宽档给表头 / 分组小标题做节奏,紧档给大标题收边 ---- */
|
||||
--ua-tracking-wide: 0.04em; /* 宽档:表头、分组标题、大写小标题(小字号需要字距才不糊成一团) */
|
||||
--ua-tracking-tight: -0.01em; /* 紧档:大标题反向收边,字号越大越需要收紧才不散 */
|
||||
|
||||
/* ---- 字重与行高:标题 / 正文 / 辅助三档 ---- */
|
||||
--ua-weight-normal: 400; /* 正文档:正文与表单值 */
|
||||
--ua-weight-medium: 500; /* 辅助强调档:控件文本、表头、次要标题 */
|
||||
--ua-weight-semibold: 600; /* 标题档:区块标题、关键数值 */
|
||||
--ua-weight-bold: 700; /* 特重档:统计大数字等少数强调位 */
|
||||
--ua-leading-none: 1; /* 单行档:图标 / 数字 / 按钮文字的垂直居中 */
|
||||
--ua-leading-tight: 1.35; /* 紧凑档:标题、单行控件 */
|
||||
--ua-leading-normal: 1.55; /* 正文档:多行正文、说明文字 */
|
||||
|
||||
/* ---- 边框:细分(比基础边框更淡,用于分割线,避免 1px 线过重) ---- */
|
||||
--ua-border-subtle: color-mix(in srgb, var(--ua-color-border) 60%, transparent); /* 细分边框色(专用于分割线):表格行线 / 卡片页脚 / 弹窗抽屉页头页脚 / 列表项 / 选项卡底边 / 折叠面板与步骤条连接线 / 分隔线组件 */
|
||||
|
||||
/* ---- 阴影:四档 elevation,柔和优先(扩散大、透明度低) ---- */
|
||||
--ua-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06); /* 1 档:贴地投影,用于卡片 / 输入组 / 轻浮起 */
|
||||
--ua-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.1); /* 2 档:下拉菜单 / 选择面板 / 气泡提示 */
|
||||
--ua-shadow-3: 0 10px 28px rgba(0, 0, 0, 0.12); /* 3 档:浮层 / 悬浮面板 / 拖拽中的卡片 */
|
||||
--ua-shadow-4: 0 18px 44px rgba(0, 0, 0, 0.14); /* 4 档:模态弹窗 / 抽屉 / 通知中心等最高层 */
|
||||
--ua-shadow-accent: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent); /* 主题色投影:主按钮 / 强调控件悬停浮起 */
|
||||
--ua-shadow-accent-strong: 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 35%, transparent); /* 主题色强投影:返回顶部等悬浮强调件 */
|
||||
--ua-shadow-highlight: inset 0 1px 0 var(--ua-color-highlight); /* 内高光型:实色控件顶缘 1px 高光(1px 是细线语义,不进 elevation 四档) */
|
||||
--ua-shadow-hairline: inset 0 -1px 0 var(--ua-border-subtle); /* 描边型:粘性表头 / 吸底条底部的 1px 细线(用阴影画线,不占布局高度) */
|
||||
--ua-shadow-inset-border: inset 0 0 0 1px var(--ua-color-border); /* 描边型:日历「今天」等只需要一圈细内框、不想改盒模型的场景 */
|
||||
--ua-shadow-inset-accent: inset 0 0 0 1px var(--ua-color-accent); /* 描边型:强调色内框,标记「今天」这类弱选中态 */
|
||||
--ua-shadow-gap: 0 0 0 2px var(--ua-color-bg); /* 断口型:用底色描一圈把相邻元素分开(头像组叠层、角标与按钮边缘) */
|
||||
|
||||
/* ---- 动效:四档交互时长 + 四档循环时长 + 两条主缓动曲线 ---- */
|
||||
--ua-duration-fast: 0.15s; /* 快:hover / 聚焦 / 颜色与边框变化(既有样式的主流时长) */
|
||||
--ua-duration-base: 0.2s; /* 中:展开收起 / 位移 / 透明度过渡 */
|
||||
--ua-duration-slow: 0.25s; /* 慢:浮层出入场 / 较大面积变化 */
|
||||
--ua-duration-slower: 0.35s; /* 更慢:进度条宽度推进 / 描边长度等大跨度形变(比浮层出场再长一档) */
|
||||
--ua-duration-loop-fast: 0.6s; /* 循环快档:条纹滚动等细微循环 */
|
||||
--ua-duration-loop-mid: 0.8s; /* 循环中档:加载转圈 / 开关内转圈(0.7 与 0.8 收敛到此档) */
|
||||
--ua-duration-loop-slow: 1.2s; /* 循环慢档:骨架扫光 / 不确定进度条(1.4 收敛到此档) */
|
||||
--ua-duration-loop-long: 1.6s; /* 循环长档:时间线呼吸等低频脉冲,别再用在加载态上 */
|
||||
--ua-duration-step: 0.02s; /* 微步档:交错入场的单项间隔(20ms),只供 calc(n * var(--ua-duration-step)) 派生,不直接当过渡时长 */
|
||||
--ua-ease: cubic-bezier(0.2, 0.8, 0.2, 1); /* 标准缓动:先快后缓,控件交互默认用这条 */
|
||||
--ua-ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* 出场缓动:元素进入视口 / 弹层出现时使用 */
|
||||
--ua-ease-spring: cubic-bezier(0.34, 1.3, 0.5, 1); /* 回弹缓动:弹窗本体、悬浮按钮这类需要一点过冲的入场 */
|
||||
--ua-translate-1: 1px; /* 位移 1 档:hover 微上浮(行内小件) */
|
||||
--ua-translate-2: 2px; /* 位移 2 档:hover 上浮标准档(按钮 / 卡片 / 悬浮件) */
|
||||
--ua-translate-3: 3px; /* 位移 3 档:贴边入场偏移(tooltip 箭头侧滑等几何配对位移) */
|
||||
--ua-translate-4: 4px; /* 位移 4 档:小型弹层入场(下拉菜单 / 气泡滑入) */
|
||||
--ua-translate-6: 6px; /* 位移 6 档:入场位移中档(alert / 通知条滑入) */
|
||||
--ua-translate-8: 8px; /* 位移 8 档:浮动件入场(悬浮按钮弹出 / 大弹层滑入;既有 10px 收敛到此档) */
|
||||
--ua-translate-12: 12px; /* 位移 12 档:长距离滑入(toast 侧边进出场 / 开关行程) */
|
||||
|
||||
/* ---- 焦点环:统一外环与内嵌两种形态,全部由强调色派生 ---- */
|
||||
--ua-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent); /* 外焦点环:输入框 / 按钮 / 可聚焦控件 */
|
||||
--ua-focus-ring-inset: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); /* 内嵌焦点环:内部已裁切或需要贴合边框的控件 */
|
||||
--ua-focus-ring-warning: 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 25%, transparent); /* 语义警告焦点环:评分等以警告色为主色的控件 */
|
||||
--ua-focus-ring-danger: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 18%, transparent); /* 错误强调环:校验失败时叠在被标红控件外侧,比 danger 描边更外扩一圈 */
|
||||
--ua-focus-ring-pulse: 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 4%, transparent); /* 呼吸光晕档:与焦点环交替出现做脉冲动画(时间线条目) */
|
||||
--ua-focus-ring-selected: 0 0 0 1.5px var(--ua-color-accent-soft); /* 选中环(非焦点):细一档 + 柔和色,2px 实色在色板/网格里像加粗边框 */
|
||||
--ua-focus-outline: 2px solid var(--ua-color-accent); /* 外焦点描边(outline 路线):键盘可见性标准档 */
|
||||
--ua-focus-outline-inset: 1.5px solid var(--ua-color-accent-soft); /* 内嵌焦点描边:密集列表细一档 + 柔和色,2px 实线像粗边框 */
|
||||
--ua-focus-outline-soft: 2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent); /* 柔和外描边:大面积控件用淡化主题色 */
|
||||
|
||||
/* ---- 控件高度:三档,略放大以提升可点面积 ---- */
|
||||
--ua-control-h-sm: 28px; /* 小控件高度:紧凑工具栏、标签内嵌按钮 */
|
||||
--ua-control-h-md: 34px; /* 中控件高度:输入框 / 下拉 / 按钮的默认档 */
|
||||
--ua-control-h-lg: 40px; /* 大控件高度:主按钮、搜索框、移动端触控目标 */
|
||||
|
||||
/* ---- 层级:五档,数值留出间隙便于中间插入 ---- */
|
||||
--ua-z-raised: 10; /* 层内抬升:拖拽中的行、临时浮起的卡片——只为压过同容器兄弟,够用即可 */
|
||||
--ua-z-dropdown: 100; /* 下拉层:菜单 / 选择面板 / 气泡 */
|
||||
--ua-z-sticky: 200; /* 粘性层:表头、吸顶工具栏 */
|
||||
--ua-z-overlay: 300; /* 遮罩层:模态背后的半透明遮罩 */
|
||||
--ua-z-modal: 400; /* 模态层:弹窗 / 抽屉本体 */
|
||||
--ua-z-toast: 500; /* 通知层:全局消息提示,永远最高 */
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--ua-color-bg: #101216;
|
||||
--ua-color-surface: #1a1c22;
|
||||
--ua-color-text: #e7e9ec;
|
||||
--ua-color-text-muted: #888780;
|
||||
--ua-color-border: rgba(255, 255, 255, 0.15);
|
||||
--ua-color-accent: #378add;
|
||||
--ua-color-accent-contrast: #042c53;
|
||||
--ua-color-danger: #d96a6a;
|
||||
--ua-color-success: #4fc39e;
|
||||
--ua-color-warning: #d9a94f;
|
||||
--ua-color-bg: #101216; /* 暗色页面底色 */
|
||||
--ua-color-surface: #1a1c22; /* 暗色表面色 */
|
||||
--ua-color-surface-2: color-mix(in srgb, var(--ua-color-text) 7%, var(--ua-color-surface)); /* 暗色表面二阶:悬停底、次级容器(与亮色同结构,暗底上要多混两档才看得见) */
|
||||
--ua-color-surface-3: color-mix(in srgb, var(--ua-color-text) 17%, var(--ua-color-surface)); /* 暗色表面三阶:按下底、最深容器 */
|
||||
--ua-color-surface-inset: color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg)); /* 暗色凹陷区:输入框 / 代码块底 */
|
||||
--ua-color-text: #e7e9ec; /* 暗色主文本色 */
|
||||
--ua-color-text-muted: #888780; /* 暗色辅助文本色 */
|
||||
--ua-color-text-secondary: #b4bac2; /* 暗色次级文本色 */
|
||||
--ua-color-text-faint: #62676f; /* 暗色三级文本色:禁用、极弱说明 */
|
||||
--ua-color-overlay: rgba(0, 0, 0, 0.6); /* 暗色遮罩:暗底上需加深才可分辨 */
|
||||
--ua-color-overlay-strong: rgba(0, 0, 0, 0.7); /* 暗色强遮罩 */
|
||||
--ua-color-border: rgba(255, 255, 255, 0.15); /* 暗色基础边框色:改用白色低透明度,避免黑边在暗底上消失 */
|
||||
--ua-color-scrollbar: rgba(255, 255, 255, 0.24); /* 暗色滚动条拇指:暗底上黑拇指完全看不见,改白色低透明度 */
|
||||
--ua-color-scrollbar-hover: rgba(255, 255, 255, 0.42); /* 暗色滚动条拇指悬停:亮度再升一档表示可拖拽 */
|
||||
--ua-color-highlight: rgba(255, 255, 255, 0.08); /* 暗色内高光:暗底上白高光刺眼,降到 8% 只留一道细光 */
|
||||
--ua-color-accent: #4a9aeb; /* 暗色强调色:提亮以保证暗底可读(原值压在白字/淡底上只有 3.92/4.44,过不了 AA,详见 ua audit:contrast) */
|
||||
--ua-color-accent-contrast: #042c53; /* 暗色强调色上的文字色:深色反白,维持对比 */
|
||||
--ua-color-accent-soft: color-mix(in srgb, var(--ua-color-accent) 82%, var(--ua-color-bg)); /* 暗色柔和强调色:与亮色同结构,混入暗底即自然降饱和 */
|
||||
--ua-color-tint-accent: color-mix(in srgb, var(--ua-color-accent) 14%, transparent); /* 暗色淡底标准档:暗底上淡底不可见,透明度升一档 */
|
||||
--ua-color-tint-accent-weak: color-mix(in srgb, var(--ua-color-accent) 8%, transparent); /* 暗色淡底弱档:透明度升一档 */
|
||||
--ua-color-danger: #d96a6a; /* 暗色危险色 */
|
||||
--ua-color-success: #4fc39e; /* 暗色成功色 */
|
||||
--ua-color-warning: #d9a94f; /* 暗色警告色 */
|
||||
--ua-color-tint-danger: color-mix(in srgb, var(--ua-color-danger) 14%, transparent); /* 暗色语义淡底(危险):与 accent 暗色同档,否则暗底上看不出「这一块是错误」 */
|
||||
--ua-color-tint-success: color-mix(in srgb, var(--ua-color-success) 14%, transparent); /* 暗色语义淡底(成功) */
|
||||
--ua-color-tint-warning: color-mix(in srgb, var(--ua-color-warning) 14%, transparent); /* 暗色语义淡底(警告) */
|
||||
|
||||
/* 暗色下投影需要更高黑度才看得见:亮色那套在暗底上几乎不可见 */
|
||||
--ua-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3); /* 暗色 1 档:贴地投影 */
|
||||
--ua-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.4); /* 暗色 2 档:下拉 / 气泡 */
|
||||
--ua-shadow-3: 0 10px 28px rgba(0, 0, 0, 0.45); /* 暗色 3 档:浮层 / 悬浮面板 */
|
||||
--ua-shadow-4: 0 18px 44px rgba(0, 0, 0, 0.5); /* 暗色 4 档:模态 / 抽屉 / 通知 */
|
||||
--ua-shadow-accent: 0 3px 10px color-mix(in srgb, var(--ua-color-accent) 30%, transparent); /* 暗色主题色投影:透明度略升补可见性 */
|
||||
--ua-shadow-accent-strong: 0 8px 20px color-mix(in srgb, var(--ua-color-accent) 40%, transparent); /* 暗色主题色强投影 */
|
||||
--ua-shadow-highlight: inset 0 1px 0 var(--ua-color-highlight); /* 暗色内高光:值不变、靠 --ua-color-highlight 降到 8%,暗底上仍是一道细光 */
|
||||
--ua-shadow-hairline: inset 0 -1px 0 var(--ua-border-subtle); /* 暗色描边细线:随 border-subtle 翻转 */
|
||||
--ua-shadow-inset-border: inset 0 0 0 1px var(--ua-color-border); /* 暗色内框 */
|
||||
--ua-shadow-inset-accent: inset 0 0 0 1px var(--ua-color-accent); /* 暗色强调内框 */
|
||||
--ua-shadow-gap: 0 0 0 2px var(--ua-color-bg); /* 暗色断口:底色翻转后仍断开 */
|
||||
--ua-focus-ring: 0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 34%, transparent); /* 暗色焦点环:透明度略升补对比 */
|
||||
--ua-focus-ring-inset: inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 52%, transparent); /* 暗色内嵌焦点环 */
|
||||
--ua-focus-ring-warning: 0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 34%, transparent); /* 暗色警告焦点环 */
|
||||
--ua-focus-ring-danger: 0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 30%, transparent); /* 暗色错误强调环 */
|
||||
--ua-focus-ring-pulse: 0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 8%, transparent); /* 暗色呼吸光晕:暗底上低透明度看不见,加倍 */
|
||||
--ua-focus-ring-selected: 0 0 0 1.5px var(--ua-color-accent-soft); /* 暗色选中环:随 accent 翻转并与亮色同口径 */
|
||||
--ua-focus-outline: 2px solid var(--ua-color-accent); /* 暗色外焦点描边:随 accent 翻转,与亮色同口径 */
|
||||
--ua-focus-outline-inset: 1.5px solid var(--ua-color-accent-soft); /* 暗色内嵌焦点描边:accent-soft 随底色重混 */
|
||||
--ua-focus-outline-soft: 2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent); /* 暗色柔和外描边:浓度档与亮色一致 */
|
||||
}
|
||||
|
||||
/* ---- 高对比偏好:系统设置「提高对比度」时加深边界与弱文字 ----
|
||||
为什么放文件末尾:媒体查询里的同选择器规则要靠后置胜出,写在暗色块之前会被顶掉。
|
||||
只重设已有变量、不新增名字:verify 的颜色令牌 ↔ tokens.json 双向核对保持一一对应。 */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--ua-color-border: rgba(0, 0, 0, 0.45); /* 边界加深:控件轮廓在强光 / 低视力下仍可辨(边框细分线随之加深,它是 border 的混色派生) */
|
||||
--ua-color-text-muted: #4b5158; /* 辅助文字升级为次级文字色,说明文字不再是浅灰 */
|
||||
--ua-color-text-faint: #6b7280; /* 三级文字升到原辅助档:禁用态也要读得清 */
|
||||
--ua-color-scrollbar: rgba(0, 0, 0, 0.45); /* 滚动条拇指加深,可拖拽区域更明确 */
|
||||
}
|
||||
[data-theme='dark'] {
|
||||
--ua-color-border: rgba(255, 255, 255, 0.45); /* 暗色同样加深:白边提亮而不是加深黑 */
|
||||
--ua-color-text-muted: #b4bac2;
|
||||
--ua-color-text-faint: #888780;
|
||||
--ua-color-scrollbar: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,289 @@
|
||||
{
|
||||
"$schema": "https://design-tokens.github.io/community-group/format/",
|
||||
"color": {
|
||||
"bg": { "light": "#ffffff", "dark": "#101216" },
|
||||
"surface": { "light": "#f6f7f9", "dark": "#1a1c22" },
|
||||
"text": { "light": "#1c1e21", "dark": "#e7e9ec" },
|
||||
"textMuted": { "light": "#6b7280", "dark": "#888780" },
|
||||
"accent": { "light": "#185fa5", "dark": "#378add" },
|
||||
"danger": "#a32d2d",
|
||||
"success": "#0f6e56"
|
||||
"bg": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#101216"
|
||||
},
|
||||
"surface": {
|
||||
"light": "#f6f7f9",
|
||||
"dark": "#1a1c22"
|
||||
},
|
||||
"text": {
|
||||
"light": "#1c1e21",
|
||||
"dark": "#e7e9ec"
|
||||
},
|
||||
"textMuted": {
|
||||
"light": "#676d78",
|
||||
"dark": "#888780"
|
||||
},
|
||||
"border": {
|
||||
"light": "rgba(0, 0, 0, 0.15)",
|
||||
"dark": "rgba(255, 255, 255, 0.15)"
|
||||
},
|
||||
"scrollbar": {
|
||||
"light": "rgba(0, 0, 0, 0.22)",
|
||||
"dark": "rgba(255, 255, 255, 0.24)"
|
||||
},
|
||||
"scrollbarHover": {
|
||||
"light": "rgba(0, 0, 0, 0.38)",
|
||||
"dark": "rgba(255, 255, 255, 0.42)"
|
||||
},
|
||||
"highlight": {
|
||||
"light": "rgba(255, 255, 255, 0.5)",
|
||||
"dark": "rgba(255, 255, 255, 0.08)"
|
||||
},
|
||||
"accent": {
|
||||
"light": "#185fa5",
|
||||
"dark": "#4a9aeb"
|
||||
},
|
||||
"accentContrast": {
|
||||
"light": "#ffffff",
|
||||
"dark": "#042c53"
|
||||
},
|
||||
"danger": {
|
||||
"light": "#a32d2d",
|
||||
"dark": "#d96a6a"
|
||||
},
|
||||
"success": {
|
||||
"light": "#0f6e56",
|
||||
"dark": "#4fc39e"
|
||||
},
|
||||
"warning": {
|
||||
"light": "#8a5d15",
|
||||
"dark": "#d9a94f"
|
||||
},
|
||||
"surface2": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-surface))",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-text) 7%, var(--ua-color-surface))"
|
||||
},
|
||||
"surface3": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-text) 14%, var(--ua-color-surface))",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-text) 17%, var(--ua-color-surface))"
|
||||
},
|
||||
"surfaceInset": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-text) 5%, var(--ua-color-bg))",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-text) 6%, var(--ua-color-bg))"
|
||||
},
|
||||
"textSecondary": {
|
||||
"light": "#4b5158",
|
||||
"dark": "#b4bac2"
|
||||
},
|
||||
"textFaint": {
|
||||
"light": "#9aa0a8",
|
||||
"dark": "#62676f"
|
||||
},
|
||||
"overlay": {
|
||||
"light": "rgba(0, 0, 0, 0.45)",
|
||||
"dark": "rgba(0, 0, 0, 0.6)"
|
||||
},
|
||||
"overlayStrong": {
|
||||
"light": "rgba(0, 0, 0, 0.6)",
|
||||
"dark": "rgba(0, 0, 0, 0.7)"
|
||||
},
|
||||
"tintAccent": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-soft), transparent)",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-accent) 14%, transparent)"
|
||||
},
|
||||
"tintAccentWeak": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-accent) 5%, transparent)",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-accent) 8%, transparent)"
|
||||
},
|
||||
"tintDanger": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-danger) var(--ua-alpha-soft), transparent)",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-danger) 14%, transparent)"
|
||||
},
|
||||
"tintSuccess": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-success) var(--ua-alpha-soft), transparent)",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-success) 14%, transparent)"
|
||||
},
|
||||
"tintWarning": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-warning) var(--ua-alpha-soft), transparent)",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-warning) 14%, transparent)"
|
||||
},
|
||||
"accentSoft": {
|
||||
"light": "color-mix(in srgb, var(--ua-color-accent) 72%, var(--ua-color-bg))",
|
||||
"dark": "color-mix(in srgb, var(--ua-color-accent) 82%, var(--ua-color-bg))"
|
||||
}
|
||||
},
|
||||
"radius": { "sm": "4px", "md": "8px", "lg": "12px" },
|
||||
"space": { "1": "4px", "2": "8px", "3": "12px", "4": "16px" }
|
||||
"alpha": {
|
||||
"soft": "10%",
|
||||
"tint": "18%",
|
||||
"line": "30%",
|
||||
"mid": "45%",
|
||||
"strong": "55%",
|
||||
"heavy": "70%"
|
||||
},
|
||||
"radius": {
|
||||
"sm": "4px",
|
||||
"md": "8px",
|
||||
"lg": "12px",
|
||||
"mdInset": "calc(var(--ua-radius-md) - 1px)"
|
||||
},
|
||||
"space": {
|
||||
"1": "4px",
|
||||
"2": "8px",
|
||||
"3": "12px",
|
||||
"4": "16px",
|
||||
"15": "6px",
|
||||
"25": "10px",
|
||||
"35": "14px",
|
||||
"05": "2px",
|
||||
"6": "24px",
|
||||
"indent": "22px"
|
||||
},
|
||||
"fontSize": {
|
||||
"xs": "11px",
|
||||
"sm": "12px",
|
||||
"md": "13px",
|
||||
"lg": "14px",
|
||||
"xl": "16px",
|
||||
"2xl": "18px",
|
||||
"3xl": "22px",
|
||||
"4xl": "26px",
|
||||
"5xl": "34px",
|
||||
"scale-md": "0.92em",
|
||||
"scale-sm": "0.85em"
|
||||
},
|
||||
"weight": {
|
||||
"normal": 400,
|
||||
"medium": 500,
|
||||
"semibold": 600,
|
||||
"bold": 700
|
||||
},
|
||||
"tracking": {
|
||||
"wide": "0.04em",
|
||||
"tight": "-0.01em"
|
||||
},
|
||||
"leading": {
|
||||
"tight": 1.35,
|
||||
"normal": 1.55,
|
||||
"none": 1
|
||||
},
|
||||
"border": {
|
||||
"subtle": "color-mix(in srgb, var(--ua-color-border) 60%, transparent)"
|
||||
},
|
||||
"shadow": {
|
||||
"1": {
|
||||
"light": "0 1px 2px rgba(0, 0, 0, 0.06)",
|
||||
"dark": "0 1px 2px rgba(0, 0, 0, 0.3)"
|
||||
},
|
||||
"2": {
|
||||
"light": "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||
"dark": "0 4px 12px rgba(0, 0, 0, 0.4)"
|
||||
},
|
||||
"3": {
|
||||
"light": "0 10px 28px rgba(0, 0, 0, 0.12)",
|
||||
"dark": "0 10px 28px rgba(0, 0, 0, 0.45)"
|
||||
},
|
||||
"4": {
|
||||
"light": "0 18px 44px rgba(0, 0, 0, 0.14)",
|
||||
"dark": "0 18px 44px rgba(0, 0, 0, 0.5)"
|
||||
},
|
||||
"accent": {
|
||||
"light": "0 3px 10px color-mix(in srgb, var(--ua-color-accent) 25%, transparent)",
|
||||
"dark": "0 3px 10px color-mix(in srgb, var(--ua-color-accent) 30%, transparent)"
|
||||
},
|
||||
"accentStrong": {
|
||||
"light": "0 8px 20px color-mix(in srgb, var(--ua-color-accent) 35%, transparent)",
|
||||
"dark": "0 8px 20px color-mix(in srgb, var(--ua-color-accent) 40%, transparent)"
|
||||
},
|
||||
"highlight": {
|
||||
"light": "inset 0 1px 0 var(--ua-color-highlight)",
|
||||
"dark": "inset 0 1px 0 var(--ua-color-highlight)"
|
||||
},
|
||||
"hairline": {
|
||||
"light": "inset 0 -1px 0 var(--ua-border-subtle)",
|
||||
"dark": "inset 0 -1px 0 var(--ua-border-subtle)"
|
||||
},
|
||||
"insetBorder": {
|
||||
"light": "inset 0 0 0 1px var(--ua-color-border)",
|
||||
"dark": "inset 0 0 0 1px var(--ua-color-border)"
|
||||
},
|
||||
"insetAccent": {
|
||||
"light": "inset 0 0 0 1px var(--ua-color-accent)",
|
||||
"dark": "inset 0 0 0 1px var(--ua-color-accent)"
|
||||
},
|
||||
"gap": {
|
||||
"light": "0 0 0 2px var(--ua-color-bg)",
|
||||
"dark": "0 0 0 2px var(--ua-color-bg)"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"duration": {
|
||||
"fast": "0.15s",
|
||||
"base": "0.2s",
|
||||
"slow": "0.25s",
|
||||
"slower": "0.35s",
|
||||
"loopFast": "0.6s",
|
||||
"loopMid": "0.8s",
|
||||
"loopSlow": "1.2s",
|
||||
"loopLong": "1.6s",
|
||||
"step": "0.02s"
|
||||
},
|
||||
"ease": "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||
"easeOut": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"easeSpring": "cubic-bezier(0.34, 1.3, 0.5, 1)"
|
||||
},
|
||||
"translate": {
|
||||
"1": "1px",
|
||||
"2": "2px",
|
||||
"3": "3px",
|
||||
"4": "4px",
|
||||
"6": "6px",
|
||||
"8": "8px",
|
||||
"12": "12px"
|
||||
},
|
||||
"focus": {
|
||||
"ring": {
|
||||
"light": "0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 22%, transparent)",
|
||||
"dark": "0 0 0 3px color-mix(in srgb, var(--ua-color-accent) 34%, transparent)"
|
||||
},
|
||||
"ringInset": {
|
||||
"light": "inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 40%, transparent)",
|
||||
"dark": "inset 0 0 0 2px color-mix(in srgb, var(--ua-color-accent) 52%, transparent)"
|
||||
},
|
||||
"ringWarning": {
|
||||
"light": "0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 25%, transparent)",
|
||||
"dark": "0 0 0 3px color-mix(in srgb, var(--ua-color-warning) 34%, transparent)"
|
||||
},
|
||||
"ringDanger": {
|
||||
"light": "0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 18%, transparent)",
|
||||
"dark": "0 0 0 3px color-mix(in srgb, var(--ua-color-danger) 30%, transparent)"
|
||||
},
|
||||
"ringPulse": {
|
||||
"light": "0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 4%, transparent)",
|
||||
"dark": "0 0 0 5px color-mix(in srgb, var(--ua-color-accent) 8%, transparent)"
|
||||
},
|
||||
"ringSelected": {
|
||||
"light": "0 0 0 2px var(--ua-color-accent)",
|
||||
"dark": "0 0 0 2px var(--ua-color-accent)"
|
||||
},
|
||||
"outline": {
|
||||
"light": "2px solid var(--ua-color-accent)",
|
||||
"dark": "2px solid var(--ua-color-accent)"
|
||||
},
|
||||
"outlineInset": {
|
||||
"light": "1.5px solid var(--ua-color-accent-soft)",
|
||||
"dark": "1.5px solid var(--ua-color-accent-soft)"
|
||||
},
|
||||
"outlineSoft": {
|
||||
"light": "2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent)",
|
||||
"dark": "2px solid color-mix(in srgb, var(--ua-color-accent) var(--ua-alpha-mid), transparent)"
|
||||
}
|
||||
},
|
||||
"control": {
|
||||
"height": {
|
||||
"sm": "28px",
|
||||
"md": "34px",
|
||||
"lg": "40px"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"dropdown": 100,
|
||||
"sticky": 200,
|
||||
"overlay": 300,
|
||||
"modal": 400,
|
||||
"toast": 500
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"引入:在应用入口 import '@ui-arch/tokens/tokens.css'",
|
||||
"消费:组件样式一律 var(--ua-color-accent) 这类变量,不写死色值",
|
||||
"暗色:根元素设 data-theme=dark,变量值自动切换",
|
||||
"扩展:新令牌先同时加进 tokens.css 与 tokens.json,再重新生成文档站"
|
||||
"扩展:新令牌先同时加进 tokens.css 与 tokens.json,再重新生成文档站",
|
||||
"选区:库内文字选区 ::selection 统一为主题色淡底(皮肤包已全局处理),应用侧不必再写"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*** 皮肤包冒烟:只测纯逻辑(今日覆盖行级差异对齐),DOM 渲染靠演示页人工过 ***/
|
||||
import { alignMiddle, diffLines, normalizeLine, toRows, DP_LIMIT } from '../dist/diffKit.js';
|
||||
|
||||
let failed = 0;
|
||||
/*** 断言:打印一条结果,失败计数 +1 ***/
|
||||
const ok = (label, cond) => {
|
||||
if (cond) { console.log(`ok - ${label}`); return; }
|
||||
console.error(`FAIL - ${label}`);
|
||||
failed += 1;
|
||||
};
|
||||
|
||||
// 1) 完全相同:全是 same,没有增删
|
||||
const same = diffLines('a\nb\nc', 'a\nb\nc', false);
|
||||
ok('相同文本全是 same', same.length === 3 && same.every((o) => o.type === 'same'));
|
||||
// 2) 纯新增一行
|
||||
const added = diffLines('a\nc', 'a\nb\nc', false);
|
||||
ok('中间插入一行 → 恰好 1 条 add', added.filter((o) => o.type === 'add').length === 1 && added.filter((o) => o.type === 'del').length === 0);
|
||||
// 3) 纯删除一行
|
||||
const removed = diffLines('a\nb\nc', 'a\nc', false);
|
||||
ok('中间删除一行 → 恰好 1 条 del', removed.filter((o) => o.type === 'del').length === 1 && removed.filter((o) => o.type === 'add').length === 0);
|
||||
// 4) 尾部换行不产生假差异
|
||||
ok('尾部换行不算差异', diffLines('a\nb\n', 'a\nb', false).every((o) => o.type === 'same'));
|
||||
// 5) 忽略行内空白:只差缩进时无增删,且显示保留原文
|
||||
const ws = diffLines('if (a) {\n return 1;\n}', 'if (a) {\n return 1;\n}', true);
|
||||
ok('忽略空白后缩进差不计入差异', ws.every((o) => o.type === 'same') && ws[1].text.includes(' '));
|
||||
// 6) 不忽略空白时同一对文本必须报差异(对照组:证明开关真的起作用)
|
||||
ok('不忽略空白时同一对文本报增删', diffLines('if (a) {\n return 1;\n}', 'if (a) {\n return 1;\n}', false).some((o) => o.type !== 'same'));
|
||||
// 7) 行内多空格归一
|
||||
ok('行内连续空白压成一个空格', normalizeLine(' a b\t c ') === 'a b c');
|
||||
// 8) 超限退化:仍保证「旧行全 del + 新行全 add」,不丢内容
|
||||
const bigA = Array.from({ length: 700 }, (_, i) => `a${i}`);
|
||||
const bigB = Array.from({ length: 700 }, (_, i) => `b${i}`);
|
||||
const big = alignMiddle(toRows(bigA.join('\n'), false), toRows(bigB.join('\n'), false));
|
||||
ok(`超限(>${DP_LIMIT})退化为整段替换`, big.filter((o) => o.type === 'del').length === 700 && big.filter((o) => o.type === 'add').length === 700);
|
||||
|
||||
if (failed) {
|
||||
console.error(`皮肤冒烟失败 ${failed} 条`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SKIN SMOKE OK');
|
||||
@@ -51,5 +51,25 @@ export function accordion(props: AccordionProps): HTMLElement {
|
||||
),
|
||||
);
|
||||
|
||||
return h('div', { class: 'ua-accordion' }, ...rows);
|
||||
const root = h('div', { class: 'ua-accordion' }, ...rows);
|
||||
|
||||
// —— 键盘导航:↑↓ 行间环形移动、Home/End 首尾直达,均跳过禁用行(对齐 tabs/tree 约定) ——
|
||||
const heads = (): HTMLButtonElement[] => rows.map((r) => r.firstElementChild as HTMLButtonElement); // 各行标题按钮
|
||||
const focusFrom = (start: number, dir: 1 | -1): void => {
|
||||
const len = heads().length;
|
||||
for (let n = 1; n <= len; n++) {
|
||||
const cand = heads()[(((start + dir * n) % len) + len) % len]; // 环形取候选:越界回绕保证落在 [0, len)
|
||||
if (!cand.disabled) { cand.focus(); return; } // 跳过禁用行,绕满一圈即止
|
||||
}
|
||||
};
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const list = heads();
|
||||
const idx = list.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (idx < 0) return; // 卫语句:焦点不在标题行
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); focusFrom(idx, e.key === 'ArrowDown' ? 1 : -1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); focusFrom(-1, 1); } // 从头正向找第一个可用
|
||||
else if (e.key === 'End') { e.preventDefault(); focusFrom(0, -1); } // 从尾反向找最后一个可用
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
|
||||
/*** 输入附加块包装:prefix/suffix 存在时把控件包进附加组,否则原样返回(零侵入,结构不变) ***/
|
||||
export function addonWrap(
|
||||
el: HTMLElement,
|
||||
prefix?: string | HTMLElement,
|
||||
suffix?: string | HTMLElement,
|
||||
column = false, // 多行控件用纵向布局:prefix 在上、suffix 在下
|
||||
): HTMLElement {
|
||||
if (!prefix && !suffix) return el; // 卫语句:无附加不包裹
|
||||
const addon = (v?: string | HTMLElement) => (v == null ? null : h('span', { class: 'ua-input-addon' }, v));
|
||||
return h('div', { class: column ? 'ua-input-group ua-input-group-column' : 'ua-input-group' }, addon(prefix), el, addon(suffix));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AffixProps } from './types.js';
|
||||
import { watchViewport } from './watch.js';
|
||||
|
||||
/*** 固钉:内容滚动越过阈值后转 fixed 贴住视口边缘(top / bottom),外层保留占位高度避免布局跳动 ***/
|
||||
export function affix(props: AffixProps, ...children: unknown[]): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const offset = props.offset ?? 0; // 触发固定的阈值
|
||||
const atBottom = props.position === 'bottom'; // 贴底还是贴顶(底部适合操作条)
|
||||
const scroller = props.target ?? window; // 滚动容器
|
||||
const inner = h('div', { class: 'ua-affix-inner' }, ...children) as HTMLElement; // 真正会被固定的内容
|
||||
const wrap = h('div', { class: 'ua-affix' }, inner) as HTMLElement; // 占位容器,固定时撑住原位
|
||||
let fixed = false; // 当前是否已固定,避免每次滚动都重写样式
|
||||
|
||||
/*** 同步固定态:越过阈值即 fixed + 撑占位高度,回到阈值以上就还原 ***/
|
||||
const sync = (): void => {
|
||||
const box = wrap.getBoundingClientRect(); // 占位容器几何:left / width 决定固定后的横向位置
|
||||
const should = atBottom ? box.bottom >= window.innerHeight - offset : box.top <= offset; // 贴底:占位落到阈值线下即固定
|
||||
if (should === fixed) {
|
||||
if (fixed) { // 固定中:宽度与横向位置跟随容器(窗口缩放 / 父容器变宽)
|
||||
inner.style.width = `${box.width}px`;
|
||||
inner.style.left = `${box.left}px`;
|
||||
}
|
||||
return; // 卫语句:状态未变不重写其余样式
|
||||
}
|
||||
fixed = should;
|
||||
if (fixed) {
|
||||
wrap.style.height = `${inner.offsetHeight}px`; // 先撑住占位,再让 inner 脱离文档流
|
||||
inner.classList.add('is-fixed');
|
||||
if (atBottom) inner.style.bottom = `${offset}px`; // 贴底:固定边距写 bottom,top 保持空
|
||||
else inner.style.top = `${offset}px`;
|
||||
inner.style.left = `${box.left}px`;
|
||||
inner.style.width = `${box.width}px`;
|
||||
} else {
|
||||
inner.classList.remove('is-fixed');
|
||||
inner.style.top = '';
|
||||
inner.style.bottom = '';
|
||||
inner.style.width = '';
|
||||
wrap.style.height = '';
|
||||
}
|
||||
};
|
||||
|
||||
watchViewport(wrap, scroller, sync); // 滚动 / 缩放后重算,节点脱离文档即自摘监听
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,15 +1,24 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AlertProps } from './types.js';
|
||||
|
||||
/*** 提示条:variant 决定语义色,可选标题与关闭按钮;关闭即退场并从 DOM 移除(内容由调用方持有) ***/
|
||||
const DEFAULT_ICON: Record<string, string> = { info: 'ℹ', success: '✓', warning: '!', danger: '✕' }; // 语义缺省图标
|
||||
|
||||
/*** 提示条:variant 决定语义色,可选标题/语义图标/关闭按钮;banner 为通栏形态,关闭即退场并从 DOM 移除 ***/
|
||||
export function alert(props: AlertProps): HTMLElement {
|
||||
const variant = props.variant ?? 'info'; // 语义档位
|
||||
const body = h(
|
||||
'div',
|
||||
{ class: 'ua-alert-body' },
|
||||
props.title ? h('div', { class: 'ua-alert-title' }, props.title) : null,
|
||||
h('div', { class: 'ua-alert-message' }, props.message),
|
||||
);
|
||||
const el = h('div', { class: `ua-alert ua-alert-${props.variant ?? 'info'}`, role: 'alert' }, body);
|
||||
const glyph = props.icon ?? (props.showIcon ? DEFAULT_ICON[variant] : null); // 自定义图标优先
|
||||
const el = h(
|
||||
'div',
|
||||
{ class: `ua-alert ua-alert-${variant}${props.banner ? ' is-banner' : ''}`, role: 'alert' },
|
||||
glyph ? h('span', { class: 'ua-alert-icon', 'aria-hidden': 'true' }, glyph) : null, // 图标纯装饰,读屏靠正文
|
||||
body,
|
||||
);
|
||||
|
||||
if (!props.closable) return el; // 纯展示形态
|
||||
el.append(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AnchorItem, AnchorProps } from './types.js';
|
||||
import { watchViewport } from './watch.js';
|
||||
|
||||
/*** 锚点导航:竖排区块链接,滚动时高亮当前区块,点击平滑跳转(尊重减少动效偏好) ***/
|
||||
export function anchor(props: AnchorProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const items = props.items; // 锚点项,href 形如 #sec-xxx
|
||||
const offset = props.offset ?? 0; // 目标顶部预留距离(吸顶栏高度)
|
||||
const scroller = props.container instanceof HTMLElement ? props.container : null; // 内滚容器:null 表示整页滚动
|
||||
const links: HTMLAnchorElement[] = []; // 与 items 一一对应的链接节点
|
||||
const root = h('nav', { class: 'ua-anchor', 'aria-label': props.label ?? '锚点导航' });
|
||||
|
||||
/*** 目标元素:href 去掉 # 后按 id 查找,找不到返回 null ***/
|
||||
const targetOf = (href: string): HTMLElement | null => document.getElementById(href.replace(/^#/, ''));
|
||||
|
||||
/*** 目标相对判定原点的垂直坐标:整页用视口坐标,容器用容器内偏移 ***/
|
||||
const topOf = (el: HTMLElement): number => (scroller
|
||||
? el.getBoundingClientRect().top - scroller.getBoundingClientRect().top
|
||||
: el.getBoundingClientRect().top);
|
||||
|
||||
/*** 当前区块:取最后一个已越过判定线的目标,全未越过时落到首项 ***/
|
||||
const current = (): AnchorItem | undefined => {
|
||||
let hit: AnchorItem | undefined = items[0];
|
||||
for (const item of items) {
|
||||
const el = targetOf(item.href);
|
||||
if (!el) continue; // 卫语句:目标不存在就跳过,不打断整表判定
|
||||
if (topOf(el) - offset <= 1) hit = item;
|
||||
}
|
||||
return hit;
|
||||
};
|
||||
|
||||
/*** 同步高亮:类名与 aria-current 一起切换,读屏能念出当前所在区块 ***/
|
||||
const sync = (): void => {
|
||||
const hit = current();
|
||||
links.forEach((link, i) => {
|
||||
const on = items[i] === hit;
|
||||
link.classList.toggle('is-active', on);
|
||||
if (on) link.setAttribute('aria-current', 'true');
|
||||
else link.removeAttribute('aria-current');
|
||||
});
|
||||
};
|
||||
|
||||
/*** 跳转:按偏移量滚动到位并回写 hash,减少动效偏好下改瞬移 ***/
|
||||
const jump = (item: AnchorItem): void => {
|
||||
const el = targetOf(item.href);
|
||||
if (!el) return; // 卫语句:目标不在页面上就什么都不做
|
||||
const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto';
|
||||
const top = topOf(el) + (scroller ? scroller.scrollTop : window.scrollY) - offset;
|
||||
if (scroller) scroller.scrollTo({ top, behavior });
|
||||
else window.scrollTo({ top, behavior });
|
||||
history.replaceState(null, '', item.href); // 用 replaceState:跳转不堆积历史条目
|
||||
props.onChange?.(item.href);
|
||||
sync();
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const link = h('a', {
|
||||
class: item.disabled ? 'ua-anchor-link is-disabled' : 'ua-anchor-link',
|
||||
href: item.href,
|
||||
'aria-disabled': item.disabled ? 'true' : undefined,
|
||||
onClick: (e: Event) => {
|
||||
e.preventDefault(); // 一律接管:自绘偏移量,不走浏览器默认锚点定位
|
||||
if (item.disabled) return; // 卫语句:禁用项只吞掉点击
|
||||
jump(item);
|
||||
},
|
||||
}, item.title) as HTMLAnchorElement;
|
||||
links.push(link);
|
||||
root.append(link);
|
||||
}
|
||||
|
||||
const host: HTMLElement | Window = scroller ?? window;
|
||||
watchViewport(root, host, sync); // 滚动 / 缩放后同步高亮,节点脱离文档即自摘监听
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import type { AutoCompleteOption, AutoCompleteProps } from './types.js';
|
||||
|
||||
let seq = 0; // 实例序号:给弹层列表一个稳定 id,供 aria-controls 关联
|
||||
|
||||
/*** 自动补全:输入即过滤候选项,↑↓ 高亮 / Enter 选中 / Esc 收起,value 信号双向绑定 ***/
|
||||
export function autoComplete(props: AutoCompleteProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value: Signal<string> = props.value ?? signal(''); // 输入值
|
||||
const open = signal(false); // 弹层开合
|
||||
const query = signal(''); // 当前过滤关键字(与 value 同步,独立信号便于受控写入)
|
||||
const active = signal(-1); // 键盘高亮下标,-1 表示无高亮
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
const max = props.maxItems ?? 8;
|
||||
|
||||
/*** 按关键字取候选:函数式入参交给调用方计算(可远程联想),结果截断到 maxItems ***/
|
||||
const candidates = (): AutoCompleteOption[] => {
|
||||
const all = typeof props.options === 'function' ? props.options(query()) : props.options;
|
||||
const q = query().trim().toLowerCase();
|
||||
const hit = q ? all.filter((o) => o.value.toLowerCase().includes(q)) : all; // 包含匹配,大小写不敏感
|
||||
return hit.slice(0, max);
|
||||
};
|
||||
|
||||
const input = h('input', {
|
||||
type: 'text',
|
||||
class: 'ua-autocomplete-input',
|
||||
placeholder: props.placeholder ?? '',
|
||||
'aria-label': props.placeholder ?? '自动补全',
|
||||
role: 'combobox',
|
||||
'aria-expanded': () => String(open()),
|
||||
'aria-autocomplete': 'list',
|
||||
disabled: props.disabled,
|
||||
value: () => value(), // 外部写入信号时回填输入框
|
||||
oninput: (e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
value(v);
|
||||
query(v);
|
||||
active(-1);
|
||||
open(v.trim().length > 0); // 空串收起,避免开屏就弹
|
||||
props.onInput?.(v);
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
|
||||
const list = h('ul', { class: 'ua-autocomplete-list', role: 'listbox', 'aria-label': '输入建议', id: `ua-ac-${(seq += 1)}` }) as HTMLElement;
|
||||
input.setAttribute('aria-controls', list.id);
|
||||
const pop = h('div', { class: () => `ua-autocomplete-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement;
|
||||
const root = h('div', { class: 'ua-autocomplete' }, input, pop) as HTMLElement;
|
||||
|
||||
/*** 选中候选:写值 + 收起 + 回调,焦点回到输入框便于继续输入 ***/
|
||||
const pick = (opt: AutoCompleteOption): void => {
|
||||
value(opt.value);
|
||||
query(opt.value);
|
||||
open(false);
|
||||
active(-1);
|
||||
props.onSelect?.(opt);
|
||||
input.focus();
|
||||
};
|
||||
|
||||
/*** 重绘候选行:高亮项由 active 决定,空结果渲染提示行 ***/
|
||||
const render = (): void => {
|
||||
const opts = candidates();
|
||||
if (!opts.length) {
|
||||
list.replaceChildren(h('li', { class: 'ua-autocomplete-empty' }, props.emptyText ?? '无匹配项'));
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
return;
|
||||
}
|
||||
const rows = opts.map((opt, i) => {
|
||||
const row = h(
|
||||
'li',
|
||||
{
|
||||
class: () => `ua-autocomplete-opt${active() === i ? ' is-active' : ''}`,
|
||||
role: 'option',
|
||||
id: `${list.id}-${i}`,
|
||||
'aria-selected': String(active() === i),
|
||||
onmousedown: (e: Event) => { e.preventDefault(); pick(opt); }, // 阻止默认:保住输入框焦点,blur 后仍能选中
|
||||
},
|
||||
h('span', { class: 'ua-autocomplete-value' }, opt.value),
|
||||
opt.hint ? h('span', { class: 'ua-autocomplete-hint' }, opt.hint) : null,
|
||||
);
|
||||
return row;
|
||||
});
|
||||
list.replaceChildren(...rows);
|
||||
const cur = rows[active()];
|
||||
if (cur) input.setAttribute('aria-activedescendant', cur.id); // 读屏跟随高亮
|
||||
else input.removeAttribute('aria-activedescendant');
|
||||
};
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const opts = candidates();
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault(); // 阻止光标跳行
|
||||
if (!open() && opts.length) { open(true); active(0); return; } // 未展开:先展开并高亮首项
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
active((active() + dir + opts.length) % opts.length); // 环形移动
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && open() && active() >= 0) { e.preventDefault(); pick(opts[active()]); return; } // 有高亮才回车选中
|
||||
if (e.key === 'Tab') { open(false); return; } // 失焦前收起,避免残留浮层
|
||||
});
|
||||
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(render); // 依赖 query / active / options,任一变化即重绘
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(input, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(input, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
}); // 空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { AvatarProps } from './types.js';
|
||||
|
||||
/*** 头像:有 src 渲染图片(alt 用名称),否则用名称首字做占位;尺寸与语义底色由 props 决定 ***/
|
||||
/*** 头像:有 src 渲染图片(alt 用名称),否则用名称首字做占位;src 加载失败就地回退文字头像,尺寸与语义底色不变 ***/
|
||||
export function avatar(props: AvatarProps): HTMLElement {
|
||||
const size = `${props.size ?? 32}px`;
|
||||
const textAvatar = (): HTMLElement =>
|
||||
h('span', { class: 'ua-avatar-text', role: 'img', 'aria-label': props.name }, firstChar(props.name)); // 文字头像补 role=img,aria-label 才会被读屏当名称播报
|
||||
const img = props.src ? h('img', { class: 'ua-avatar-img', src: props.src, alt: props.name }) : null;
|
||||
img?.addEventListener('error', () => img.replaceWith(textAvatar())); // 破图兜底:碎图标比空白更刺眼,回退首字占位
|
||||
return h(
|
||||
'span',
|
||||
{ class: `ua-avatar ua-avatar-${props.variant ?? 'default'}`, style: `width:${size};height:${size}`, title: props.name },
|
||||
props.src
|
||||
? h('img', { class: 'ua-avatar-img', src: props.src, alt: props.name })
|
||||
: h('span', { class: 'ua-avatar-text', 'aria-label': props.name }, firstChar(props.name)),
|
||||
img ?? textAvatar(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { avatar } from './avatar.js';
|
||||
import type { AvatarGroupProps } from './types.js';
|
||||
|
||||
/*** 头像组:横向叠放(负边距 + 底色描边切口),超出 max 折叠成 +N,整组 title 列出全部姓名 ***/
|
||||
export function avatarGroup(props: AvatarGroupProps): HTMLElement {
|
||||
const size = props.size ?? 32; // 直径与单个 avatar 一致
|
||||
const max = Math.max(props.max ?? 4, 1); // 至少留一个位置,避免传 0 后只剩 +N
|
||||
const shown = props.items.slice(0, max); // 展示前 max 个
|
||||
const rest = props.items.length - shown.length;
|
||||
const group = h('div', {
|
||||
class: 'ua-avatar-group',
|
||||
title: props.items.map((item) => item.name).join('、'), // 折叠后仍能看到全部成员
|
||||
}, ...shown.map((item) => avatar({ name: item.name, src: item.src, variant: item.variant, size })));
|
||||
if (rest <= 0) return group; // 卫语句:没超上限就不渲染 +N
|
||||
group.append(h('span', {
|
||||
class: 'ua-avatar ua-avatar-more',
|
||||
style: `width:${size}px;height:${size}px;font-size:${Math.round(size * 0.38)}px`,
|
||||
'aria-label': `还有 ${rest} 人`, // 纯文本 +N 读屏会念成「加 3」,补可访问名
|
||||
}, `+${rest}`));
|
||||
return group;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { BackTopProps } from './types.js';
|
||||
import { watchViewport } from './watch.js';
|
||||
|
||||
/*** 返回顶部:滚动越过阈值浮现,环形进度显示已滚比例,点击平滑回顶(尊重减少动效偏好) ***/
|
||||
export function backTop(props: BackTopProps = {}): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const threshold = props.threshold ?? 300; // 浮现阈值
|
||||
const scroller = props.target ?? window; // 滚动容器
|
||||
const el = props.target instanceof HTMLElement ? props.target : null; // 元素容器:null 表示整页
|
||||
const ring = h('span', { class: 'ua-backtop-ring', 'aria-hidden': 'true' }); // 进度环(纯装饰)
|
||||
const btn = h(
|
||||
'button',
|
||||
{ type: 'button', class: 'ua-backtop', 'aria-label': '回到顶部', onClick: () => jump() },
|
||||
ring,
|
||||
h('span', { class: 'ua-backtop-icon', 'aria-hidden': 'true' }, '↑'),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
/*** 当前滚动位置:整页用 window.scrollY,容器用 scrollTop ***/
|
||||
const offset = (): number => (el ? el.scrollTop : window.scrollY);
|
||||
|
||||
/*** 可滚动总距离:不足 0 时按 0 处理,避免除零与负比例 ***/
|
||||
const distance = (): number => (el ? el.scrollHeight - el.clientHeight : document.documentElement.scrollHeight - window.innerHeight) || 0;
|
||||
|
||||
/*** 同步浮现态与进度环:仅在阈值上下切换时才改类名,减少样式重算 ***/
|
||||
const sync = (): void => {
|
||||
const y = offset();
|
||||
const total = distance();
|
||||
btn.classList.toggle('is-visible', y > threshold); // 越过阈值才浮现
|
||||
if (props.progress === false) return; // 卫语句:关掉进度环就不再算角度
|
||||
const deg = total > 0 ? Math.min(Math.round((y / total) * 360), 360) : 0;
|
||||
ring.style.background = `conic-gradient(var(--ua-color-accent-contrast) ${deg}deg, transparent 0)`; // 已滚比例用纯色弧
|
||||
};
|
||||
|
||||
/*** 回顶:平滑滚动,用户偏好减少动效时直接跳转 ***/
|
||||
const jump = (): void => {
|
||||
const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (el) el.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
|
||||
else window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
|
||||
props.onClick?.();
|
||||
};
|
||||
|
||||
watchViewport(btn, scroller, sync); // 滚动 / 缩放后同步浮现态,节点脱离文档即自摘监听
|
||||
return btn;
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { BadgeProps } from './types.js';
|
||||
|
||||
/*** 徽标:variant 决定语义色,纯展示不持有状态 ***/
|
||||
/*** 徽标:文字 / 数字角标 / 纯红点三种形态,variant 决定语义色,纯展示不持有状态 ***/
|
||||
export function badge(props: BadgeProps): HTMLElement {
|
||||
return h('span', { class: `ua-badge ua-badge-${props.variant ?? 'default'}` }, props.label);
|
||||
const tone = `ua-badge ua-badge-${props.variant ?? 'default'}`;
|
||||
if (props.dot) {
|
||||
// 红点:无可见文字,靠 aria-label 给出含义(如「有未读」)
|
||||
return h('span', { class: `${tone} ua-badge-dot`, role: 'img', 'aria-label': props.label ?? '有更新' });
|
||||
}
|
||||
if (props.count != null) {
|
||||
const max = props.max ?? 99; // 缺省上限 99
|
||||
const shown = props.count > max ? `${max}+` : String(Math.max(0, props.count)); // 负数归零,超限折成 N+
|
||||
return h('span', { class: `${tone} ua-badge-count`, 'aria-label': props.label ? `${props.label} ${shown}` : undefined }, shown);
|
||||
}
|
||||
return h('span', { class: tone }, props.label);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { BreadcrumbProps } from './types.js';
|
||||
|
||||
/*** 面包屑:nav + ol 语义结构,末项为当前页(不可点、aria-current);有 href 渲染 <a>,无 href 渲染 button 交 onNavigate ***/
|
||||
/*** 面包屑:nav + ol 语义结构,末项为当前页(不可点、aria-current);条目超过 maxItems 时把中段折叠成「…」按钮 ***/
|
||||
export function breadcrumb(props: BreadcrumbProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const items = props.items;
|
||||
const sep = props.separator ?? '/';
|
||||
const sep = props.separator ?? '/'; // 分隔符
|
||||
const max = props.maxItems ?? Number.POSITIVE_INFINITY; // 展示条数上限:超出折叠中段
|
||||
const expanded = signal(false); // 折叠段是否已展开(展开后不再折叠)
|
||||
const list = h('ol', { class: 'ua-crumb-list' });
|
||||
|
||||
const entries = items.map((item, i) => {
|
||||
const last = i === items.length - 1; // 末项即当前页
|
||||
const node = last
|
||||
? h('span', { class: 'ua-crumb-current', 'aria-current': 'page' }, item.label)
|
||||
: item.href
|
||||
? h('a', { class: 'ua-crumb-link', href: item.href }, item.label)
|
||||
: h('button', { type: 'button', class: 'ua-crumb-link', onClick: () => props.onNavigate?.(item, i) }, item.label);
|
||||
return h('li', { class: 'ua-crumb-item' }, node, last ? null : h('span', { class: 'ua-crumb-sep', 'aria-hidden': 'true' }, sep));
|
||||
});
|
||||
/*** 逐项渲染:有 href 用 <a>,无 href 用 button 交 onNavigate;折叠按钮紧跟首项插入一次 ***/
|
||||
const render = (): void => {
|
||||
const fold = !expanded() && items.length > max; // 是否需要折叠
|
||||
const tail = Math.max(2, max - 1); // 尾部保留条数:至少当前页 + 上一级
|
||||
const kept = fold ? [0, ...items.slice(-tail).map((_, k) => items.length - tail + k)] : items.map((_, i) => i);
|
||||
const nodes: HTMLElement[] = [];
|
||||
kept.forEach((idx, pos) => {
|
||||
const item = items[idx];
|
||||
const last = idx === items.length - 1; // 末项即当前页
|
||||
const node = last
|
||||
? h('span', { class: 'ua-crumb-current', 'aria-current': 'page' }, item.label)
|
||||
: item.href
|
||||
? h('a', { class: 'ua-crumb-link', href: item.href }, item.label)
|
||||
: h('button', { type: 'button', class: 'ua-crumb-link', onClick: () => props.onNavigate?.(item, idx) }, item.label);
|
||||
nodes.push(h('li', { class: 'ua-crumb-item' }, node, last ? null : h('span', { class: 'ua-crumb-sep', 'aria-hidden': 'true' }, sep)));
|
||||
if (!fold || pos > 0) return; // 卫语句:折叠按钮只插一次
|
||||
const hidden = items.length - kept.length; // 被折叠的条数
|
||||
nodes.push(h('li', { class: 'ua-crumb-item' }, h(
|
||||
'button',
|
||||
{ type: 'button', class: 'ua-crumb-fold', 'aria-label': `展开折叠的 ${hidden} 项`, onClick: () => expanded(true) },
|
||||
'…',
|
||||
), h('span', { class: 'ua-crumb-sep', 'aria-hidden': 'true' }, sep)));
|
||||
});
|
||||
list.replaceChildren(...nodes);
|
||||
};
|
||||
|
||||
return h('nav', { class: 'ua-breadcrumb', 'aria-label': '面包屑导航' }, h('ol', { class: 'ua-crumb-list' }, ...entries));
|
||||
effect(render); // expanded 变化即重绘
|
||||
return h('nav', { class: 'ua-breadcrumb', 'aria-label': '面包屑导航' }, list);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { ButtonProps } from './types.js';
|
||||
|
||||
/*** 按钮:variant 控制视觉强度,disabled 支持布尔或信号禁用,onClick 为点击回调 ***/
|
||||
/*** 按钮:variant 控制视觉强度,disabled / loading 支持布尔或信号动态同步(loading 转圈并禁点),onClick 为点击回调 ***/
|
||||
export function button(props: ButtonProps): HTMLElement {
|
||||
return h(
|
||||
const loading = typeof props.loading === 'function' ? props.loading : signal(!!props.loading); // 归一成信号:布尔也走同一响应式路径
|
||||
const spin = h('span', { class: 'ua-spinner ua-btn-spin', 'aria-hidden': 'true' }); // 转圈指示:CSS 默认 display:none,加载态才显形
|
||||
const el = h(
|
||||
'button',
|
||||
{ class: `ua-btn ua-btn-${props.variant ?? 'primary'}`, disabled: props.disabled, onClick: props.onClick }, // false/undefined 移除属性,Signal 走响应式
|
||||
{
|
||||
class: `ua-btn ua-btn-${props.variant ?? 'primary'}${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`,
|
||||
type: props.type ?? 'button',
|
||||
disabled: () => (typeof props.disabled === 'function' ? props.disabled() : !!props.disabled) || loading(), // 禁点口径:显式禁用或加载中
|
||||
'aria-busy': () => (loading() ? 'true' : null), // 读屏播报忙碌态(null 移除属性)
|
||||
onClick: props.onClick,
|
||||
}, // 缺省 button:表单内不隐式提交
|
||||
spin,
|
||||
props.label,
|
||||
);
|
||||
effect(() => el.classList.toggle('is-loading', loading())); // 加载态只切类名,不整棵重渲染(保住焦点与子节点)
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { CalendarProps } from './types.js';
|
||||
import { fmt, monthGrid, pad, parse, shiftDay, today } from './dateKit.js';
|
||||
|
||||
/*** 月历:整月网格直接内嵌在页面里(排期表 / 值班表 / 日程选择),每日可带标记文案,方向键漫游 + Enter 选中 ***/
|
||||
export function calendar(props: CalendarProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value = props.value ?? signal(''); // 选中日期(YYYY-MM-DD)
|
||||
const from = parse(value()) ?? today(); // 初始定位:跟随选中值,否则今天
|
||||
const month = props.month ?? signal(`${from[0]}-${pad(from[1] + 1)}`); // 展示月份(YYYY-MM)
|
||||
const focus = signal(value() || fmt(...today())); // 键盘漫游焦点日
|
||||
const grid = h('div', { class: 'ua-calendar-grid', role: 'grid' });
|
||||
const label = h('span', { class: 'ua-calendar-label' });
|
||||
|
||||
/*** 当前展示的年月:月份串非法时回落到初始定位 ***/
|
||||
const view = (): [number, number] => {
|
||||
const hit = /^(\d{4})-(\d{2})$/.exec(month());
|
||||
return hit ? [Number(hit[1]), Number(hit[2]) - 1] : [from[0], from[1]];
|
||||
};
|
||||
|
||||
/*** 翻月:只改展示,不动选中值;跨年自动进位 ***/
|
||||
const turn = (delta: number): void => {
|
||||
const [y, m] = view();
|
||||
const d = new Date(y, m + delta, 1);
|
||||
const next = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
||||
month(next);
|
||||
props.onMonthChange?.(next);
|
||||
};
|
||||
|
||||
/*** 选择:写回信号 + 回调,焦点同步到该日 ***/
|
||||
const pick = (date: string): void => {
|
||||
value(date);
|
||||
focus(date);
|
||||
props.onSelect?.(date);
|
||||
};
|
||||
|
||||
/*** 重绘网格:月初留白、今天与选中分别打标,漫游 tabindex 保证 Tab 只进一次 ***/
|
||||
const render = (): void => {
|
||||
const [y, m] = view();
|
||||
const { lead, total } = monthGrid(y, m);
|
||||
const cells: HTMLElement[] = Array.from({ length: lead }, () => h('span', { class: 'ua-calendar-blank' }));
|
||||
const nowKey = fmt(...today());
|
||||
for (let d = 1; d <= total; d++) {
|
||||
const date = fmt(y, m, d);
|
||||
const mark = props.marker?.(date) ?? null;
|
||||
const cls = `ua-calendar-day${date === value() ? ' is-selected' : ''}${date === nowKey ? ' is-today' : ''}`;
|
||||
cells.push(h('button', {
|
||||
type: 'button',
|
||||
class: cls,
|
||||
role: 'gridcell',
|
||||
'aria-selected': String(date === value()),
|
||||
tabindex: date === focus() ? '0' : '-1',
|
||||
'aria-label': mark ? `${date},${mark}` : date, // 标记文案同时进无障碍名,读屏也能听到「几项」
|
||||
onClick: () => pick(date),
|
||||
}, h('span', { class: 'ua-calendar-num' }, String(d)), mark ? h('span', { class: 'ua-calendar-mark' }, mark) : null));
|
||||
}
|
||||
grid.replaceChildren(...cells);
|
||||
label.textContent = `${y} 年 ${m + 1} 月`;
|
||||
};
|
||||
|
||||
// —— 键盘:←→ 逐日、↑↓ 逐周、Enter / 空格 选中;跨月自动翻页后把焦点交给新网格 ——
|
||||
grid.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
|
||||
if (!step[e.key]) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return; // 卫语句:只接管选中键
|
||||
e.preventDefault();
|
||||
pick(focus());
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const next = shiftDay(focus(), step[e.key]);
|
||||
const p = parse(next) ?? today();
|
||||
focus(next);
|
||||
const [y, m] = view();
|
||||
if (p[0] !== y || p[1] !== m) month(`${p[0]}-${pad(p[1] + 1)}`); // 跨月:先翻页,重绘交给 effect
|
||||
requestAnimationFrame(() => (grid.querySelector('.ua-calendar-day[tabindex="0"]') as HTMLElement | null)?.focus());
|
||||
});
|
||||
|
||||
const head = h(
|
||||
'div',
|
||||
{ class: 'ua-calendar-head' },
|
||||
h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'),
|
||||
label,
|
||||
h('button', { type: 'button', class: 'ua-calendar-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'),
|
||||
);
|
||||
const week = h('div', { class: 'ua-calendar-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w)));
|
||||
effect(render); // month / value / focus 任一变化即重绘
|
||||
return h('div', { class: 'ua-calendar' }, head, week, grid);
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { CardProps } from './types.js';
|
||||
|
||||
/*** 卡片容器:可选标题 + 任意子内容 ***/
|
||||
/*** 卡片容器:可选标题行(含右侧操作区)+ 任意子内容 + 可选底部操作区,bordered / hoverable 控制外观 ***/
|
||||
export function card(props: CardProps, ...children: unknown[]): HTMLElement {
|
||||
const cls = `ua-card${props.bordered === false ? ' ua-card-borderless' : ''}${props.hoverable ? ' ua-card-hoverable' : ''}`;
|
||||
const head = props.title || props.extra
|
||||
? h(
|
||||
'div',
|
||||
{ class: 'ua-card-head' },
|
||||
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : h('span', {}), // 无标题时占位,保证操作区仍靠右
|
||||
props.extra ? h('span', { class: 'ua-card-extra' }, props.extra) : null,
|
||||
)
|
||||
: null;
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-card' },
|
||||
props.title ? h('h3', { class: 'ua-card-title' }, props.title) : null,
|
||||
{ class: cls },
|
||||
head,
|
||||
...children,
|
||||
);
|
||||
props.footer ? h('div', { class: 'ua-card-foot' }, props.footer) : null, // 底部操作区:右对齐,仅传了才渲染
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { CarouselProps } from './types.js';
|
||||
|
||||
/*** 轮播:items 横排 + translateX 位移;指示点 / 左右箭头 / ←→ 键切换,autoplay 毫秒自动前进且悬停暂停 ***/
|
||||
export function carousel(props: CarouselProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const total = props.items.length;
|
||||
const index = props.index ?? signal(0); // 未传外部信号时用内部信号,行为一致
|
||||
const wrap = (i: number) => (total ? ((i % total) + total) % total : 0); // 环形取模,负数也回绕
|
||||
const dots: HTMLButtonElement[] = []; // 指示点,顺序即下标
|
||||
const track = h('div', { class: 'ua-carousel-track' }) as HTMLElement; // 幻灯片轨道,靠 transform 位移
|
||||
const dotsBar = h('div', { class: 'ua-carousel-dots' }) as HTMLElement;
|
||||
|
||||
/*** 跳到指定下标(自动环形),并回传 onChange ***/
|
||||
const go = (i: number) => {
|
||||
if (!total) return; // 卫语句:无内容不动作
|
||||
index(wrap(i));
|
||||
props.onChange?.(index());
|
||||
};
|
||||
|
||||
props.items.forEach((item, i) => {
|
||||
track.append(h('div', { class: 'ua-carousel-slide', role: 'group', 'aria-roledescription': '幻灯片', 'aria-label': `第 ${i + 1} 张,共 ${total} 张` }, item));
|
||||
const dot = h('button', { type: 'button', class: 'ua-carousel-dot', 'aria-label': `跳到第 ${i + 1} 张`, onClick: () => go(i) }) as HTMLButtonElement;
|
||||
dots.push(dot);
|
||||
dotsBar.append(dot);
|
||||
});
|
||||
|
||||
/*** 生成左右箭头 ***/
|
||||
const arrow = (dir: 1 | -1, label: string) =>
|
||||
h('button', { type: 'button', class: `ua-carousel-arrow ua-carousel-${dir === 1 ? 'next' : 'prev'}`, 'aria-label': label, onClick: () => go(index() + dir) }, dir === 1 ? '›' : '‹');
|
||||
|
||||
const root = h(
|
||||
'div',
|
||||
{ class: 'ua-carousel', tabindex: '0', role: 'region', 'aria-roledescription': '轮播', 'aria-label': props.label ?? '轮播' },
|
||||
track,
|
||||
arrow(-1, '上一张'),
|
||||
arrow(1, '下一张'),
|
||||
dotsBar,
|
||||
) as HTMLElement;
|
||||
if (props.height) root.style.height = `${props.height}px`; // 固定高度由调用方指定,缺省随内容
|
||||
|
||||
effect(() => {
|
||||
const i = wrap(index());
|
||||
track.style.transform = `translateX(${-i * 100}%)`; // 整屏位移,配合 CSS transition 出滑动效果
|
||||
dots.forEach((dot, k) => {
|
||||
dot.classList.toggle('ua-carousel-dot-active', k === i);
|
||||
dot.setAttribute('aria-current', String(k === i)); // 读屏标注当前张
|
||||
});
|
||||
});
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; // 卫语句:只接管方向键
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 不冒泡:避免外层 tabs/menu 同时响应
|
||||
go(index() + (e.key === 'ArrowRight' ? 1 : -1));
|
||||
});
|
||||
|
||||
if (props.autoplay && total > 1) {
|
||||
let hover = false; // 悬停中
|
||||
let focused = false; // 焦点在轮播内(键盘用户没有 hover,必须单独计)
|
||||
root.addEventListener('pointerenter', () => { hover = true; });
|
||||
root.addEventListener('pointerleave', () => { hover = false; });
|
||||
root.addEventListener('focusin', () => { focused = true; });
|
||||
root.addEventListener('focusout', () => { focused = false; });
|
||||
window.setInterval(() => { if (!hover && !focused) go(index() + 1); }, props.autoplay); // 两者都不在才前进
|
||||
}
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import type { CascaderOption, CascaderProps } from './types.js';
|
||||
|
||||
/*** 沿路径解析节点链:路径失效时在断点截断,避免脏数据把列算错 ***/
|
||||
function chain(options: CascaderOption[], path: string[]): CascaderOption[] {
|
||||
const out: CascaderOption[] = [];
|
||||
let level = options;
|
||||
for (const v of path) {
|
||||
const hit = level.find((o) => o.value === v);
|
||||
if (!hit) break;
|
||||
out.push(hit);
|
||||
level = hit.children ?? [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 级联选择:多列联动面板,悬停下钻 / 点击提交,value 信号双向绑定,Esc 与弹层栈共用逐层关闭 ***/
|
||||
export function cascader(props: CascaderProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 面板开合
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位:空间不足自动翻转
|
||||
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 已提交路径
|
||||
const path = signal<string[]>([]); // 浏览路径(悬停/下钻都会改,不等于已提交)
|
||||
const sep = props.separator ?? ' / '; // 路径分隔符
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
|
||||
const panels = h('div', { class: 'ua-cascader-panels', role: 'menu' }) as HTMLElement; // 多列容器
|
||||
const labelText = (): string => chain(props.options, value()).map((o) => o.label).join(sep); // 触发器文案
|
||||
|
||||
/*** 由浏览路径推出各列选项:根列恒在,逐级 append 子级 ***/
|
||||
const columns = (): CascaderOption[][] => {
|
||||
const out: CascaderOption[][] = [props.options];
|
||||
for (let d = 0; d < path().length; d++) {
|
||||
const node = out[d].find((o) => o.value === path()[d]);
|
||||
if (!node?.children?.length) break; // 断在叶子:后续不再出列
|
||||
out.push(node.children);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/*** 提交路径:写信号 + 回调 + 收起并把焦点还给触发器 ***/
|
||||
const commit = (p: string[]): void => {
|
||||
value(p);
|
||||
props.onChange?.([...p]);
|
||||
open(false);
|
||||
trigger.focus();
|
||||
};
|
||||
|
||||
/*** 造一列:当前路径高亮、已提交路径打标、有子级显示下钻箭头 ***/
|
||||
const column = (opts: CascaderOption[], depth: number): HTMLElement => {
|
||||
const cur = path()[depth]; // 本列浏览中的值
|
||||
const done = value()[depth]; // 本列已提交的值
|
||||
const rows = opts.map((o) => {
|
||||
const hasChild = Boolean(o.children?.length);
|
||||
const cls = `ua-cascader-opt${o.value === cur ? ' is-active' : ''}${o.value === done ? ' is-picked' : ''}${o.disabled ? ' is-disabled' : ''}`;
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: cls,
|
||||
role: 'menuitem',
|
||||
'aria-haspopup': hasChild ? 'true' : 'false',
|
||||
disabled: o.disabled === true, // 禁用项既不可选也不可下钻
|
||||
onClick: () => {
|
||||
path([...path().slice(0, depth), o.value]); // 点选即把路径截断到本列
|
||||
if (!hasChild || props.changeOnSelect) commit([...path().slice(0, depth), o.value]); // 叶子或允许中间层:立即提交
|
||||
},
|
||||
onmouseenter: () => {
|
||||
if (o.disabled || o.value === cur) return; // 卫语句:禁用项与当前项不触发联动
|
||||
path([...path().slice(0, depth), o.value]);
|
||||
},
|
||||
},
|
||||
h('span', { class: 'ua-cascader-label' }, o.label),
|
||||
hasChild ? h('span', { class: 'ua-cascader-arrow', 'aria-hidden': 'true' }, '›') : null,
|
||||
);
|
||||
});
|
||||
return h('ul', { class: 'ua-cascader-col', role: 'group' }, ...rows) as HTMLElement;
|
||||
};
|
||||
|
||||
/*** 重绘所有列:列数随浏览深度增长 ***/
|
||||
const render = (): void => {
|
||||
const cols = columns();
|
||||
panels.replaceChildren(...cols.map((opts, d) => column(opts, d)));
|
||||
};
|
||||
|
||||
/*** 把焦点送到某列第 i 项(重建后 DOM 已换,必须等下一帧) ***/
|
||||
const focusCol = (depth: number, i = 0): void => {
|
||||
requestAnimationFrame(() => {
|
||||
const ul = panels.children[depth] as HTMLElement | undefined;
|
||||
if (!ul) return; // 卫语句:列不存在
|
||||
(ul.children[Math.min(i, ul.children.length - 1)] as HTMLElement | undefined)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// —— 触发器 ——
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-cascader-trigger',
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled,
|
||||
onClick: () => { if (!open()) path([...value()]); open(!open()); },
|
||||
},
|
||||
h('span', { class: () => `ua-cascader-value${value().length ? '' : ' is-empty'}` }, () => labelText() || props.placeholder || '请选择'),
|
||||
h('span', { class: 'ua-cascader-caret', 'aria-hidden': 'true' }, '▾'),
|
||||
);
|
||||
|
||||
const pop = h('div', { class: () => `ua-cascader-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, panels);
|
||||
const root = h('div', { class: () => `ua-cascader${open() ? ' ua-open' : ''}` }, trigger, pop);
|
||||
|
||||
// 面板键盘导航:↑↓ 同列移动、→ 下钻、← 回退、Enter 提交,Esc 交给弹层栈
|
||||
panels.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const btn = document.activeElement as HTMLElement | null;
|
||||
const ul = btn?.parentElement as HTMLElement | null;
|
||||
if (!ul?.classList.contains('ua-cascader-col') || !btn) return; // 卫语句:焦点不在选项上
|
||||
const siblings = Array.from(ul.children); // 列内项,HTMLCollection 不能直接展开
|
||||
const depth = Array.from(panels.children).indexOf(ul); // 当前列序号
|
||||
const at = siblings.indexOf(btn);
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const next = siblings[(at + (e.key === 'ArrowDown' ? 1 : -1) + siblings.length) % siblings.length] as HTMLElement;
|
||||
next?.focus(); // 列内环形移动
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); btn.click(); if (open()) focusCol(depth + 1); return; } // 下钻并聚焦新列首项
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); focusCol(Math.max(depth - 1, 0), at); return; } // 回退到上一列同序项
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } // 与鼠标点击同语义
|
||||
});
|
||||
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(render); // 依赖 open / path / value,任一变化即重绘
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈:与弹窗/抽屉共用逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
}); // 空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { CheckboxProps } from './types.js';
|
||||
|
||||
/*** 复选框:checked 信号双向绑定,disabled 支持布尔或信号禁用,onChange 为变更回调 ***/
|
||||
/*** 复选框:checked 信号双向绑定,indeterminate 半选态(视觉与 mixed 播报),disabled 支持布尔或信号禁用,onChange 为变更回调 ***/
|
||||
export function checkbox(props: CheckboxProps): HTMLElement {
|
||||
const el = h('input', { type: 'checkbox', class: 'ua-checkbox', disabled: props.disabled }) as HTMLInputElement; // false/undefined 移除属性,Signal 走响应式
|
||||
if (props.checked) {
|
||||
@@ -11,6 +11,10 @@ export function checkbox(props: CheckboxProps): HTMLElement {
|
||||
if (el.checked !== checked()) el.checked = checked(); // 外部变化写回勾选框
|
||||
});
|
||||
}
|
||||
if (props.indeterminate !== undefined) {
|
||||
const ind = props.indeterminate;
|
||||
effect(() => { el.indeterminate = typeof ind === 'function' ? ind() : !!ind; }); // 半选态同步原生属性(accent-color 自动渲染横杠,读屏播报 mixed)
|
||||
}
|
||||
if (props.onChange) el.addEventListener('change', () => props.onChange!(el.checked));
|
||||
if (!props.label) return el;
|
||||
const wrap = h('label', { class: 'ua-check-row' }, el, props.label);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import type { CheckboxGroupProps } from './types.js';
|
||||
|
||||
/*** 多选组:value 数组信号双向绑定,min/max 用「禁用边界」而非报错,可选全选行(含半选) ***/
|
||||
export function checkboxGroup(props: CheckboxGroupProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 选中值
|
||||
const inputs: HTMLInputElement[] = []; // 各选项勾选框
|
||||
const dir = props.direction ?? 'horizontal'; // 排列方向
|
||||
const usable = props.options.filter((o) => !o.disabled); // 可勾选项(全选基数)
|
||||
|
||||
/*** 整组禁用:布尔直接取,信号在此读取以纳入 effect 依赖 ***/
|
||||
const groupOff = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true);
|
||||
|
||||
/*** 变更落库:写信号 + 回调,边界不在此处拒绝——由 sync 提前禁用越界选项 ***/
|
||||
const update = (next: string[]): void => {
|
||||
value(next);
|
||||
props.onChange?.([...next]);
|
||||
};
|
||||
|
||||
const all = h('input', { type: 'checkbox', class: 'ua-checkbox', 'aria-label': '全选' }) as HTMLInputElement;
|
||||
all.addEventListener('change', () => {
|
||||
update(all.checked ? usable.map((o) => o.value) : []); // 全选只覆盖未禁用项
|
||||
});
|
||||
|
||||
const rows = props.options.map((opt) => {
|
||||
const input = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
input.value = opt.value;
|
||||
input.addEventListener('change', () => {
|
||||
const cur = new Set(value());
|
||||
input.checked ? cur.add(opt.value) : cur.delete(opt.value); // 用户操作写回
|
||||
update([...cur]);
|
||||
});
|
||||
inputs.push(input);
|
||||
return h('label', { class: 'ua-check-row' }, input, opt.label);
|
||||
});
|
||||
|
||||
/*** 同步勾选与可见可用性:到上限禁用未选项、到下限禁用已选项(不改值只锁操作) ***/
|
||||
const sync = (): void => {
|
||||
const cur = value();
|
||||
const off = groupOff();
|
||||
const atMax = props.max != null && cur.length >= props.max;
|
||||
const atMin = props.min != null && cur.length <= props.min;
|
||||
props.options.forEach((opt, i) => {
|
||||
const input = inputs[i];
|
||||
const on = cur.includes(opt.value);
|
||||
input.checked = on;
|
||||
input.disabled = off || opt.disabled === true || (on ? atMin : atMax); // 边界项锁死,避免越界
|
||||
});
|
||||
const picked = usable.filter((o) => cur.includes(o.value)).length;
|
||||
all.checked = picked > 0 && picked === usable.length;
|
||||
all.indeterminate = picked > 0 && picked < usable.length; // 半选:部分已选
|
||||
all.disabled = off || (picked === usable.length && atMin); // 全选行同样受边界约束
|
||||
};
|
||||
|
||||
effect(sync);
|
||||
const body = h('div', { class: `ua-check-group ua-check-group-${dir}` }, ...(props.checkAll ? [h('label', { class: 'ua-check-row' }, all, '全选')] : []), ...rows);
|
||||
return h('div', { class: 'ua-checkbox-group', role: 'group' }, body) as HTMLElement;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { copyButton } from './copyButton.js';
|
||||
import type { CodeBlockProps } from './types.js';
|
||||
|
||||
/*** 代码块:等宽渲染多行代码,可选行号列与标题行(语言 / 文件名),右上角一键复制原文;只做展示不做高亮 ***/
|
||||
export function codeBlock(props: CodeBlockProps): HTMLElement {
|
||||
const lines = props.code.replace(/\r?\n$/, '').split(/\r?\n/); // 末尾换行不生成空行:复制走原文,显示不多一行
|
||||
const showNo = props.lineNumbers !== false && lines.length > 1; // 单行代码给行号只是噪音
|
||||
const body = h(
|
||||
'div',
|
||||
{ class: 'ua-code-body' },
|
||||
...lines.map((text, i) => h(
|
||||
'div',
|
||||
{ class: 'ua-code-line' },
|
||||
showNo ? h('span', { class: 'ua-code-no', 'aria-hidden': 'true' }, String(i + 1)) : null, // 行号是装饰,读屏跳过
|
||||
h('span', { class: 'ua-code-text' }, text || ' '), // 空行补一个空格,撑住行高
|
||||
)),
|
||||
);
|
||||
if (props.maxHeight !== undefined) body.style.maxHeight = `${props.maxHeight}px`; // 只在给了上限时限高,超出内部滚动
|
||||
const title = props.title ?? props.lang; // 标题优先用文件名,其次语言名
|
||||
const head = props.copyable === false && !title
|
||||
? null // 既无标题也不可复制:整个头部省掉,不留空条
|
||||
: h(
|
||||
'div',
|
||||
{ class: 'ua-code-head' },
|
||||
title ? h('span', { class: 'ua-code-title' }, title) : h('span', null, ''),
|
||||
props.copyable === false ? null : copyButton({ text: props.code, label: '复制', size: 'sm', variant: 'ghost', onCopied: props.onCopy }),
|
||||
);
|
||||
return h('div', { class: `ua-code${props.wrap ? ' is-wrap' : ''}`, role: 'group', 'aria-label': props.ariaLabel ?? '代码块' }, head, body); // is-wrap 只改折行,行号与缩进不动
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import type { ColorPickerProps } from './types.js';
|
||||
|
||||
// 缺省色板:直接取语义令牌,天然跟随暗色主题切换,不写死任何十六进制
|
||||
const DEFAULT_PRESETS = ['var(--ua-color-accent)', 'var(--ua-color-success)', 'var(--ua-color-warning)', 'var(--ua-color-danger)', 'var(--ua-color-text)', 'var(--ua-color-border)'];
|
||||
|
||||
/*** 颜色选择:色板点选 + 自定义取色 / 手输,value 信号双向绑定,Esc 收起,色块本身即预览 ***/
|
||||
export function colorPicker(props: ColorPickerProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value: Signal<string> = props.value ?? signal(''); // 当前颜色
|
||||
const open = signal(false); // 弹层开合
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
|
||||
const presets = props.presets ?? DEFAULT_PRESETS; // 预设色板
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
|
||||
/*** 提交颜色:写信号 + 回调 + 收起,输入框同步回填便于手改 ***/
|
||||
const commit = (color: string): void => {
|
||||
const c = color.trim();
|
||||
if (!c) return; // 卫语句:空值不提交
|
||||
value(c);
|
||||
props.onChange?.(c);
|
||||
open(false);
|
||||
trigger.focus();
|
||||
};
|
||||
|
||||
const swatch = h('span', { class: 'ua-colorpicker-swatch', 'aria-hidden': 'true' }) as HTMLElement;
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-colorpicker-trigger',
|
||||
'aria-haspopup': 'dialog',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled,
|
||||
onClick: () => open(!open()),
|
||||
},
|
||||
swatch,
|
||||
h('span', { class: () => `ua-colorpicker-value${value() ? '' : ' is-empty'}` }, () => value() || props.placeholder || '请选择颜色'),
|
||||
h('span', { class: 'ua-colorpicker-caret', 'aria-hidden': 'true' }, '▾'),
|
||||
);
|
||||
|
||||
// 预设色板:每个色块是一颗按钮,选中项描边标记
|
||||
const grid = h('div', { class: 'ua-colorpicker-grid', role: 'listbox', 'aria-label': '预设颜色' });
|
||||
const cells = presets.map((c) =>
|
||||
h('button', {
|
||||
type: 'button',
|
||||
class: () => `ua-colorpicker-cell${value() === c ? ' is-selected' : ''}`,
|
||||
role: 'option',
|
||||
'aria-selected': () => String(value() === c),
|
||||
'aria-label': c,
|
||||
title: c,
|
||||
style: `background:${c}`, // 色块底色直接取预设值(令牌或自定义色)
|
||||
onClick: () => commit(c),
|
||||
}),
|
||||
);
|
||||
grid.append(...cells);
|
||||
|
||||
// 自定义区:原生取色器负责拾色,文本框负责粘 CSS 颜色串(含 var(--ua-*))
|
||||
const native = h('input', { type: 'color', class: 'ua-colorpicker-native', 'aria-label': '取色器', value: () => (/^#[0-9a-f]{6}$/i.test(value()) ? value() : '#000000') }) as HTMLInputElement;
|
||||
native.addEventListener('input', () => commit(native.value));
|
||||
const hex = h('input', {
|
||||
type: 'text',
|
||||
class: 'ua-colorpicker-hex',
|
||||
placeholder: 'CSS 颜色,如 var(--ua-color-accent)',
|
||||
'aria-label': '颜色值',
|
||||
value: () => value(),
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return; // 卫语句:只处理回车提交
|
||||
e.preventDefault();
|
||||
commit((e.target as HTMLInputElement).value);
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
|
||||
const pop = h(
|
||||
'div',
|
||||
{ class: () => `ua-colorpicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` },
|
||||
grid,
|
||||
h('div', { class: 'ua-colorpicker-custom' }, native, hex),
|
||||
);
|
||||
const root = h('div', { class: () => `ua-colorpicker${open() ? ' ua-open' : ''}` }, trigger, pop) as HTMLElement;
|
||||
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(() => {
|
||||
swatch.style.background = value() || 'transparent'; // 触发器色块实时预览
|
||||
swatch.classList.toggle('is-empty', !value());
|
||||
});
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
|
||||
const MIN = 56; // 列宽下限:小于这个值表头文字会被压成竖排
|
||||
|
||||
/*** 列宽手柄:右缘拖动改 <col> 宽度(双击复位),← → 每次微调 8px,两者共用 apply 收口 ***/
|
||||
export function attachResize(th: HTMLElement, col: HTMLElement, start: number, label: string): void {
|
||||
// —— 状态集中声明 ——
|
||||
let dragging = false; // 是否处于拖拽中
|
||||
let originX = 0; // 按下时的指针横坐标
|
||||
let originW = 0; // 按下时的实际列宽
|
||||
|
||||
/*** 唯一写宽入口:夹取下限后写回 <col> ***/
|
||||
const apply = (w: number): void => { col.style.width = `${Math.max(MIN, Math.round(w))}px`; };
|
||||
|
||||
const grip = h('span', {
|
||||
class: 'ua-col-grip',
|
||||
role: 'separator', // 可调节分隔符语义:读屏可识别为「可调整」
|
||||
'aria-orientation': 'vertical',
|
||||
'aria-label': `调整「${label}」列宽`,
|
||||
tabindex: '0',
|
||||
onpointerdown: (e: PointerEvent) => {
|
||||
dragging = true;
|
||||
originX = e.clientX;
|
||||
originW = col.getBoundingClientRect().width; // 取实际渲染宽度,避免与 min-width 打架
|
||||
grip.setPointerCapture(e.pointerId); // 捕获指针:拖出表头也继续跟手
|
||||
e.preventDefault(); // 不触发列头排序
|
||||
e.stopPropagation();
|
||||
document.body.classList.add('ua-col-resizing'); // 拖拽期间全局禁用文本选中
|
||||
},
|
||||
onpointermove: (e: PointerEvent) => { if (dragging) apply(originW + e.clientX - originX); },
|
||||
onpointerup: stop,
|
||||
onpointercancel: stop,
|
||||
onclick: (e: MouseEvent) => e.stopPropagation(), // 点手柄不该触发排序
|
||||
ondblclick: (e: MouseEvent) => { e.stopPropagation(); apply(start); }, // 双击回到声明宽度
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; // 卫语句:只接管方向键
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 不冒泡到表格的行选择等外层快捷键
|
||||
apply(col.getBoundingClientRect().width + (e.key === 'ArrowRight' ? 8 : -8));
|
||||
},
|
||||
}) as HTMLElement;
|
||||
|
||||
/*** 拖拽收尾:复位标志与全局样式 ***/
|
||||
function stop(): void {
|
||||
if (!dragging) return; // 卫语句:不在拖拽中
|
||||
dragging = false;
|
||||
document.body.classList.remove('ua-col-resizing');
|
||||
}
|
||||
|
||||
th.append(grip);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { CommandPaletteHandle, CommandPaletteItem, CommandPaletteProps } from './types.js';
|
||||
import { lockScroll, pushOverlay } from './overlay.js';
|
||||
|
||||
let seq = 0; // 实例序号:选项与列表的 id 靠它唯一
|
||||
|
||||
/*** 作用:条目是否命中搜索词——主文案 / 辅助说明 / 额外关键词任一包含即命中(大小写不敏感) ***/
|
||||
function matches(item: CommandPaletteItem, q: string): boolean {
|
||||
if (!q) return true; // 空词不过滤
|
||||
const hay = [item.label, item.hint ?? '', ...(item.keywords ?? [])].join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
}
|
||||
|
||||
/*** 命令面板:⌘K 唤起,输入即过滤,↑↓ 选择、回车执行、Esc 收起;返回 { el, open, close } 句柄 ***/
|
||||
export function commandPalette(props: CommandPaletteProps): CommandPaletteHandle {
|
||||
// —— 状态集中声明 ——
|
||||
const uid = `ua-cmdk-${++seq}`; // 实例 id 前缀
|
||||
const query = signal(''); // 搜索词:输入事件写入,供过滤
|
||||
const active = signal(0); // 当前高亮项在「过滤后序列」中的下标
|
||||
let visible: CommandPaletteItem[] = []; // 过滤后的可见条目
|
||||
let nodes: HTMLElement[] = []; // 与 visible 同序的节点,供高亮就地切换(不整树重绘)
|
||||
let lastFocus: HTMLElement | null = null; // 打开前焦点,关闭归还
|
||||
let release: (() => void) | null = null; // Esc 栈注销函数
|
||||
let releaseLock: (() => void) | null = null; // 滚动锁解锁函数
|
||||
|
||||
// —— DOM 骨架:遮罩 → 面板 → 输入框 + 列表 ——
|
||||
const input = h('input', {
|
||||
class: 'ua-cmdk-input',
|
||||
type: 'text',
|
||||
placeholder: props.placeholder ?? '搜索命令…',
|
||||
'aria-label': props.placeholder ?? '搜索命令', // 占位文案只是提示,不是可访问名:读屏要靠这一条才知道搜的是什么
|
||||
'aria-controls': `${uid}-list`,
|
||||
role: 'combobox',
|
||||
'aria-expanded': 'true',
|
||||
}) as HTMLInputElement;
|
||||
const list = h('div', { class: 'ua-cmdk-list', id: `${uid}-list`, role: 'listbox', 'aria-label': '命令列表' });
|
||||
const panel = h('div', { class: 'ua-cmdk-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': '命令面板' }, input, list);
|
||||
const root = h('div', { class: 'ua-cmdk', onClick: (e: Event) => { if (e.target === root) close(); } }, panel);
|
||||
|
||||
/*** 选中即执行:先收起(归还焦点)再回调,避免回调里再开弹层时焦点打转 ***/
|
||||
const run = (item: CommandPaletteItem): void => {
|
||||
close();
|
||||
props.onSelect(item);
|
||||
};
|
||||
|
||||
/*** 生成一行的节点:label 左、hint 右,点击与悬停都能选中 ***/
|
||||
const row = (item: CommandPaletteItem, idx: number): HTMLElement =>
|
||||
h('div', {
|
||||
class: 'ua-cmdk-item',
|
||||
id: `${uid}-opt-${idx}`,
|
||||
role: 'option',
|
||||
'aria-selected': 'false',
|
||||
onClick: () => run(item),
|
||||
onPointermove: () => setActive(idx), // 鼠标划过即高亮,点之前就知道会选中谁
|
||||
}, h('span', { class: 'ua-cmdk-label' }, item.label), item.hint ? h('span', { class: 'ua-cmdk-hint' }, item.hint) : null);
|
||||
|
||||
/*** 高亮第 idx 项(环形):只切 class + scrollIntoView,不重建列表 ***/
|
||||
function setActive(idx: number): void {
|
||||
if (!visible.length) return; // 卫语句:空列表无需高亮
|
||||
const next = (idx + visible.length) % visible.length; // 环形:首尾相接
|
||||
active(next);
|
||||
nodes.forEach((el, i) => {
|
||||
const on = i === next;
|
||||
el.classList.toggle('is-active', on);
|
||||
el.setAttribute('aria-selected', String(on));
|
||||
});
|
||||
nodes[next].scrollIntoView({ block: 'nearest' }); // 窄容器内跟随滚动
|
||||
input.setAttribute('aria-activedescendant', `${uid}-opt-${next}`);
|
||||
}
|
||||
|
||||
/*** 按当前搜索词重绘列表:同组连续,组间插标题;无命中给空状态 ***/
|
||||
function paint(): void {
|
||||
const q = query().trim().toLowerCase();
|
||||
visible = props.items.filter((it) => matches(it, q));
|
||||
nodes = [];
|
||||
const parts: HTMLElement[] = [];
|
||||
let group = ''; // 上一个已画过的组名,用于判定是否该插标题
|
||||
visible.forEach((item, idx) => {
|
||||
if (item.group && item.group !== group) {
|
||||
group = item.group;
|
||||
parts.push(h('div', { class: 'ua-cmdk-group' }, group));
|
||||
}
|
||||
const el = row(item, idx);
|
||||
nodes.push(el);
|
||||
parts.push(el);
|
||||
});
|
||||
list.replaceChildren(...(parts.length ? parts : [h('div', { class: 'ua-cmdk-empty' }, props.emptyText ?? '没有匹配的命令')]));
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
if (visible.length) setActive(0);
|
||||
}
|
||||
|
||||
/*** 打开:清搜索词、重绘、抢焦点(写回信号,由下面的 effect 统一处理副作用) ***/
|
||||
function open(): void {
|
||||
if (props.open()) return; // 卫语句:已打开不重复触发
|
||||
props.open(true);
|
||||
}
|
||||
|
||||
/*** 关闭:写回信号即可,副作用(出栈 / 解锁 / 还焦点)由 effect 收口 ***/
|
||||
function close(): void {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发
|
||||
props.open(false);
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
query(input.value);
|
||||
paint();
|
||||
});
|
||||
|
||||
// —— 键盘:↑↓ 环形选择、Home/End 跳首尾、回车执行(Esc 交给弹层栈,保证只关最上层) ——
|
||||
panel.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(active() + 1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(active() - 1); return; }
|
||||
if (e.key === 'Home') { e.preventDefault(); setActive(0); return; }
|
||||
if (e.key === 'End') { e.preventDefault(); setActive(visible.length - 1); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const item = visible[active()];
|
||||
if (item) run(item); // 无命中时回车不做事
|
||||
}
|
||||
});
|
||||
|
||||
// —— 显隐副作用收口:焦点、Esc 栈、背景滚动锁 ——
|
||||
effect(() => {
|
||||
const on = props.open();
|
||||
root.classList.toggle('ua-open', on);
|
||||
if (on) {
|
||||
lastFocus = document.activeElement as HTMLElement | null;
|
||||
query('');
|
||||
input.value = '';
|
||||
paint();
|
||||
release ??= pushOverlay(close);
|
||||
releaseLock ??= lockScroll();
|
||||
input.focus();
|
||||
} else {
|
||||
release?.();
|
||||
release = null;
|
||||
releaseLock?.();
|
||||
releaseLock = null;
|
||||
lastFocus?.focus(); // 归还焦点:键盘用户不会被困在隐藏层里
|
||||
lastFocus = null;
|
||||
}
|
||||
});
|
||||
|
||||
// —— 全局快捷键:⌘K / Ctrl+K 切换;hotkey=false 时不注册(多实例场景由调用方自己绑) ——
|
||||
if (props.hotkey !== false) {
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; // 卫语句:只认 ⌘K / Ctrl+K
|
||||
e.preventDefault();
|
||||
if (props.open()) close(); else open(); // 再按一次收起
|
||||
});
|
||||
}
|
||||
return { el: root, open, close };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { h, signal } from '@ui-arch/frame';
|
||||
import type { CopyButtonProps } from './types.js';
|
||||
|
||||
/*** 复制按钮:点击把 text 写入剪贴板,成功后短暂换成已复制文案再还原;clipboard API 不可用时降级 execCommand ***/
|
||||
export function copyButton(props: CopyButtonProps): HTMLElement {
|
||||
const label = signal(props.label ?? '复制'); // 文案信号:成功后短暂切换(h() 对函数型子节点响应式刷新)
|
||||
let timer = 0; // 还原定时器句柄:连点时先清旧计时,避免文案提前回跳
|
||||
|
||||
/*** 写入剪贴板:标准 API 优先,失败(http 上下文 / 旧浏览器)走隐藏 textarea + execCommand 兜底 ***/
|
||||
const write = async (): Promise<void> => {
|
||||
const text = typeof props.text === 'function' ? props.text() : props.text; // 函数形式取实时值
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.append(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy'); // 降级路径:依赖临时选区
|
||||
ta.remove();
|
||||
}
|
||||
label(props.copiedLabel ?? '已复制');
|
||||
if (props.onCopied) props.onCopied(text);
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(() => label(props.label ?? '复制'), 2000); // 2s 后还原文案
|
||||
};
|
||||
|
||||
// 直接构造按钮(ButtonProps.label 收窄为 string,无法传信号,复用会破坏其类型口径)
|
||||
return h('button', { class: `ua-btn ua-btn-${props.variant ?? 'ghost'}${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`, type: 'button', disabled: props.disabled, onClick: () => { void write(); } }, label);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { h, signal } from '@ui-arch/frame';
|
||||
import type { CountdownHandle, CountdownProps } from './types.js';
|
||||
|
||||
/*** 倒计时:秒级递减到 0 自动停表并触发 onFinish,缺省 mm:ss 展示,句柄可 start / stop / reset ***/
|
||||
export function countdown(props: CountdownProps): CountdownHandle {
|
||||
const remain = signal(props.seconds); // 剩余秒数(单一来源,h() 函数型子节点响应式刷新文本)
|
||||
let timer = 0; // setInterval 句柄(0 表示停表中)
|
||||
const fmt = props.format ?? ((s: number): string => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`); // 缺省 mm:ss 两位补零
|
||||
|
||||
const stop = (): void => { if (timer) { clearInterval(timer); timer = 0; } }; // 停表并清句柄,幂等
|
||||
|
||||
/*** 开始 / 续跑:重入先停旧表;已归零时视为重开一轮 ***/
|
||||
const start = (): void => {
|
||||
stop();
|
||||
if (remain() <= 0) remain(props.seconds);
|
||||
timer = window.setInterval(() => {
|
||||
const next = Math.max(0, remain() - 1);
|
||||
remain(next);
|
||||
if (next <= 0) { stop(); if (props.onFinish) props.onFinish(); } // 归零即停表并回调,不留负数帧
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const reset = (): void => { stop(); remain(props.seconds); }; // 复位到总时长并停表
|
||||
const el = h('span', { class: 'ua-countdown', role: 'timer' }, () => fmt(remain())) as HTMLSpanElement;
|
||||
if (props.running ?? true) start(); // 缺省挂载即开始
|
||||
return { el, start, stop, reset };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { RowModel } from '@ui-arch/table-core';
|
||||
import { button } from './button.js';
|
||||
import type { TableColumn } from './types.js';
|
||||
|
||||
/*** 单元格转义:含逗号 / 引号 / 换行时整体加引号,内部引号翻倍 ***/
|
||||
function cell(v: unknown): string {
|
||||
const s = v == null ? '' : String(v);
|
||||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
/*** 导出 CSV:前置 BOM + CRLF 换行,Excel 打开中文不乱码 ***/
|
||||
export function downloadCsv(name: string, header: string[], rows: unknown[][]): void {
|
||||
const text = '\ufeff' + [header, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }));
|
||||
const a = h('a', { href: url, download: name }) as HTMLAnchorElement;
|
||||
document.body.appendChild(a); // 挂进文档再点击:Safari 对游离锚点不触发下载
|
||||
a.click();
|
||||
a.remove(); // 立即摘除,不留残留节点
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/*** 导出按钮:取筛选 + 排序后的全量行(不分页),列序与表头一致、单元格走 format;列可由读取器惰性给出,跟随列显隐 ***/
|
||||
export function exportButton<T>(
|
||||
table: { getAllRows(): RowModel<T>; getCellValue(row: T, key: string): unknown },
|
||||
columns: TableColumn<T>[] | (() => TableColumn<T>[]),
|
||||
name: string,
|
||||
): HTMLElement {
|
||||
const run = (): void => {
|
||||
const cols = typeof columns === 'function' ? columns() : columns; // 点导出时才取列:隐藏列变化后导出跟着变
|
||||
const rows = table.getAllRows().rows;
|
||||
const header = cols.map((c) => String(c.header ?? c.key));
|
||||
const body = rows.map((row) => cols.map((c) => {
|
||||
const raw = table.getCellValue(row, c.key);
|
||||
return c.format ? c.format(raw, row) : raw; // 与单元格显示同源,导出内容与看到的一致
|
||||
}));
|
||||
downloadCsv(name, header, body);
|
||||
};
|
||||
return button({ label: '导出 CSV', variant: 'ghost', onClick: run });
|
||||
}
|
||||
@@ -4,31 +4,36 @@ import {
|
||||
paginationPlugin,
|
||||
selectionPlugin,
|
||||
sortPlugin,
|
||||
type RowKey,
|
||||
} from '@ui-arch/table-core';
|
||||
import type { PageInfo, RowKey, TableInstance } from '@ui-arch/table-core';
|
||||
import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { iconSearch } from './icons.js'; import { computed, effect, h, renderList, signal } from '@ui-arch/frame';
|
||||
import { attachResize } from './columnResize.js';
|
||||
import { exportButton } from './csv.js';
|
||||
import { skeleton } from './skeleton.js';
|
||||
import { textField } from './textField.js';
|
||||
import type { DataTableProps } from './types.js';
|
||||
import { emptyRow } from './tableEmpty.js';
|
||||
import { detailRow, expandCell } from './tableExpand.js';
|
||||
import { tablePager } from './tablePager.js';
|
||||
import { attachDataSource, readRows } from './tableDataSource.js';
|
||||
import { bridgePage, bridgeSelection } from './tableBridge.js';
|
||||
import { columnPicker } from './tableColumns.js';
|
||||
import type { DataTableProps, TableApi } from './types.js';
|
||||
|
||||
/*** 数据表格:粘性表头 + 行选择 + 排序 + 筛选 + 分页 + 空状态;状态由 table-core 持有,信号只做桥接渲染 ***/
|
||||
export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const selectable = props.selectable ?? false; // 行选择开关
|
||||
const selectable = props.selectable ?? false; // 状态集中声明:行选择开关
|
||||
const expanded = signal<RowKey[]>([]); // 已展开行的 rowKey:纯视图状态,不参与内核派生
|
||||
const keyword = signal(''); // 筛选框关键字:受控信号,空状态里的「清除筛选」靠它一键还原
|
||||
const table = createTable({
|
||||
data: props.data,
|
||||
data: readRows(props.data), // 信号数据源先取当前值,后续靠 attachDataSource 换
|
||||
columns: props.columns,
|
||||
rowKey: 'id',
|
||||
plugins: [filterPlugin<T>(), sortPlugin<T>(), paginationPlugin<T>(), selectionPlugin<T>()],
|
||||
initialState: { pagination: { page: 1, pageSize: props.pageSize ?? 5 } },
|
||||
});
|
||||
const api = table as unknown as TableInstance<T> & {
|
||||
getPageInfo(): PageInfo;
|
||||
selectAllVisible(): void;
|
||||
};
|
||||
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增触发信号系统
|
||||
table.subscribe(() => version(version() + 1));
|
||||
const api = table as unknown as TableApi<T>; // 用到的两个取数方法在类型别名里声明
|
||||
const version = signal(0); // 桥接信号:table-core 订阅通知 → 递增 → 派生统一重算
|
||||
table.subscribe(() => version(version() + 1)); attachDataSource(table, props.data); // 信号数据源变化即换源(静态数组直接返回)
|
||||
|
||||
// —— 派生:全部由 version 单一来源推导,语义与内核一致 ——
|
||||
const visible = computed(() => {
|
||||
@@ -46,39 +51,17 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
const selectedCount = computed(() => state().selection.length);
|
||||
// 加载态读取器:布尔直接读,信号则保持响应性(供 renderList 依赖收集)
|
||||
const busy = (): boolean => (typeof props.loading === 'function' ? props.loading() : props.loading === true);
|
||||
|
||||
// —— 受控页码桥接:外部信号 ↔ 内核分页;外部越界先钳制,signal 等值写回不触发,双向同步收敛安全 ——
|
||||
if (props.page) {
|
||||
const pageSig = props.page;
|
||||
effect(() => {
|
||||
const external = Math.min(api.getPageInfo().totalPages, Math.max(1, pageSig())); // 越界钳制,防无效派发
|
||||
if (external !== info().page) table.dispatch({ type: 'setPage', page: external }); // 外部翻页 → 内核
|
||||
});
|
||||
effect(() => {
|
||||
const internal = info().page;
|
||||
if (internal !== pageSig()) pageSig(internal); // 内部翻页(筛选/内置翻页器)→ 写回外部
|
||||
});
|
||||
}
|
||||
|
||||
// —— 受控选中桥接:外部信号 ↔ 内核选择集;比较内容而非引用,避免来回派发 ——
|
||||
if (props.selected) {
|
||||
const selSig = props.selected;
|
||||
const sameKeys = (a: RowKey[], b: RowKey[]): boolean => a.length === b.length && a.every((k, i) => k === b[i]);
|
||||
effect(() => {
|
||||
const external = selSig();
|
||||
if (!sameKeys(external, state().selection)) table.dispatch({ type: 'setSelection', keys: external }); // 外部改写 → 内核
|
||||
});
|
||||
effect(() => {
|
||||
const internal = state().selection;
|
||||
if (!sameKeys(internal, selSig())) selSig([...internal]); // 内部勾选 → 写回外部(拷贝避免外部改到内核数组)
|
||||
});
|
||||
}
|
||||
// —— 受控桥接:页码与选中都走 tableBridge,外部信号与内核状态双向收敛 ——
|
||||
if (props.page) bridgePage({ page: props.page, info, onPage: (page) => table.dispatch({ type: 'setPage', page }) });
|
||||
if (props.selected) bridgeSelection({ selected: props.selected, selection: () => state().selection, onSelection: (keys) => table.dispatch({ type: 'setSelection', keys }) });
|
||||
|
||||
const filterKey = props.filterKey ?? props.columns[0].key;
|
||||
const colspan = props.columns.length + (selectable ? 1 : 0);
|
||||
const hidden = props.hiddenColumns ?? signal<string[]>([]); // 隐藏列键集:外部没给就自己持有
|
||||
const shown = computed(() => props.columns.filter((c) => !hidden().includes(c.key))); // 可见列:表头 / 单元格 / 导出 / colspan 全从这一处派生
|
||||
const span = (): number => shown().length + (selectable ? 1 : 0) + (props.renderDetail ? 1 : 0); // 跨列数随可见列与展开列变化
|
||||
|
||||
// —— 表头全选框:checked / indeterminate 三态跟随当页选中情况 ——
|
||||
const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
const headCheck = h('input', { type: 'checkbox', class: 'ua-checkbox', 'aria-label': '全选本页' }) as HTMLInputElement; // 无名字的复选框读屏只念「复选框」
|
||||
headCheck.addEventListener('change', () => {
|
||||
if (headCheck.checked) api.selectAllVisible();
|
||||
else table.dispatch({ type: 'clearSelection' });
|
||||
@@ -91,33 +74,51 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
headCheck.indeterminate = onPage > 0 && onPage < keys.length;
|
||||
});
|
||||
|
||||
/*** 排序标记:升 ↑ / 降 ↓ / 未排序 ↕(可点击提示) ***/
|
||||
/*** 排序标记:升 ↑ / 降 ↓ / 未排序 ↕;多列排序时追加优先级序号(↑1 / ↓2) ***/
|
||||
const sortMark = (key: string) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'ua-sort-mark' },
|
||||
() => {
|
||||
const rule = state().sorting.find((r) => r.key === key);
|
||||
return rule ? (rule.dir === 'asc' ? '↑' : '↓') : '↕';
|
||||
const rules = state().sorting;
|
||||
const at = rules.findIndex((r) => r.key === key);
|
||||
if (at === -1) return '↕';
|
||||
const arrow = rules[at].dir === 'asc' ? '↑' : '↓';
|
||||
return rules.length > 1 ? `${arrow}${at + 1}` : arrow; // 只有多列参与排序时才显示序号,单列保持原样
|
||||
},
|
||||
);
|
||||
|
||||
const th = (col: (typeof props.columns)[number]) =>
|
||||
h(
|
||||
const th = (col: (typeof props.columns)[number]) => {
|
||||
const toggle = (multi: boolean) => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key, multi }); // multi 由 Shift 决定:叠加 / 单列
|
||||
const el = h(
|
||||
'th',
|
||||
{
|
||||
class: `${col.sortable ? 'ua-sortable' : ''}${col.align ? ` ua-align-${col.align}` : ''}`.trim(),
|
||||
onClick: () => col.sortable && table.dispatch({ type: 'toggleSort', key: col.key }),
|
||||
scope: 'col', // 表头单元格语义:读屏据此把列标题与数据格关联
|
||||
tabindex: col.sortable ? '0' : null, // 可排序表头进 Tab 序,不可排序的不抢焦点
|
||||
onClick: (e: MouseEvent) => toggle(e.shiftKey), // Shift+点击 = 多列叠加排序(内核按 rules 顺序逐列比较)
|
||||
onKeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e.shiftKey); } // Enter / 空格 等价鼠标点击,Shift 同样生效
|
||||
},
|
||||
},
|
||||
col.header ?? col.key,
|
||||
col.sortable ? sortMark(col.key) : null,
|
||||
);
|
||||
if (typeof col.width === 'number') attachResize(el, colEls.get(col.key)!, col.width, String(col.header ?? col.key)); // 定宽列可拖拽
|
||||
if (col.sortable) {
|
||||
effect(() => {
|
||||
const rule = state().sorting.find((r) => r.key === col.key); // 与视觉箭头同源
|
||||
el.setAttribute('aria-sort', rule ? (rule.dir === 'asc' ? 'ascending' : 'descending') : 'none');
|
||||
});
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
/*** 骨架行:列宽与表头对齐,行数取当前每页条数(上限 6 行防过长) ***/
|
||||
const skeletonRows = (): Node[] => {
|
||||
const count = Math.min(props.pageSize ?? 5, 6);
|
||||
const cell = () => h('td', null, skeleton({ variant: 'text', rows: 1 }));
|
||||
return Array.from({ length: count }, () => h('tr', { class: 'ua-loading-row' }, ...Array.from({ length: colspan }, cell)));
|
||||
return Array.from({ length: count }, () => h('tr', { class: 'ua-loading-row' }, ...Array.from({ length: span() }, cell)));
|
||||
};
|
||||
|
||||
const tbody = h('tbody');
|
||||
@@ -125,37 +126,39 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
if (busy()) return skeletonRows(); // 加载态优先:不看数据,避免闪出旧行
|
||||
const sel = state().selection; // 读入依赖:选择变化即整表重绘(行数 = pageSize,代价可控)
|
||||
const rows = visible().rows;
|
||||
if (!rows.length) {
|
||||
return [h('tr', { class: 'ua-empty-row' }, h('td', { colspan }, '暂无匹配数据'))];
|
||||
}
|
||||
return rows.map((row, i) => {
|
||||
if (!rows.length) return [emptyRow(span(), keyword, () => table.dispatch({ type: 'setFilter', key: filterKey, value: '' }), props.emptyText, props.emptyAction)]; // 空状态:筛选词与内核筛选一起清;真没数据时给调用方的操作插槽
|
||||
return rows.flatMap((row, i) => {
|
||||
const key = visible().keys[i];
|
||||
const selected = sel.includes(key);
|
||||
const cells = props.columns.map((col) => {
|
||||
const open = !!props.renderDetail && expanded().includes(key); // 展开态按 rowKey 记:翻页或筛选后同一行仍保持着
|
||||
const cells = shown().map((col) => {
|
||||
const raw = table.getCellValue(row, col.key);
|
||||
const text = col.format ? col.format(raw, row) : String(raw); // format 只产出纯文本,天然免注入
|
||||
return h('td', col.align ? { class: `ua-align-${col.align}` } : null, text);
|
||||
const custom = col.render?.(row); // 自定义单元格:调用方直接给节点(editable / tag / avatar),排序与筛选仍走原值
|
||||
return h('td', { class: col.align ? `ua-align-${col.align}` : null, onclick: custom ? (e: MouseEvent) => e.stopPropagation() : null }, custom ?? (col.format ? col.format(raw, row) : String(raw))); // 无 render 退回纯文本(免注入);格内点击不触发整行回调
|
||||
});
|
||||
if (selectable) {
|
||||
const check = h('input', { type: 'checkbox', class: 'ua-checkbox' }) as HTMLInputElement;
|
||||
const check = h('input', { type: 'checkbox', class: 'ua-checkbox', 'aria-label': `选择第 ${i + 1} 行` }) as HTMLInputElement; // 受控:选中态由内核 selection 决定;名字带行号,读屏才分得清选哪行
|
||||
check.checked = selected;
|
||||
check.addEventListener('change', () => table.dispatch({ type: 'toggleSelect', key }));
|
||||
cells.unshift(h('td', { class: 'ua-cell-check' }, check));
|
||||
}
|
||||
return h('tr', selected ? { class: 'ua-row-selected' } : null, ...cells);
|
||||
if (props.renderDetail) cells.unshift(expandCell(open, `第 ${i + 1} 行`, () => expanded(open ? expanded().filter((k) => k !== key) : [...expanded(), key]))); // 展开列在最前:与选择列并列但不互斥
|
||||
const rowProps = props.onRowClick // 传了才把整行做成可聚焦按钮语义(Tab 进入、Enter / 空格触发)
|
||||
? { tabindex: '0', class: `ua-row-clickable${selected ? ' ua-row-selected' : ''}`, 'aria-selected': selectable ? String(selected) : null, onclick: () => props.onRowClick?.(row), onkeydown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onRowClick?.(row); } } }
|
||||
: selected ? { class: 'ua-row-selected', 'aria-selected': 'true' } : null; // 淡底是视觉,aria-selected 才是读屏能听到的「这行被选中」
|
||||
const tr = h('tr', rowProps, ...cells);
|
||||
return props.renderDetail && open ? [tr, detailRow(span(), props.renderDetail(row))] : [tr];
|
||||
});
|
||||
});
|
||||
|
||||
// —— 列宽:数字落成 <col> 固定宽度,'flex' 或未设置交给浏览器自适应 ——
|
||||
const colgroup = h(
|
||||
'colgroup',
|
||||
null,
|
||||
selectable ? h('col', { style: 'width:44px' }) : null,
|
||||
...props.columns.map((col) => (typeof col.width === 'number' ? h('col', { style: `width:${col.width}px` }) : h('col'))),
|
||||
);
|
||||
// —— 列宽:数字落成 <col> 固定宽度,'flex' 或未设置交给浏览器自适应;按列键建一次,显隐只切换是否挂进 colgroup ——
|
||||
const colEls = new Map(props.columns.map((col) => [col.key, typeof col.width === 'number' ? h('col', { style: `width:${col.width}px` }) : h('col')])); // 复用同一批 <col>,拖拽改的宽度不因显隐丢失
|
||||
const colgroup = h('colgroup');
|
||||
renderList(colgroup, () => [...(selectable ? [h('col', { style: 'width:44px' })] : []), ...shown().map((c) => colEls.get(c.key)!)]);
|
||||
|
||||
const filterInput = textField({
|
||||
placeholder: `筛选 ${filterKey}…`,
|
||||
prefix: iconSearch(),
|
||||
value: keyword, // 受控信号:空状态里的「清除筛选」能同时清掉输入框内容
|
||||
disabled: props.loading, // 加载中禁止筛选,避免与在途结果竞争
|
||||
onInput: (value) => {
|
||||
table.dispatch({ type: 'setFilter', key: filterKey, value });
|
||||
@@ -163,6 +166,14 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
},
|
||||
});
|
||||
|
||||
// —— 表头:随可见列重绘(隐藏列既不占表头也不占单元格);选择与展开各占一个 th,顺序与行内单元格一致 ——
|
||||
const thead = h('thead');
|
||||
const lead = (): unknown[] => [
|
||||
props.renderDetail ? h('th', { class: 'ua-cell-expand', scope: 'col' }, h('span', { class: 'ua-visually-hidden' }, '展开详情')) : null, // 展开列在最前
|
||||
selectable ? h('th', { class: 'ua-cell-check', scope: 'col' }, headCheck) : null, // 全选框借这次归位进 th(原先直接塞进 tr,属无效结构)
|
||||
];
|
||||
renderList(thead, () => [h('tr', null, ...lead(), ...shown().map(th))]);
|
||||
|
||||
return h(
|
||||
'section',
|
||||
{ class: 'ua-table', 'aria-busy': () => String(busy()) },
|
||||
@@ -171,14 +182,17 @@ export function dataTable<T extends { id: RowKey }>(props: DataTableProps<T>): H
|
||||
{ class: 'ua-table-toolbar' },
|
||||
filterInput,
|
||||
selectable ? h('span', { class: 'ua-sel-count' }, () => (selectedCount() ? `已选 ${selectedCount()} 行` : '')) : null,
|
||||
props.columnToggle ? columnPicker({ columns: props.columns, hidden, disabled: busy }) : null, // 列显隐面板:勾选即重绘表头与单元格
|
||||
props.exportable ? exportButton(table, () => shown(), props.exportName ?? 'table.csv') : null, // 导出筛选+排序后的全量行,列跟随当前可见列
|
||||
),
|
||||
h('div', { class: 'ua-table-scroll' }, h('table', null, colgroup, h('thead', null, h('tr', null, selectable ? headCheck : null, ...props.columns.map(th))), tbody)),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-pager' },
|
||||
button({ label: '上一页', variant: 'ghost', disabled: props.loading, onClick: () => table.dispatch({ type: 'setPage', page: info().page - 1 }) }),
|
||||
h('span', { class: 'ua-page-info' }, () => `第 ${info().page} / ${info().totalPages} 页 · 共 ${visible().total} 行`),
|
||||
button({ label: '下一页', variant: 'ghost', disabled: props.loading, onClick: () => table.dispatch({ type: 'setPage', page: info().page + 1 }) }),
|
||||
),
|
||||
h('div', { class: 'ua-table-scroll' }, h('table', null, props.caption ? h('caption', { class: 'ua-visually-hidden' }, props.caption) : null, colgroup, thead, tbody)), // caption 视觉隐藏仍进无障碍树:表格才有名字
|
||||
tablePager({
|
||||
info,
|
||||
total: () => visible().total,
|
||||
loading: busy,
|
||||
pageSizes: props.pageSizes,
|
||||
onPage: (page) => table.dispatch({ type: 'setPage', page }),
|
||||
onPageSize: (pageSize) => table.dispatch({ type: 'setPageSize', pageSize }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*** 日期工具:datePicker 与 calendar 共用一套格式化与网格语义,避免两处各写一遍 ***/
|
||||
export const pad = (n: number): string => String(n).padStart(2, '0'); // 两位补零
|
||||
|
||||
/*** 拼成 YYYY-MM-DD ***/
|
||||
export const fmt = (y: number, m: number, d: number): string => `${y}-${pad(m + 1)}-${pad(d)}`;
|
||||
|
||||
/*** 解析 YYYY-MM-DD:非法串返回 null,调用方自行回落 ***/
|
||||
export const parse = (s: string): [number, number, number] | null => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
return m ? [Number(m[1]), Number(m[2]) - 1, Number(m[3])] : null;
|
||||
};
|
||||
|
||||
/*** 今天 ***/
|
||||
export const today = (): [number, number, number] => {
|
||||
const d = new Date();
|
||||
return [d.getFullYear(), d.getMonth(), d.getDate()];
|
||||
};
|
||||
|
||||
/*** 按天偏移:跨月 / 跨年自动进位,非法输入回落到今天 ***/
|
||||
export const shiftDay = (key: string, delta: number): string => {
|
||||
const p = parse(key) ?? today();
|
||||
const d = new Date(p[0], p[1], p[2] + delta);
|
||||
return fmt(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
};
|
||||
|
||||
/*** 单月网格元数据:月初留白格数与当月天数 ***/
|
||||
export const monthGrid = (y: number, m: number): { lead: number; total: number } => ({
|
||||
lead: new Date(y, m, 1).getDay(), // 1 号是周几 → 前面留几个空位
|
||||
total: new Date(y, m + 1, 0).getDate(), // 下月第 0 天即本月最后一天
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import type { DatePickerProps, DatePreset } from './types.js';
|
||||
import { fmt, pad, parse, shiftDay, today } from './dateKit.js';
|
||||
|
||||
/*** 日期选择:单月网格 + 上下月翻页 + 今天快捷键,value 信号双向绑定,方向键/PageUp/PageDown 可导航 ***/
|
||||
export function datePicker(props: DatePickerProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value: Signal<string> = props.value ?? signal(''); // 已选日期(区间模式下是起点)
|
||||
const end: Signal<string> = props.end ?? signal(''); // 区间终点,非区间模式恒为空
|
||||
const open = signal(false); // 弹层开合
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
|
||||
const initial = parse(value()) ?? today(); // 初始视图:跟随已选,否则今天
|
||||
const view = signal<[number, number]>([initial[0], initial[1]]); // 当前展示的年月
|
||||
const focus = signal<string>(value() || fmt(...today())); // 键盘焦点日
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
|
||||
const grid = h('div', { class: 'ua-datepicker-grid', role: 'grid' }) as HTMLElement;
|
||||
const label = h('span', { class: 'ua-datepicker-label' }) as HTMLElement;
|
||||
const head = h(
|
||||
'div',
|
||||
{ class: 'ua-datepicker-head' },
|
||||
h('button', { type: 'button', class: 'ua-datepicker-nav', 'aria-label': '上一月', onClick: () => turn(-1) }, '‹'),
|
||||
label,
|
||||
h('button', { type: 'button', class: 'ua-datepicker-nav', 'aria-label': '下一月', onClick: () => turn(1) }, '›'),
|
||||
);
|
||||
const presetRow = props.range && props.presets?.length // 快捷区间只在区间模式有意义
|
||||
? h('div', { class: 'ua-datepicker-presets' }, ...props.presets.map((p) => h('button', {
|
||||
type: 'button', class: 'ua-datepicker-preset', onClick: () => applyPreset(p),
|
||||
}, p.label)))
|
||||
: null;
|
||||
const pop = h(
|
||||
'div',
|
||||
{ class: () => `ua-datepicker-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` },
|
||||
head,
|
||||
...(presetRow ? [presetRow] : []),
|
||||
h('div', { class: 'ua-datepicker-week' }, ...['日', '一', '二', '三', '四', '五', '六'].map((w) => h('span', {}, w))),
|
||||
grid,
|
||||
h('div', { class: 'ua-datepicker-foot' }, h('button', { type: 'button', class: 'ua-datepicker-today', onClick: () => commit(fmt(...today())) }, '今天')),
|
||||
) as HTMLElement;
|
||||
|
||||
/*** 翻月:只改视图,不改已选值;跨年自动进位 ***/
|
||||
function turn(delta: number): void {
|
||||
const [y, m] = view();
|
||||
const d = new Date(y, m + delta, 1);
|
||||
view([d.getFullYear(), d.getMonth()]);
|
||||
}
|
||||
|
||||
/*** 提交:单日模式写值即收起;区间模式首点定起点并留在弹层,次点补终点后收起 ***/
|
||||
function commit(date: string): void {
|
||||
if (!props.range) {
|
||||
value(date);
|
||||
props.onChange?.(date);
|
||||
open(false);
|
||||
trigger.focus();
|
||||
return;
|
||||
}
|
||||
if (!value() || end()) { // 未选起点或上一段区间已完整:从这个日期重开一段
|
||||
value(date);
|
||||
end('');
|
||||
render(); // 清掉上一段的区间底色
|
||||
return;
|
||||
}
|
||||
const [start, stop] = date < value() ? [date, value()] : [value(), date]; // 点早了就自动交换
|
||||
value(start);
|
||||
end(stop);
|
||||
props.onRangeChange?.(start, stop);
|
||||
open(false);
|
||||
trigger.focus();
|
||||
}
|
||||
|
||||
/*** 应用快捷区间:days 优先(今天往前共 N 天),否则用绝对 from/to;按 min/max 钳制后写值并收起 ***/
|
||||
function applyPreset(p: DatePreset): void {
|
||||
const stop = p.days ? fmt(...today()) : (p.to || fmt(...today()));
|
||||
const start = p.days ? shiftDay(stop, -(p.days - 1)) : (p.from || stop);
|
||||
const [lo, hi] = start <= stop ? [start, stop] : [stop, start]; // 防御:from 晚于 to 时自动交换
|
||||
const fit = (v: string): string => (props.min && v < props.min ? props.min : props.max && v > props.max ? props.max : v); // 钳进合法区间
|
||||
value(fit(lo));
|
||||
end(fit(hi));
|
||||
props.onRangeChange?.(value(), end());
|
||||
open(false);
|
||||
trigger.focus();
|
||||
}
|
||||
|
||||
/*** 是否越界:min / max 为闭区间,缺省视为不限制 ***/
|
||||
const outOfRange = (date: string): boolean => (props.min ? date < props.min : false) || (props.max ? date > props.max : false);
|
||||
|
||||
/*** 重绘网格:前置空位对齐星期、今天/选中/焦点分别打标 ***/
|
||||
function render(): void {
|
||||
const [y, m] = view();
|
||||
const lead = new Date(y, m, 1).getDay(); // 当月 1 号是周几
|
||||
const total = new Date(y, m + 1, 0).getDate(); // 当月天数
|
||||
const cells: HTMLElement[] = [];
|
||||
for (let i = 0; i < lead; i++) cells.push(h('span', { class: 'ua-datepicker-blank' })); // 月初留白
|
||||
for (let d = 1; d <= total; d++) {
|
||||
const date = fmt(y, m, d);
|
||||
const dead = outOfRange(date);
|
||||
const edge = date === value() || Boolean(props.range && end() && date === end()); // 区间两端
|
||||
const between = Boolean(props.range && value() && end() && date > value() && date < end()); // 区间内部
|
||||
const cls = `ua-datepicker-day${edge ? ' is-selected' : ''}${between ? ' is-in-range' : ''}${date === fmt(...today()) ? ' is-today' : ''}${dead ? ' is-disabled' : ''}`;
|
||||
cells.push(
|
||||
h('button', {
|
||||
type: 'button',
|
||||
class: cls,
|
||||
role: 'gridcell',
|
||||
tabindex: date === focus() ? '0' : '-1', // 漫游 tabindex:只有焦点日可 Tab 进入
|
||||
'aria-selected': String(edge),
|
||||
disabled: dead,
|
||||
'aria-label': date,
|
||||
onClick: () => commit(date),
|
||||
}, String(d)),
|
||||
);
|
||||
}
|
||||
grid.replaceChildren(...cells);
|
||||
label.textContent = `${y} 年 ${m + 1} 月`;
|
||||
requestAnimationFrame(() => {
|
||||
if (!open()) return; // 卫语句:收起时不抢焦点
|
||||
(grid.querySelector('.ua-datepicker-day[tabindex="0"]') as HTMLElement | null)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/*** 触发器文案:单日回显日期;区间回显「起 ~ 止」,只定了起点时后面跟省略号 ***/
|
||||
const labelText = (): string => {
|
||||
if (!props.range) return value() || props.placeholder || '请选择日期';
|
||||
if (!value()) return props.placeholder || '请选择日期范围';
|
||||
return end() ? `${value()} ~ ${end()}` : `${value()} ~ …`;
|
||||
};
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-datepicker-trigger',
|
||||
'aria-haspopup': 'dialog',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled,
|
||||
onClick: () => {
|
||||
if (!open()) {
|
||||
const p = parse(value()) ?? today(); // 打开时视图对准已选日期(无选则今天)
|
||||
view([p[0], p[1]]);
|
||||
focus(value() || fmt(...p));
|
||||
}
|
||||
open(!open());
|
||||
},
|
||||
},
|
||||
h('span', { class: () => `ua-datepicker-value${value() ? '' : ' is-empty'}` }, () => labelText()),
|
||||
h('span', { class: 'ua-datepicker-caret', 'aria-hidden': 'true' }, '▤'),
|
||||
);
|
||||
const root = h('div', { class: () => `ua-datepicker${open() ? ' ua-open' : ''}` }, trigger, pop) as HTMLElement;
|
||||
|
||||
// 键盘:←→ 逐日、↑↓ 逐周、PageUp/PageDown 逐月、Enter 提交,Esc 交给弹层栈
|
||||
pop.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 };
|
||||
if (step[e.key]) {
|
||||
e.preventDefault();
|
||||
const next = shiftDay(focus(), step[e.key]);
|
||||
const p = parse(next) ?? today();
|
||||
focus(next);
|
||||
if (p[0] !== view()[0] || p[1] !== view()[1]) view([p[0], p[1]]); // 跨月自动翻页
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); turn(e.key === 'PageUp' ? -1 : 1); return; }
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!outOfRange(focus())) commit(focus()); }
|
||||
});
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(render); // 依赖 view / value / focus
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
}); // 空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { DescriptionItem, DescriptionsProps } from './types.js';
|
||||
|
||||
/*** 单个描述项:字段名 + 字段值,span 控制跨列(超出列数时按整行处理) ***/
|
||||
function cell(item: DescriptionItem, column: number): HTMLElement {
|
||||
const cellEl = h('div', { class: 'ua-desc-item' },
|
||||
h('div', { class: 'ua-desc-label' }, item.label),
|
||||
h('div', { class: 'ua-desc-value' }, item.value));
|
||||
cellEl.style.gridColumn = `span ${Math.min(item.span ?? 1, column)}`; // 跨列不越界
|
||||
return cellEl;
|
||||
}
|
||||
|
||||
/*** 描述列表:label:value 两栏网格,column 定桌面列数,窄屏由样式表降到单列;bordered 走表格形态,layout=vertical 把字段名挪到值上方 ***/
|
||||
export function descriptions(props: DescriptionsProps): HTMLElement {
|
||||
const column = props.column ?? 3;
|
||||
const vertical = props.layout === 'vertical' ? ' is-vertical' : '';
|
||||
const grid = h('div', { class: `ua-desc ua-desc-${props.size ?? 'md'}${props.bordered ? ' is-bordered' : ''}${vertical}` },
|
||||
...props.items.map((item) => cell(item, column)));
|
||||
grid.style.setProperty('--ua-desc-col', String(column)); // 列数交给 CSS 变量,媒体查询可在窄屏覆盖
|
||||
if (!props.title) return grid;
|
||||
const head = props.extra // 标题行:给了操作区就左右分栏,否则保持原来的单标题结构
|
||||
? h('div', { class: 'ua-desc-head' }, h('div', { class: 'ua-desc-title' }, props.title), h('div', { class: 'ua-desc-extra' }, props.extra))
|
||||
: h('div', { class: 'ua-desc-title' }, props.title);
|
||||
return h('div', { class: 'ua-desc-wrap' }, head, grid);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*** 行级差异纯逻辑:切行 / 归一键 / 中间段 LCS 对齐;不碰 DOM,可被冒烟脚本直接跑 ***/
|
||||
|
||||
/*** 一条差异:same 原样、add 只在右边、del 只在左边 ***/
|
||||
export type DiffOp = { type: 'same' | 'add' | 'del'; text: string };
|
||||
/*** 比较用的一行:text 是显示原文,key 是比对键(忽略空白模式下把行内空白压成一个空格) ***/
|
||||
export type DiffRow = { text: string; key: string };
|
||||
// LCS 计算上限:中间段行数乘积超过这个值就退化为「整段替换」,避免大文件把主线程卡住
|
||||
export const DP_LIMIT = 400_000;
|
||||
|
||||
/*** 作用:比对键归一——行内连续空白压成一个空格并去首尾空白;显示仍用原文,缩进不丢 ***/
|
||||
export function normalizeLine(line: string): string {
|
||||
return line.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/*** 作用:切行并算比对键;末尾换行不产生一行空内容(否则每个文件都多一条假差异) ***/
|
||||
export function toRows(text: string, ignore: boolean): DiffRow[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); // 尾部换行忽略
|
||||
return lines.map((line) => ({ text: line, key: ignore ? normalizeLine(line) : line }));
|
||||
}
|
||||
|
||||
/*** 作用:中间段做 LCS 对齐,输出 del / add / same 序列;超限或一侧为空时退化为整段替换 ***/
|
||||
export function alignMiddle(a: DiffRow[], b: DiffRow[]): DiffOp[] {
|
||||
if (!a.length || !b.length) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))];
|
||||
if (a.length * b.length > DP_LIMIT) return [...a.map((r) => ({ type: 'del' as const, text: r.text })), ...b.map((r) => ({ type: 'add' as const, text: r.text }))]; // 超限:语义仍正确,只是不再逐行对齐
|
||||
const dp: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0)); // dp[i][j]:a 前 i 行与 b 前 j 行的 LCS 长度
|
||||
for (let i = a.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = b.length - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] = a[i].key === b[j].key ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const ops: DiffOp[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i].key === b[j].key) { ops.push({ type: 'same', text: a[i].text }); i += 1; j += 1; continue; }
|
||||
if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i].text }); i += 1; continue; }
|
||||
ops.push({ type: 'add', text: b[j].text }); j += 1;
|
||||
}
|
||||
for (; i < a.length; i += 1) ops.push({ type: 'del', text: a[i].text });
|
||||
for (; j < b.length; j += 1) ops.push({ type: 'add', text: b[j].text });
|
||||
return ops;
|
||||
}
|
||||
|
||||
/*** 作用:逐行差异——剪公共前后缀后只对中间段做对齐,日常改动只占几行 ***/
|
||||
export function diffLines(before: string, after: string, ignore: boolean): DiffOp[] {
|
||||
const a = toRows(before, ignore);
|
||||
const b = toRows(after, ignore);
|
||||
let head = 0;
|
||||
while (head < a.length && head < b.length && a[head].key === b[head].key) head += 1; // 公共前缀
|
||||
let tail = 0;
|
||||
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].key === b[b.length - 1 - tail].key) tail += 1; // 公共后缀
|
||||
return [
|
||||
...a.slice(0, head).map((r) => ({ type: 'same' as const, text: r.text })),
|
||||
...alignMiddle(a.slice(head, a.length - tail), b.slice(head, b.length - tail)),
|
||||
...a.slice(a.length - tail).map((r) => ({ type: 'same' as const, text: r.text })),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { diffLines } from './diffKit.js';
|
||||
import type { DiffViewProps } from './types.js';
|
||||
|
||||
/*** 文本差异对比:行级对齐 + 增删着色 + 双行号列,超限时退化为整段替换(只影响精度不影响语义) ***/
|
||||
export function diffView(props: DiffViewProps): HTMLElement {
|
||||
const ignore = props.ignoreWhitespace === true; // 只在显式开启时归一行内空白
|
||||
const ops = diffLines(props.before, props.after, ignore); // 纯逻辑在 diffKit:这里只负责把它画出来
|
||||
const added = ops.filter((o) => o.type === 'add').length; // 新增行数:统计条与读屏共用
|
||||
const removed = ops.filter((o) => o.type === 'del').length; // 删除行数
|
||||
let oldNo = 0;
|
||||
let newNo = 0;
|
||||
const rows = ops.map((op) => {
|
||||
if (op.type !== 'add') oldNo += 1; // same 与 del 占旧行号
|
||||
if (op.type !== 'del') newNo += 1; // same 与 add 占新行号
|
||||
const sign = op.type === 'add' ? '+' : op.type === 'del' ? '−' : ' ';
|
||||
return h(
|
||||
'div',
|
||||
{ class: `ua-diff-row is-${op.type}` },
|
||||
h('span', { class: 'ua-diff-no' }, op.type === 'add' ? '' : String(oldNo)),
|
||||
h('span', { class: 'ua-diff-no' }, op.type === 'del' ? '' : String(newNo)),
|
||||
h('span', { class: 'ua-diff-sign', 'aria-hidden': 'true' }, sign),
|
||||
h('span', { class: 'ua-diff-text' }, op.text || ' '),
|
||||
);
|
||||
});
|
||||
const stats = props.showStats === false
|
||||
? null
|
||||
: h(
|
||||
'div',
|
||||
{ class: 'ua-diff-stats' },
|
||||
h('span', { class: 'ua-diff-add' }, `+${added}`),
|
||||
h('span', { class: 'ua-diff-del' }, `−${removed}`),
|
||||
ignore ? h('span', { class: 'ua-diff-note' }, '已忽略行内空白差异') : null, // 归一口径必须写在脸上,否则「为什么不报这几行」说不清
|
||||
);
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-diff', role: 'group', 'aria-label': props.ariaLabel ?? `文本差异:新增 ${added} 行,删除 ${removed} 行` },
|
||||
stats,
|
||||
h('div', { class: 'ua-diff-body' }, ...rows),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { DividerProps } from './types.js';
|
||||
|
||||
/*** 分隔线:水平/垂直两种方向,可带居中文案标签,dashed 切虚线 ***/
|
||||
export function divider(props: DividerProps): HTMLDivElement {
|
||||
const cls = ['ua-divider', props.vertical ? 'ua-divider-vertical' : '', props.dashed ? 'ua-divider-dashed' : ''].filter(Boolean).join(' ');
|
||||
const line = props.label && !props.vertical
|
||||
? h('div', { class: cls, role: 'separator', 'aria-label': props.label }, h('span', { class: 'ua-divider-label' }, props.label))
|
||||
: h('div', { class: cls, role: 'separator', 'aria-label': props.label }); // 垂直或无标签:纯线条
|
||||
return line as HTMLDivElement;
|
||||
}
|
||||
@@ -1,60 +1,61 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { DrawerProps } from './types.js';
|
||||
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
|
||||
|
||||
let seq = 0; // 实例序号:给标题生成唯一 id,供 aria-labelledby 关联
|
||||
|
||||
/*** 抽屉:open 信号驱动侧滑面板,点遮罩 / × / Esc 关闭;Tab 焦点陷阱 + 关闭归还焦点,与 modal 的分工是不打断主视图、可从侧边保留上下文 ***/
|
||||
export function drawer(props: DrawerProps, ...children: unknown[]): HTMLElement {
|
||||
const titleId = `ua-drawer-title-${++seq}`; // 标题 id:抽屉的可访问名
|
||||
const modal = props.modal !== false; // 是否模态:false 时无遮罩、不锁滚动、不困焦点,页面仍可操作
|
||||
const close = (): void => {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||
props.open(false);
|
||||
props.onClose?.();
|
||||
};
|
||||
const overlay = h('div', {
|
||||
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}`,
|
||||
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}${modal ? '' : ' is-plain'}`,
|
||||
onClick: (e: Event) => {
|
||||
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
|
||||
if (!modal) return; // 卫语句:非模态没有遮罩,点外面不算关闭
|
||||
if (e.target !== overlay) return; // 卫语句:只处理点在遮罩本身的
|
||||
if (props.closeOnOverlay === false) return; // 显式禁止时防误关,× 与 Esc 仍可用
|
||||
close();
|
||||
},
|
||||
});
|
||||
const panel = h(
|
||||
'aside',
|
||||
{
|
||||
class: `ua-drawer ua-drawer-${props.placement ?? 'right'}`,
|
||||
style: `width:${props.width ?? 320}px`,
|
||||
style: `${props.placement === 'top' || props.placement === 'bottom' ? 'height' : 'width'}:${props.width ?? 320}px`, // 上下滑出改高度
|
||||
role: 'dialog',
|
||||
'aria-labelledby': titleId,
|
||||
'aria-hidden': () => String(!props.open()),
|
||||
'aria-modal': modal ? 'true' : 'false',
|
||||
},
|
||||
h('div', { class: 'ua-drawer-head' }, h('h3', { class: 'ua-drawer-title' }, props.title), h('button', { class: 'ua-drawer-close', 'aria-label': '关闭抽屉', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-drawer-head' }, h('h3', { class: 'ua-drawer-title', id: titleId }, props.title), h('button', { class: 'ua-drawer-close', 'aria-label': '关闭抽屉', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-drawer-body' }, ...children),
|
||||
props.footer ? h('div', { class: 'ua-drawer-foot' }, props.footer) : null,
|
||||
);
|
||||
overlay.append(panel);
|
||||
|
||||
let lastFocus: HTMLElement | null = null; // 打开前焦点宿主,关闭时归还
|
||||
const focusables = (): HTMLElement[] =>
|
||||
Array.from(panel.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((el) => !el.hasAttribute('disabled')); // 面板内可聚焦元素(排除禁用)
|
||||
let release: (() => void) | null = null; // Esc 栈注销函数,关闭时释放
|
||||
let releaseLock: (() => void) | null = null; // 背景滚动锁解锁函数,全关才还原
|
||||
effect(() => {
|
||||
if (props.open()) {
|
||||
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
|
||||
release ??= pushOverlay(close); // 入 Esc 栈:多弹层时只关最上层
|
||||
if (modal) releaseLock ??= lockScroll(); // 仅模态期锁背景滚动
|
||||
(panel.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
} else if (lastFocus) {
|
||||
lastFocus.focus(); // 关闭时归还焦点
|
||||
} else {
|
||||
release?.(); // 出 Esc 栈
|
||||
release = null;
|
||||
releaseLock?.(); // 解锁背景滚动
|
||||
releaseLock = null;
|
||||
lastFocus?.focus(); // 关闭时归还焦点
|
||||
lastFocus = null;
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !props.open()) return; // 仅处理打开中的 Tab
|
||||
const els = focusables();
|
||||
if (els.length === 0) { e.preventDefault(); return; } // 无可聚焦元素:拦截 Tab 防焦点逃逸
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement))) {
|
||||
e.preventDefault(); last.focus(); // Shift+Tab 到首位时回绕到末尾
|
||||
} else if (!e.shiftKey && (document.activeElement === last || !panel.contains(document.activeElement))) {
|
||||
e.preventDefault(); first.focus(); // Tab 到末位时回绕到开头
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open()) close();
|
||||
});
|
||||
if (modal) trapFocus(panel, () => props.open()); // 模态才困焦点:非模态要让用户能继续操作背后页面
|
||||
return overlay;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { EditableProps } from './types.js';
|
||||
|
||||
/*** 行内编辑:静止态就是文字(hover 才露出可编辑暗示),点一下就地变输入框;Enter 提交、Esc 取消、失焦提交;validate 不过则留在编辑态并标红(与 form 同口径) ***/
|
||||
export function editable(props: EditableProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value = props.value; // 唯一数据源:只有提交才写回,取消绝不落值
|
||||
let editing = false; // 是否处于编辑态
|
||||
let canceling = false; // Esc 触发的失焦不算提交(blur 让位一次)
|
||||
let painted: string | null = null; // 上次静止态渲染的值;null 表示还没画过(effect 首跑必须画一次)
|
||||
const root = h('div', { class: 'ua-editable' }) as HTMLElement;
|
||||
const isDisabled = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : !!props.disabled); // 布尔与信号两种入参统一判断
|
||||
|
||||
/*** 回到静止态并重画(提交 / 取消共用一条出口,避免两条路径各漏一半清理) ***/
|
||||
const stop = (): void => { editing = false; painted = null; paint(); canceling = false; }; // 画完再复位:paint 里的 replaceChildren 会同步触发 blur,那时 canceling 必须仍为 true
|
||||
|
||||
/*** 提交:先去空白再校验;校验不过留在编辑态标红,过了才写回信号与回调 ***/
|
||||
const commit = (raw: string, field: HTMLElement): void => {
|
||||
const next = raw.trim();
|
||||
const msg = props.validate?.(next) ?? null;
|
||||
if (msg) {
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
const tip = root.querySelector('.ua-editable-error');
|
||||
if (tip) tip.textContent = msg;
|
||||
else root.append(h('span', { class: 'ua-editable-error', role: 'alert' }, msg));
|
||||
return;
|
||||
}
|
||||
value(next);
|
||||
props.onCommit?.(next);
|
||||
stop();
|
||||
};
|
||||
|
||||
/*** 画一遍:编辑态渲染输入控件并聚焦,静止态渲染整块可点的文字按钮 ***/
|
||||
const paint = (): void => {
|
||||
if (editing) {
|
||||
const field = props.multiline
|
||||
? h('textarea', { class: 'ua-editable-input', rows: 3, disabled: props.disabled }, value())
|
||||
: h('input', { class: 'ua-editable-input', disabled: props.disabled, value: value() });
|
||||
const el = field as HTMLInputElement | HTMLTextAreaElement;
|
||||
el.addEventListener('keydown', ((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); canceling = true; stop(); return; } // Esc:丢弃草稿
|
||||
const enter = e.key === 'Enter' && (!props.multiline || e.ctrlKey || e.metaKey); // 多行只认 Ctrl/Cmd+Enter,换行留给 Enter
|
||||
if (enter) { e.preventDefault(); commit(el.value, el); }
|
||||
}) as EventListener); // 联合类型(input | textarea)取不到同一个重载,这里显式收窄
|
||||
el.addEventListener('blur', () => {
|
||||
if (canceling) { canceling = false; return; } // 卫语句:取消导致的失焦不提交
|
||||
commit(el.value, el);
|
||||
});
|
||||
root.replaceChildren(el);
|
||||
el.focus();
|
||||
el.select?.();
|
||||
return;
|
||||
}
|
||||
const text = value();
|
||||
painted = text;
|
||||
root.replaceChildren(h('button', {
|
||||
type: 'button',
|
||||
class: `ua-editable-view${text ? '' : ' is-empty'}`,
|
||||
disabled: props.disabled,
|
||||
title: text ? '点击编辑' : '点击填写',
|
||||
'aria-label': `${props.label ?? '编辑'}:${text || '空'}`,
|
||||
onClick: () => { if (isDisabled()) return; editing = true; paint(); }, // 卫语句:禁用态点了也不进编辑
|
||||
}, text || props.placeholder || '点击填写'));
|
||||
};
|
||||
|
||||
effect(() => {
|
||||
const next = value();
|
||||
if (!editing && next !== painted) paint(); // 外部改值只同步静止态,不打断正在编辑的草稿
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { EmptyProps } from './types.js';
|
||||
|
||||
/*** 空状态:居中占位提示,title/description 可选,action 放操作区(常见为按钮),icon 可换成自家插图 ***/
|
||||
export function empty(props: EmptyProps): HTMLDivElement {
|
||||
return h(
|
||||
'div',
|
||||
{ class: 'ua-empty', role: 'status' }, // role=status 由读屏播报
|
||||
h('div', { class: 'ua-empty-icon', 'aria-hidden': 'true' }, props.icon ?? '⌀'), // 装饰符号默认 ⌀,读屏跳过
|
||||
props.title ? h('div', { class: 'ua-empty-title' }, props.title) : null,
|
||||
props.description ? h('div', { class: 'ua-empty-desc' }, props.description) : null,
|
||||
props.action ? h('div', { class: 'ua-empty-action' }, props.action) : null,
|
||||
) as HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { FloatButtonProps } from './types.js';
|
||||
|
||||
/*** 悬浮操作按钮:固定停靠左下 / 右下角,label 缺省为纯图标圆形;返回 fixed 包裹层,挂 body 才不被父级 overflow 裁剪 ***/
|
||||
export function floatButton(props: FloatButtonProps): HTMLElement {
|
||||
const cls = ['ua-fab', props.label ? 'has-label' : ''].filter(Boolean).join(' ');
|
||||
const btn = h('button', {
|
||||
class: cls,
|
||||
type: 'button',
|
||||
'aria-label': props.tooltip ?? props.label ?? '快捷操作', // 纯图标时必须有可读名称
|
||||
title: props.tooltip ?? null,
|
||||
},
|
||||
props.icon ? h('span', { class: 'ua-fab-icon', 'aria-hidden': 'true' }, props.icon) : null,
|
||||
props.label ? h('span', { class: 'ua-fab-label' }, props.label) : null,
|
||||
props.badge != null ? h('span', { class: 'ua-fab-badge' }, String(props.badge)) : null);
|
||||
if (props.onClick) btn.addEventListener('click', props.onClick);
|
||||
const offset = `${props.offset ?? 24}px`; // 缺省离边 24px,与常见浮动件手感一致
|
||||
return h('div', {
|
||||
class: `ua-fab-wrap is-${props.position ?? 'right-bottom'}`,
|
||||
style: `--ua-fab-offset:${offset}`,
|
||||
}, btn);
|
||||
}
|
||||
+138
-42
@@ -1,30 +1,39 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { textField } from './textField.js';
|
||||
import type { FormItemHandle, FormItemProps, FormProps, FormRule } from './types.js';
|
||||
import { checkRule, checkRuleAsync } from './formRules.js';
|
||||
import { grid } from './grid.js';
|
||||
import type { FormItemHandle, FormItemProps, FormProps } from './types.js';
|
||||
|
||||
/*** 执行单条规则:null 通过,字符串为错误消息 ***/
|
||||
function checkRule(value: string, rule: FormRule): string | null {
|
||||
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
||||
if (!value) return null; // 非必填且为空:其余规则跳过
|
||||
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
||||
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
||||
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
||||
if (rule.validator) {
|
||||
const r = rule.validator(value);
|
||||
if (r !== true) return typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
let uid = 0; // 字段序号:错误区 / 辅助说明的 id 必须全页唯一,否则多表单时读屏关联到别人家的文案
|
||||
|
||||
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄供 form 聚合校验 ***/
|
||||
/*** 字段容器里的焦点落点:自绘控件(非搜索式下拉、评分、开关)没有原生 input,只查 input 会拿到 null,退到按钮 / 带 tabindex 的节点 ***/
|
||||
const focusableOf = (box: HTMLElement): HTMLElement | null =>
|
||||
box.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select, button, [tabindex]:not([tabindex="-1"])');
|
||||
|
||||
/*** 表单字段:label + 控件插槽 + 错误区;返回句柄(同步 / 异步两种校验)供 form 聚合 ***/
|
||||
export function formItem(props: FormItemProps, control: HTMLElement): FormItemHandle {
|
||||
// —— 状态集中声明 ——
|
||||
const mode = props.validateOn ?? 'blur'; // 校验时机;缺省失焦校验
|
||||
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])];
|
||||
const error = h('div', { class: 'ua-form-error' });
|
||||
const label = h('label', { class: 'ua-form-label' }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null);
|
||||
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, error));
|
||||
const rules = [...(props.required ? [{ required: true }] : []), ...(props.rules ?? [])]; // 必填规则排最前
|
||||
const fid = `ua-form-${(uid += 1)}`; // 本字段唯一后缀:错误区 / 辅助说明 / 标签各自派生 id
|
||||
// 标签归属:恰好一个原生控件才用 for(单选组的 for 会变成「点标签选中第一项」),组类控件改用 aria-labelledby
|
||||
const native = Array.from(control.querySelectorAll<HTMLElement>('input:not([type="hidden"]), textarea, select'));
|
||||
const only = (control.matches('input:not([type="hidden"]), textarea, select') ? [control] : native).length === 1
|
||||
? (control.matches('input:not([type="hidden"]), textarea, select') ? control : native[0])
|
||||
: null;
|
||||
if (only && !only.id) only.id = `${fid}-control`; // 组件自带的 id 不动:可能是它内部逻辑的锚
|
||||
if (!only) (control.hasAttribute('role') ? control : focusableOf(control))?.setAttribute('aria-labelledby', `${fid}-label`); // 自绘下拉 / 单选组靠标签 id 取名
|
||||
const error = h('div', { class: 'ua-form-error', id: `${fid}-error` }); // 错误区:id 供 aria-describedby 指过来
|
||||
const hint = props.hint ? h('div', { class: 'ua-form-hint', id: `${fid}-hint` }, props.hint) : null; // 辅助说明:报错时隐藏,避免与错误挤在一起
|
||||
const label = h('label', { class: 'ua-form-label', for: only?.id ?? null, id: `${fid}-label` }, props.label, props.required ? h('i', { class: 'ua-form-req' }, '*') : null);
|
||||
const el = h('div', { class: 'ua-form-item' }, label, h('div', { class: 'ua-form-control' }, control, hint, error));
|
||||
if (props.span) el.style.gridColumn = `span ${props.span}`; // 多列布局里跨列;单列容器下由 CSS 还原为整行
|
||||
// 条件显示:隐藏时顺带清掉错误态,否则「看不见的红框」会拦住提交
|
||||
if (props.visible) effect(() => { el.hidden = !props.visible?.(); if (el.hidden) paint(null); });
|
||||
let seq = 0; // 校验轮次:异步结论回来时若已有更新的一轮,丢弃旧结果(防乱序覆盖)
|
||||
// 初始值快照:重置要回到「刚渲染时」而不是空串(带默认值的字段不能被清成空)
|
||||
const initial = Array.from(control.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input, textarea, select'))
|
||||
.map((node) => ({ node, value: node.value, checked: (node as HTMLInputElement).checked }));
|
||||
|
||||
/*** 错误态落点:控件根 + 内部原生控件,保证 select / checkbox 这类包裹式控件也能标红 ***/
|
||||
const marked = (): HTMLElement[] => [
|
||||
@@ -32,57 +41,144 @@ export function formItem(props: FormItemProps, control: HTMLElement): FormItemHa
|
||||
...Array.from(control.querySelectorAll<HTMLElement>('input, textarea, select, .ua-select-trigger')),
|
||||
];
|
||||
|
||||
/*** 执行全部规则:返回首个错误消息,同时同步错误区、控件态与 aria-invalid ***/
|
||||
const validate = () => {
|
||||
/*** 落笔:错误文案 + 控件红框 + aria-invalid + 说明关联,视觉与读屏同源 ***/
|
||||
const paint = (msg: string | null): void => {
|
||||
error.textContent = msg ?? '';
|
||||
if (hint) hint.hidden = !!msg; // 有错时让位:同一位置只留一条提示
|
||||
control.classList.toggle('ua-invalid', !!msg);
|
||||
const described = msg ? error.id : hint && !hint.hidden ? hint.id : ''; // 读屏该念哪条:报错优先,其次辅助说明
|
||||
for (const node of marked()) {
|
||||
node.setAttribute('aria-invalid', String(!!msg));
|
||||
if (described) node.setAttribute('aria-describedby', described); // 只说「无效」不够,要把原因一起念出来
|
||||
else node.removeAttribute('aria-describedby'); // 无文案可念时摘掉,别留一个指空的关联
|
||||
}
|
||||
el.dispatchEvent(new CustomEvent('ua:field-validated')); // 不冒泡:只让归属本字段的表单重算汇总条
|
||||
};
|
||||
|
||||
/*** 同步校验:异步规则不结论,保证 blur / input 路径不被 Promise 拖慢 ***/
|
||||
const validate = (): string | null => {
|
||||
if (el.hidden) return null; // 隐藏项不参与校验:没显示的字段不该拦住提交
|
||||
const value = props.value();
|
||||
let msg: string | null = null;
|
||||
for (const rule of rules) {
|
||||
msg = checkRule(value, rule);
|
||||
if (msg) break; // 首个失败即停止
|
||||
}
|
||||
error.textContent = msg ?? '';
|
||||
control.classList.toggle('ua-invalid', !!msg);
|
||||
for (const node of marked()) node.setAttribute('aria-invalid', String(!!msg)); // 读屏同步错误态
|
||||
paint(msg);
|
||||
return msg;
|
||||
};
|
||||
|
||||
/*** 异步校验:按序 await 每条规则,结论按轮次取舍,过期结果不动 UI ***/
|
||||
const validateAsync = async (): Promise<string | null> => {
|
||||
if (el.hidden) return null; // 隐藏项不参与异步校验,与同步版同一口径
|
||||
seq += 1;
|
||||
const mine = seq; // 本轮编号
|
||||
const value = props.value();
|
||||
let msg: string | null = null;
|
||||
for (const rule of rules) {
|
||||
msg = await checkRuleAsync(value, rule);
|
||||
if (msg) break;
|
||||
}
|
||||
if (mine === seq) paint(msg); // 期间又有新校验:交给最新一轮落笔
|
||||
return msg;
|
||||
};
|
||||
|
||||
/*** 复位:先跑调用方的自绘控件钩子,再把原生控件还原到初始值并同步信号,最后清错误态 ***/
|
||||
const reset = (): void => {
|
||||
props.reset?.(); // 自绘控件(无原生 input)拿不到值,只能交给调用方回写信号
|
||||
for (const { node, value, checked } of initial) {
|
||||
const boxed = node instanceof HTMLInputElement && (node.type === 'checkbox' || node.type === 'radio'); // 勾选类走 checked
|
||||
if (boxed) (node as HTMLInputElement).checked = checked;
|
||||
else node.value = value;
|
||||
node.dispatchEvent(new Event('input', { bubbles: true })); // 冒泡给组件监听器:信号跟着改,否则下次渲染又把旧值写回 DOM
|
||||
if (boxed) node.dispatchEvent(new Event('change', { bubbles: true })); // 勾选类只认 change,input 未必触发
|
||||
}
|
||||
paint(null); // 红框与错误文案一起退场
|
||||
};
|
||||
|
||||
control.addEventListener('input', () => {
|
||||
if (mode === 'input' || error.textContent) validate(); // input 模式即时校验;其余模式仅清已报错误
|
||||
if (mode === 'input' || error.textContent) void validateAsync(); // input 模式即时校验;其余模式仅清已报错误
|
||||
});
|
||||
control.addEventListener('change', () => {
|
||||
if (mode === 'input') validate(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
|
||||
if (mode === 'input') void validateAsync(); // select / checkbox / radio 变更即校验(原生 input 事件不冒泡)
|
||||
});
|
||||
control.addEventListener('focusout', () => {
|
||||
if (mode !== 'submit') validate(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
|
||||
if (mode !== 'submit') void validateAsync(); // 用会冒泡的 focusout 取代 blur,包裹式控件同样生效
|
||||
});
|
||||
return { el, validate };
|
||||
return { el, validate, validateAsync, reset };
|
||||
}
|
||||
|
||||
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit) ***/
|
||||
/*** 表单容器:布局 + 原生 submit 拦截 + 聚合校验(全部通过才触发 onSubmit,异步规则会等待) ***/
|
||||
export function form(props: FormProps, items: FormItemHandle[]): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
/*** 整体提交:逐项校验,聚焦首个出错项并中止 ***/
|
||||
const submit = () => {
|
||||
const summary = h('div', { class: 'ua-form-summary', role: 'alert' }) as HTMLElement; // 错误汇总:读屏能一次听到共几处要改
|
||||
summary.hidden = true;
|
||||
let quiet = false; // 重算中标记:validate → paint → 事件 → 重算 会自激,必须挡住
|
||||
/*** 汇总条实时重算:字段改了错误数要跟着掉,否则「有 2 项需要修改」会一直挂到下次提交 ***/
|
||||
const refreshSummary = (): void => {
|
||||
if (quiet || summary.hidden) return; // 卫语句:只有条子已经亮着才需要刷新
|
||||
quiet = true;
|
||||
const bad = items.filter((item) => item.validate()).length; // 同步校验足够:异步规则本就到提交时才下结论
|
||||
quiet = false;
|
||||
summary.hidden = bad === 0;
|
||||
summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : '';
|
||||
};
|
||||
for (const item of items) item.el.addEventListener('ua:field-validated', refreshSummary); // 逐字段订阅
|
||||
/*** 重置:逐项复位 + 收起汇总条,最后交给调用方回调(只跑回调等于点了没反应) ***/
|
||||
const doReset = (): void => {
|
||||
for (const item of items) item.reset();
|
||||
summary.hidden = true;
|
||||
summary.textContent = '';
|
||||
props.onReset?.();
|
||||
};
|
||||
const busy = signal(false); // 校验进行中:按钮转圈 + aria-busy,异步规则跑 400ms 时用户看得见在等
|
||||
const submitBtn = button({ label: props.submitText ?? '提交', type: 'submit', loading: busy }) as HTMLButtonElement; // 走原生 submit,避免 click + submit 双触发
|
||||
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', type: 'button', onClick: doReset }) : null; // 重置不得提交表单
|
||||
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
|
||||
let running = false; // 提交中标记:异步校验期间屏蔽重复提交
|
||||
|
||||
/*** 整体提交:逐项 await 校验(全部跑完以显示所有错误),有错聚焦首个并中止 ***/
|
||||
const submit = async (): Promise<void> => {
|
||||
if (running) return; // 卫语句:上一轮异步校验还没结束
|
||||
running = true;
|
||||
busy(true); // loading 自带禁点,不必再手写 disabled(两处都设反而容易漏复位)
|
||||
let firstBad: HTMLElement | null = null;
|
||||
let bad = 0; // 不通过的字段数
|
||||
for (const item of items) {
|
||||
if (item.validate()) {
|
||||
(item.el.querySelector('input, textarea, select') as HTMLElement | null)?.focus();
|
||||
return; // 卫语句:有错误不提交
|
||||
}
|
||||
const msg = await item.validateAsync();
|
||||
if (!msg) continue; // 卫语句:本项通过
|
||||
bad += 1;
|
||||
if (firstBad) continue; // 只把首个错误控件留作焦点目标
|
||||
const target = focusableOf(item.el);
|
||||
if (!target) item.el.tabIndex = -1; // 整块无可聚焦控件时把字段容器本身设为焦点落点(-1 不进 Tab 序,只接受程序聚焦)
|
||||
firstBad = target ?? item.el;
|
||||
}
|
||||
busy(false);
|
||||
running = false;
|
||||
summary.hidden = bad === 0;
|
||||
summary.textContent = bad ? `有 ${bad} 项需要修改,已定位到第一处` : '';
|
||||
if (bad > 0) { firstBad?.focus(); props.onError?.(bad); return; } // 有错即中止:判据用错误数而非焦点目标,否则自绘控件字段会漏过去直接提交
|
||||
props.onSubmit?.();
|
||||
};
|
||||
const submitBtn = button({ label: props.submitText ?? '提交', onClick: submit });
|
||||
const resetBtn = props.resetText ? button({ label: props.resetText, variant: 'ghost', onClick: props.onReset }) : null;
|
||||
const actions = h('div', { class: 'ua-form-actions' }, submitBtn, resetBtn);
|
||||
|
||||
const fields = items.map((i) => i.el); // 字段节点:单列直接铺,多列交给栅格
|
||||
const board = props.columns && props.columns > 1 ? grid({ columns: props.columns, gutter: 'lg' }, ...fields) : fields; // 多列布局复用 grid
|
||||
const el = h(
|
||||
'form',
|
||||
{ class: props.layout === 'horizontal' ? 'ua-form ua-form-h' : 'ua-form' },
|
||||
...items.map((i) => i.el),
|
||||
props.errorSummary === false ? null : summary, // 汇总条:关掉时连节点都不插
|
||||
board, // 多列时是一层 grid,单列时是字段数组(h 自动扁平化)
|
||||
actions,
|
||||
);
|
||||
el.addEventListener('submit', (e) => {
|
||||
e.preventDefault(); // 拦截原生提交,走校验流程
|
||||
submit();
|
||||
void submit();
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || e.target instanceof HTMLTextAreaElement) return; // 多行输入框允许换行
|
||||
if (!(e.target instanceof HTMLInputElement)) return; // 只接管单行输入框
|
||||
e.preventDefault(); // 拦下浏览器隐式提交,统一走校验
|
||||
void submit();
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FormRule } from './types.js';
|
||||
|
||||
/*** 执行规则的同步部分(required / min / max / pattern);null 通过,字符串为错误消息 ***/
|
||||
export function syncPart(value: string, rule: FormRule): string | null {
|
||||
if (rule.required && !value.trim()) return rule.message ?? '该项为必填项';
|
||||
if (!value) return null; // 非必填且为空:其余规则跳过
|
||||
if (rule.min !== undefined && value.length < rule.min) return rule.message ?? `至少 ${rule.min} 个字符`;
|
||||
if (rule.max !== undefined && value.length > rule.max) return rule.message ?? `最多 ${rule.max} 个字符`;
|
||||
if (rule.pattern && !rule.pattern.test(value)) return rule.message ?? '格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
/*** 同步求值单条规则:validator 返回 Promise 时视为待定(交给异步版),null 通过 ***/
|
||||
export function checkRule(value: string, rule: FormRule): string | null {
|
||||
const syncMsg = syncPart(value, rule);
|
||||
if (syncMsg || !value || !rule.validator) return syncMsg;
|
||||
const r = rule.validator(value);
|
||||
if (typeof r === 'object' && r !== null) return null; // Promise:异步规则,此处不下结论
|
||||
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
|
||||
/*** 异步求值单条规则:await validator,同步返回值的规则结论与同步版一致 ***/
|
||||
export async function checkRuleAsync(value: string, rule: FormRule): Promise<string | null> {
|
||||
const syncMsg = syncPart(value, rule);
|
||||
if (syncMsg || !value || !rule.validator) return syncMsg;
|
||||
const r = await rule.validator(value);
|
||||
return r === true ? null : typeof r === 'string' ? r : rule.message ?? '校验未通过';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { GridProps } from './types.js';
|
||||
|
||||
// 间距档位 → 令牌(数字档位不入表,直接拼像素)
|
||||
const GUTTER: Record<string, string> = { sm: 'var(--ua-space-2)', md: 'var(--ua-space-3)', lg: 'var(--ua-space-4)' };
|
||||
// 交叉轴对齐 → grid align-items 取值
|
||||
const ALIGN: Record<string, string> = { start: 'start', center: 'center', end: 'end', stretch: 'stretch' };
|
||||
|
||||
/*** 栅格容器:columns 固定列数、min 走 auto-fit 自适应列宽,gutter 统一列间距;列模板写进 --ua-grid-cols,窄屏由样式表降为单列 ***/
|
||||
export function grid(props: GridProps, ...children: unknown[]): HTMLElement {
|
||||
const gutter = typeof props.gutter === 'number' ? `${props.gutter}px` : GUTTER[props.gutter ?? 'md']; // 数字档位按像素透传
|
||||
const template = props.min
|
||||
? `repeat(auto-fit, minmax(${props.min}, 1fr))` // 自适应:列数由可用宽度决定
|
||||
: `repeat(${props.columns ?? 3}, minmax(0, 1fr))`; // 固定列数:minmax(0,1fr) 防止长内容撑破
|
||||
const el = h('div', {
|
||||
class: ['ua-grid', props.dense ? 'is-dense' : '', props.align ? `is-align-${props.align}` : ''].filter(Boolean).join(' '),
|
||||
style: `gap:${gutter};align-items:${ALIGN[props.align ?? 'stretch']};`,
|
||||
}, ...children);
|
||||
el.style.setProperty('--ua-grid-cols', template); // 列模板交给 CSS 变量,媒体查询可覆盖
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { HeatmapProps } from './types.js';
|
||||
|
||||
// 缺省:最近 12 周、格子 11px、周一为首行——与国内日历习惯一致
|
||||
const WEEKS = 12;
|
||||
const CELL = 11;
|
||||
const DAY = 86_400_000;
|
||||
const LEVELS = 4; // 非零分四档,加上「零」共五档
|
||||
|
||||
/*** 补零与日期键:热力图的键必须与外部数据同一格式(YYYY-MM-DD) ***/
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
const keyOf = (d: Date): string => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
|
||||
/*** 日历热力图:按周列 × 星期行铺格子,颜色深浅表示当天数值;不画坐标轴,靠 title 看具体日期与数量 ***/
|
||||
export function heatmap(props: HeatmapProps): HTMLElement {
|
||||
const weeks = props.weeks ?? WEEKS;
|
||||
const cell = props.cellSize ?? CELL;
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of props.values) counts.set(item.date, (counts.get(item.date) ?? 0) + item.count); // 同一天多条求和,不覆盖
|
||||
const max = Math.max(1, ...counts.values()); // 分母:至少 1,避免全零时除零
|
||||
// 末列对齐到「本周」:先取今天,再回退到本周末,保证最后一列是完整 7 行
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const end = new Date(today.getTime() + (6 - ((today.getDay() + 6) % 7)) * DAY); // 周一起算的第 6 天(周日)
|
||||
const start = new Date(end.getTime() - (weeks * 7 - 1) * DAY);
|
||||
const grid = h('div', { class: 'ua-heat-grid', role: 'img', 'aria-label': props.ariaLabel ?? `最近 ${weeks} 周活跃度` });
|
||||
grid.style.gridTemplateRows = `repeat(7, ${cell}px)`; // 七行对应周一到周日,行高与格子边长同源
|
||||
for (let i = 0; i < weeks * 7; i += 1) {
|
||||
const d = new Date(start.getTime() + i * DAY);
|
||||
const n = counts.get(keyOf(d)) ?? 0;
|
||||
const level = n === 0 ? 0 : Math.min(LEVELS, Math.ceil((n / max) * LEVELS)); // 归一成四档,最大那天必落在最高档
|
||||
const box = h('span', { class: `ua-heat-cell is-${level}` });
|
||||
box.style.width = `${cell}px`;
|
||||
box.style.height = `${cell}px`;
|
||||
box.title = props.formatValue ? props.formatValue(n, keyOf(d)) : `${keyOf(d)} · ${n}`; // 悬停看明细:热力图本身不标数字
|
||||
grid.append(box);
|
||||
}
|
||||
const legend = h(
|
||||
'div',
|
||||
{ class: 'ua-heat-legend' },
|
||||
h('span', { class: 'ua-heat-hint' }, props.legendText ?? '少'),
|
||||
...[0, 1, 2, 3, 4].map((lv) => { const sw = h('span', { class: `ua-heat-cell is-${lv}` }); sw.style.width = `${cell}px`; sw.style.height = `${cell}px`; return sw; }), // 图例色块与网格同尺寸:两处各写一个尺寸会差出一号
|
||||
h('span', { class: 'ua-heat-hint' }, '多'),
|
||||
);
|
||||
return h('div', { class: 'ua-heat' }, grid, legend);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { effect, h, type Signal } from '@ui-arch/frame';
|
||||
import type { HighlightProps } from './types.js';
|
||||
|
||||
/*** 作用:读信号或原始值(string | Signal<string> 统一取值) ***/
|
||||
const read = (v: string | Signal<string>): string => (typeof v === 'function' ? v() : v);
|
||||
|
||||
/*** 关键词高亮:命中片段包进 mark(大小写不敏感、全部命中),text / keyword 均支持信号响应式刷新,空关键词原样展示 ***/
|
||||
export function highlight(props: HighlightProps): HTMLSpanElement {
|
||||
const root = h('span', { class: 'ua-highlight' }) as HTMLSpanElement;
|
||||
|
||||
/*** 作用:按当前 text / keyword 重画内容:命中段包 mark,未命中段原样拼接(effect 追踪两个信号,任一变化重画) ***/
|
||||
const render = (): void => {
|
||||
const text = read(props.text);
|
||||
const kw = read(props.keyword).trim();
|
||||
root.textContent = '';
|
||||
if (!kw) { root.textContent = text; return; } // 空关键词不高亮
|
||||
const lower = text.toLowerCase();
|
||||
const k = kw.toLowerCase();
|
||||
let cursor = 0;
|
||||
for (let idx = lower.indexOf(k); idx !== -1; idx = lower.indexOf(k, cursor)) {
|
||||
if (idx > cursor) root.append(text.slice(cursor, idx)); // 命中前缀原样
|
||||
root.append(h('mark', { class: 'ua-highlight-mark' }, text.slice(idx, idx + k.length))); // 命中段包 mark
|
||||
cursor = idx + k.length;
|
||||
}
|
||||
if (cursor < text.length) root.append(text.slice(cursor)); // 尾段原样
|
||||
};
|
||||
|
||||
effect(render);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*** 内联图标工厂:组件内部用小图标一律走 SVG,不用 emoji —— emoji 在 Windows / macOS 会渲染成彩色位图,尺寸、颜色、基线都不可控 ***/
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg'; // SVG 命名空间常量,createElementNS 必填
|
||||
|
||||
/*** 放大镜图标:搜索框的默认前缀。外层 span 承载布局,返回 HTMLElement 以兼容 prefix 入参类型 ***/
|
||||
export function iconSearch(): HTMLElement {
|
||||
const holder = document.createElement('span');
|
||||
holder.style.display = 'inline-flex'; // 让图标与输入文本在同一基线居中
|
||||
holder.setAttribute('aria-hidden', 'true'); // 纯装饰:语义由输入框 placeholder / label 承担
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 16 16');
|
||||
svg.setAttribute('width', '14');
|
||||
svg.setAttribute('height', '14');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor'); // 跟随主题文字色,暗色自动翻转
|
||||
svg.setAttribute('stroke-width', '1.5');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
const lens = document.createElementNS(NS, 'circle');
|
||||
lens.setAttribute('cx', '7');
|
||||
lens.setAttribute('cy', '7');
|
||||
lens.setAttribute('r', '4.5');
|
||||
const handle = document.createElementNS(NS, 'line');
|
||||
handle.setAttribute('x1', '10.5');
|
||||
handle.setAttribute('y1', '10.5');
|
||||
handle.setAttribute('x2', '14');
|
||||
handle.setAttribute('y2', '14');
|
||||
svg.append(lens, handle);
|
||||
holder.append(svg);
|
||||
return holder;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
|
||||
import type { ImageProps } from './types.js';
|
||||
|
||||
/*** 图片:缩略图展示,点击 / 回车打开遮罩大图预览(Esc 或点击遮罩关闭),加载失败自动落占位;样式只消费 --ua-* 令牌 ***/
|
||||
export function image(props: ImageProps): HTMLElement {
|
||||
const style = props.width ? `width:${props.width}px` : '';
|
||||
const img = h('img', {
|
||||
class: 'ua-image',
|
||||
src: props.src,
|
||||
alt: props.alt ?? '',
|
||||
style,
|
||||
tabindex: '0', // 可聚焦,键盘用户回车预览
|
||||
role: 'button',
|
||||
'aria-label': '预览图片',
|
||||
onclick: () => openViewer(props.src, props.alt),
|
||||
onkeydown: (e: KeyboardEvent) => e.key === 'Enter' && openViewer(props.src, props.alt),
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
const box = h('span', { class: 'ua-image-fallback', style, role: 'img', 'aria-label': props.fallback ?? '图片加载失败' }, props.fallback ?? '图片加载失败');
|
||||
img.replaceWith(box); // 失败兜底:整块替换,避免残留破图图标
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
/*** 打开全屏遮罩预览层;关闭时自清理、解绑键盘并归还焦点 ***/
|
||||
function openViewer(src: string, alt?: string): void {
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:预览层占一层,关闭即释放
|
||||
const releaseLock = lockScroll(); // 预览期锁背景滚动
|
||||
const prev = document.activeElement as HTMLElement | null; // 焦点归还目标
|
||||
const mask = h(
|
||||
'div',
|
||||
{ class: 'ua-image-mask', role: 'dialog', 'aria-modal': 'true', 'aria-label': '图片预览', tabindex: '-1' },
|
||||
h('img', { class: 'ua-image-full', src, alt: alt ?? '' }),
|
||||
);
|
||||
const close = () => {
|
||||
mask.remove(); // 自清理:遮罩移除 + 出 Esc 栈 + 焦点归还
|
||||
releaseEsc?.();
|
||||
releaseLock();
|
||||
prev?.focus?.();
|
||||
};
|
||||
mask.addEventListener('click', close);
|
||||
releaseEsc = pushOverlay(close); // 入 Esc 栈:预览层压在最上面,Esc 先关它
|
||||
trapFocus(mask, () => true); // 预览层常开:Tab 全程锁在遮罩内(层内无控件,等于整体拦截)
|
||||
document.body.appendChild(mask);
|
||||
mask.focus(); // 焦点移入预览层,读屏与键盘都不再落在背后的页面
|
||||
}
|
||||
@@ -5,8 +5,10 @@ export { inputNumber } from './inputNumber.js';
|
||||
export { textarea } from './textarea.js';
|
||||
export { slider } from './slider.js';
|
||||
export { checkbox } from './checkbox.js';
|
||||
export { checkboxGroup } from './checkboxGroup.js';
|
||||
export { switchButton } from './switch.js';
|
||||
export { select } from './select.js';
|
||||
export { multiSelect } from './multiSelect.js';
|
||||
export { menu } from './menu.js';
|
||||
export { radioGroup } from './radioGroup.js';
|
||||
export { accordion } from './accordion.js';
|
||||
@@ -15,17 +17,77 @@ export { card } from './card.js';
|
||||
export { badge } from './badge.js';
|
||||
export { tag } from './tag.js';
|
||||
export { avatar } from './avatar.js';
|
||||
export { image } from './image.js';
|
||||
export { alert } from './alert.js';
|
||||
export { toast, showToast } from './toast.js';
|
||||
export { notification } from './notification.js';
|
||||
export { tooltip } from './tooltip.js';
|
||||
export { popover } from './popover.js';
|
||||
export { flip } from './position.js';
|
||||
export { skeleton } from './skeleton.js';
|
||||
export { pagination } from './pagination.js';
|
||||
export { spinner } from './spinner.js';
|
||||
export { progress } from './progress.js';
|
||||
export { tabs } from './tabs.js';
|
||||
export { segmented } from './segmented.js';
|
||||
export { stepper } from './stepper.js';
|
||||
export { rate } from './rate.js';
|
||||
export { empty } from './empty.js';
|
||||
export { result } from './result.js';
|
||||
export { divider } from './divider.js';
|
||||
export { space } from './space.js';
|
||||
export { list } from './list.js';
|
||||
export { carousel } from './carousel.js';
|
||||
export { tree } from './tree.js';
|
||||
export { upload } from './upload.js';
|
||||
export { descriptions } from './descriptions.js';
|
||||
export { statistic } from './statistic.js';
|
||||
export { timeline } from './timeline.js';
|
||||
export { modal } from './modal.js';
|
||||
export { drawer } from './drawer.js';
|
||||
export { form, formItem } from './form.js';
|
||||
export { dataTable } from './dataTable.js';
|
||||
export { transfer } from './transfer.js';
|
||||
export { cascader } from './cascader.js';
|
||||
export { splitter } from './splitter.js';
|
||||
export { autoComplete } from './autoComplete.js';
|
||||
export { datePicker } from './datePicker.js';
|
||||
export { backTop } from './backTop.js';
|
||||
export { colorPicker } from './colorPicker.js';
|
||||
export { tour } from './tour.js';
|
||||
export { watermark } from './watermark.js';
|
||||
export { affix } from './affix.js';
|
||||
export { anchor } from './anchor.js';
|
||||
export { popconfirm } from './popconfirm.js';
|
||||
export { calendar } from './calendar.js';
|
||||
export { typography } from './typography.js';
|
||||
export { grid } from './grid.js';
|
||||
export { floatButton } from './floatButton.js';
|
||||
export { mentions } from './mentions.js';
|
||||
export { treeSelect } from './treeSelect.js';
|
||||
export { link } from './link.js';
|
||||
export { avatarGroup } from './avatarGroup.js';
|
||||
export { virtualList } from './virtualList.js';
|
||||
export { commandPalette } from './commandPalette.js';
|
||||
export { timePicker } from './timePicker.js';
|
||||
export { pinInput } from './pinInput.js';
|
||||
export { copyButton } from './copyButton.js';
|
||||
export { countdown } from './countdown.js';
|
||||
export { kbd } from './kbd.js';
|
||||
export { highlight } from './highlight.js';
|
||||
export { inputTag } from './inputTag.js';
|
||||
export { layout } from './layout.js';
|
||||
export { jsonViewer } from './jsonViewer.js';
|
||||
export { diffView } from './diffView.js';
|
||||
export { codeBlock } from './codeBlock.js';
|
||||
export { signaturePad } from './signaturePad.js';
|
||||
export { loading } from './loading.js';
|
||||
export { relativeTime } from './relativeTime.js';
|
||||
export { sparkline } from './sparkline.js';
|
||||
export { heatmap } from './heatmap.js';
|
||||
export { progressRing } from './progressRing.js';
|
||||
export { noticeBar } from './noticeBar.js';
|
||||
export { sortableList } from './sortableList.js';
|
||||
export { masonry } from './masonry.js';
|
||||
export { editable } from './editable.js';
|
||||
export { theme, currentThemeDark } from './theme.js';
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { addonWrap } from './addons.js';
|
||||
import type { InputNumberProps } from './types.js';
|
||||
|
||||
/*** 数字输入:−/+ 按钮 + 输入框三段式,value 信号双向绑定,min/max/step 超界自动夹取,键盘 ↑↓ 调节 ***/
|
||||
export function inputNumber(props: InputNumberProps): HTMLDivElement {
|
||||
/*** 数字输入:−/+ 按钮 + 输入框三段式,value 信号双向绑定,min/max/step 超界自动夹取,precision 小数位归一,键盘 ↑↓ 调节,长按连发步进 ***/
|
||||
export function inputNumber(props: InputNumberProps): HTMLElement {
|
||||
const min = props.min ?? -Infinity; // 无下限缺省
|
||||
const max = props.max ?? Infinity; // 无上限缺省
|
||||
const step = props.step ?? 1; // 步长缺省 1
|
||||
const clamp = (v: number): number => Math.min(max, Math.max(min, v)); // 任何来源的值都夹回合法区间
|
||||
const emit = (v: number): void => { const n = clamp(v); props.value(n); if (props.onChange) props.onChange(n); }; // 写回信号并回调
|
||||
const input = h('input', { class: 'ua-input ua-input-number-field', inputmode: 'decimal', disabled: props.disabled, 'aria-label': '数字输入' }) as HTMLInputElement;
|
||||
const sync = (): void => { const text = String(props.value()); if (input.value !== text) input.value = text; }; // 外部变化写回输入框
|
||||
const quantize = (n: number): number => (props.precision === undefined ? n : Number(n.toFixed(props.precision))); // precision 归一(顺带消除步进浮点尾差)
|
||||
const emit = (v: number): void => { const n = quantize(clamp(v)); props.value(n); if (props.onChange) props.onChange(n); }; // 写回信号并回调
|
||||
const input = h('input', { class: `ua-input ua-input-number-field${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`, inputmode: 'decimal', disabled: props.disabled, 'aria-label': props.ariaLabel ?? '数字输入' }) as HTMLInputElement;
|
||||
const sync = (): void => { const text = props.precision === undefined ? String(props.value()) : props.value().toFixed(props.precision); if (input.value !== text) input.value = text; }; // 外部变化写回输入框(precision 定长显示)
|
||||
effect(sync);
|
||||
input.addEventListener('change', () => { const n = parseFloat(input.value); if (Number.isNaN(n)) sync(); else emit(n); }); // 非法输入回滚当前值
|
||||
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
@@ -22,5 +24,16 @@ export function inputNumber(props: InputNumberProps): HTMLDivElement {
|
||||
const inc = button({ label: '+', variant: 'ghost', disabled: props.disabled, onClick: () => emit(props.value() + step) });
|
||||
dec.setAttribute('aria-label', '减少');
|
||||
inc.setAttribute('aria-label', '增加');
|
||||
return h('div', { class: 'ua-input-number', role: 'group' }, dec, input, inc) as HTMLDivElement;
|
||||
// —— 长按连发:400ms 延迟后每 100ms 连续步进,松开 / 移出即停(单击行为不受影响) ——
|
||||
let holdTimer = 0; // 长按首步延迟计时器
|
||||
let repeatTimer = 0; // 连发重复计时器
|
||||
const stopHold = (): void => { window.clearTimeout(holdTimer); window.clearInterval(repeatTimer); holdTimer = 0; repeatTimer = 0; }; // 停止连发
|
||||
const hold = (dir: number): void => { // dir:-1 减 / 1 加
|
||||
stopHold(); // 重复按下先清旧计时
|
||||
holdTimer = window.setTimeout(() => { repeatTimer = window.setInterval(() => emit(props.value() + dir * step), 100); }, 400);
|
||||
};
|
||||
dec.addEventListener('pointerdown', () => hold(-1));
|
||||
inc.addEventListener('pointerdown', () => hold(1));
|
||||
['pointerup', 'pointerleave', 'pointercancel'].forEach((ev) => { dec.addEventListener(ev, stopHold); inc.addEventListener(ev, stopHold); }); // 任一中止事件都停
|
||||
return addonWrap(h('div', { class: 'ua-input-number', role: 'group' }, dec, input, inc) as HTMLDivElement, props.prefix, props.suffix);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { effect, h, type Signal } from '@ui-arch/frame';
|
||||
import type { InputTagProps } from './types.js';
|
||||
|
||||
/*** 作用:把布尔或信号口径的禁用态读成当前值(供渲染时统一判断,天然跟随信号) ***/
|
||||
const off = (v: boolean | Signal<boolean> | undefined): boolean => (typeof v === 'function' ? v() : Boolean(v));
|
||||
|
||||
/*** 作用:标签输入——回车 / 逗号成标签、chip 逐个删、空框退格删末个、失焦自动入列;外框与 chip 复用现有类,零新增视觉字面量 ***/
|
||||
export function inputTag(props: InputTagProps): HTMLElement {
|
||||
const root = h('div', { class: 'ua-input-group ua-input-tag' }) as HTMLElement;
|
||||
const hint = props.placeholder ?? '输入后回车添加'; // 未满时的占位文案
|
||||
const input = h('input', { class: 'ua-input', placeholder: hint, 'aria-label': props.ariaLabel ?? '标签输入' }) as HTMLInputElement;
|
||||
const limit = props.max ?? 0; // 0 = 不限条数
|
||||
const maxLength = props.maxLength ?? 24; // 单个标签超长直接截断,防止一个标签撑爆整行
|
||||
const allowDuplicate = props.allowDuplicate ?? false;
|
||||
const SEP = /[,,\n\t]+/; // 多值粘贴的分隔符:半角 / 全角逗号、换行、制表符(从表格或列表里整段粘来最常见)
|
||||
root.append(input);
|
||||
|
||||
/*** 作用:写回标签集合(单一出口:信号与 onChange 永远同源,避免两处各写一次) ***/
|
||||
const put = (next: string[]): void => {
|
||||
props.value(next);
|
||||
props.onChange?.(next);
|
||||
};
|
||||
|
||||
/*** 作用:追加一个标签:截断超长、丢弃重复与超限,并把输入框清空 ***/
|
||||
const commit = (raw: string): void => {
|
||||
const text = raw.trim().slice(0, maxLength);
|
||||
input.value = '';
|
||||
if (!text) return;
|
||||
const list = props.value();
|
||||
if (limit > 0 && list.length >= limit) return; // 已达上限:静默丢弃,输入框已清空
|
||||
if (!allowDuplicate && list.includes(text)) return; // 重复标签:不重复入列
|
||||
put([...list, text]);
|
||||
};
|
||||
|
||||
/*** 作用:按信号重画 chip 与输入框状态(chip 全量重建,input 始终留在末尾) ***/
|
||||
const render = (): void => {
|
||||
const list = props.value();
|
||||
root.querySelectorAll('.ua-tag').forEach((node) => node.remove()); // 先清后建:条数少,重建比 diff 稳
|
||||
for (const tag of list) {
|
||||
const close = h('button', {
|
||||
type: 'button',
|
||||
class: 'ua-tag-close',
|
||||
'aria-label': `移除标签 ${tag}`,
|
||||
onClick: () => {
|
||||
put(props.value().filter((item) => item !== tag)); // 按值删,不用索引(外部可能已改序)
|
||||
input.focus(); // 焦点归还:chip 一删按钮就消失,不接住焦点会掉回 body
|
||||
},
|
||||
}, '×');
|
||||
root.insertBefore(h('span', { class: 'ua-tag' }, h('span', {}, tag), close), input);
|
||||
}
|
||||
const full = limit > 0 && list.length >= limit;
|
||||
input.disabled = off(props.disabled) || full; // 满额即禁输:占位文案同时改成「已达上限」
|
||||
input.placeholder = full ? `最多 ${limit} 个标签` : hint;
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
|
||||
event.preventDefault(); // 回车不提交外层表单;逗号不留进输入值
|
||||
commit(input.value);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && input.value === '') {
|
||||
const list = props.value();
|
||||
if (list.length) put(list.slice(0, -1)); // 空框退格:删掉最后一个 chip
|
||||
}
|
||||
});
|
||||
input.addEventListener('paste', (event) => {
|
||||
const text = event.clipboardData?.getData('text') ?? '';
|
||||
if (!SEP.test(text)) return; // 单值粘贴交给浏览器默认行为,不打断普通输入
|
||||
event.preventDefault();
|
||||
text.split(SEP).forEach((piece) => commit(piece)); // 多值粘贴:拆成多个 chip,逐个过截断 / 去重 / 上限
|
||||
});
|
||||
input.addEventListener('blur', () => commit(input.value)); // 失焦即入列:手输后忘了回车也不会丢
|
||||
root.addEventListener('click', () => input.focus()); // 点外框任意位置都聚焦输入框
|
||||
|
||||
effect(render);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { JsonViewerProps } from './types.js';
|
||||
|
||||
// 值类型归类:数组 / 对象算分支,其余算叶子(null 单独一类,避免被 typeof 报成 object)
|
||||
type JsonKind = 'array' | 'object' | 'string' | 'number' | 'boolean' | 'null';
|
||||
const kindOf = (v: unknown): JsonKind => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'object' ? 'object' : (typeof v as JsonKind));
|
||||
|
||||
/*** 叶子值文案:字符串补引号,其余按字面;null 用灰字斜体在样式层区分 ***/
|
||||
function leafText(v: unknown): string {
|
||||
return typeof v === 'string' ? `"${v}"` : String(v);
|
||||
}
|
||||
|
||||
/*** 分支摘要:收起时也要看得出里面有多少内容,否则无法判断值不值得展开 ***/
|
||||
function summaryOf(v: unknown): string {
|
||||
if (Array.isArray(v)) return `${v.length} 项`;
|
||||
const n = Object.keys(v as Record<string, unknown>).length; // 对象分支:按自有键计数
|
||||
return `${n} 个键`;
|
||||
}
|
||||
|
||||
/*** JSON 树查看:按层折叠展示任意值,数组 / 对象可展开收起,叶子按类型着色;只读、不解析编辑 ***/
|
||||
export function jsonViewer(props: JsonViewerProps): HTMLElement {
|
||||
const rootLabel = props.rootLabel ?? 'root'; // 根标签:贴顶那一行的键名
|
||||
const openDepth = props.depth ?? 2; // 默认展开层数:2 层能看出一级结构又不至于刷屏
|
||||
const root = h('div', { class: 'ua-json', role: 'tree', 'aria-label': props.ariaLabel ?? 'JSON 数据' }) as HTMLDivElement;
|
||||
if (props.maxHeight !== undefined) root.style.maxHeight = `${props.maxHeight}px`; // 只在给了上限时才限高,缺省随内容
|
||||
|
||||
/*** 单行渲染:分支行带开关与计数摘要,叶子行只带类型类名 ***/
|
||||
const row = (key: string, value: unknown, level: number, path: string): HTMLElement => {
|
||||
const kind = kindOf(value);
|
||||
const branch = kind === 'array' || kind === 'object';
|
||||
const label = h('span', { class: 'ua-json-key' }, key); // 键名:数组下标也用 key 位展示
|
||||
if (!branch) {
|
||||
return h('div', { class: 'ua-json-row is-leaf', role: 'treeitem' }, label, h('span', { class: 'ua-json-colon' }, ':'), h('span', { class: `ua-json-val ua-json-${kind}` }, leafText(value)));
|
||||
}
|
||||
const box = h('div', { class: 'ua-json-children', role: 'group' }); // 子节点容器:收起时整块隐藏,DOM 保留折叠态
|
||||
const caret = h('span', { class: 'ua-json-caret', 'aria-hidden': 'true' }, '▸'); // 开关图标:方向由 aria-expanded 决定样式
|
||||
const toggle = h('button', {
|
||||
type: 'button',
|
||||
class: 'ua-json-toggle',
|
||||
'aria-expanded': () => String(!box.hidden),
|
||||
'aria-label': () => `${box.hidden ? '展开' : '收起'} ${path}`,
|
||||
onClick: () => { box.hidden = !box.hidden; }, // 只翻 hidden:样式按 :has 或 aria-expanded 跟随
|
||||
}, caret, label, h('span', { class: 'ua-json-colon' }, ':'), h('span', { class: 'ua-json-sum' }, `${kind === 'array' ? '[' : '{'}${summaryOf(value)}${kind === 'array' ? ']' : '}'}`));
|
||||
const entries: [string, unknown][] = kind === 'array'
|
||||
? (value as unknown[]).map((v, i) => [String(i), v]) // 数组下标转字符串,走同一套行渲染
|
||||
: Object.entries(value as Record<string, unknown>);
|
||||
for (const [k, v] of entries) box.append(row(k, v, level + 1, `${path}.${k}`)); // 递归:路径用点号拼接,报给读屏时能定位
|
||||
box.hidden = level >= openDepth; // 超出默认层数的一律先收起,用户按需展开
|
||||
return h('div', { class: 'ua-json-branch', role: 'treeitem', 'aria-level': String(level + 1) }, toggle, box);
|
||||
};
|
||||
|
||||
root.append(row(rootLabel, props.value, 0, rootLabel));
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { KbdProps } from './types.js';
|
||||
|
||||
/*** 键盘按键标记:键帽样式展示单个按键或组合键(多键以 + 连接),用于快捷键提示 ***/
|
||||
export function kbd(props: KbdProps): HTMLElement {
|
||||
const keys = Array.isArray(props.keys) ? props.keys : [props.keys]; // 统一成数组:单键 / 组合键同一渲染路径
|
||||
const parts: (Node | null)[] = keys.map((k, i) => (i > 0 ? h('span', { class: 'ua-kbd-sep', 'aria-hidden': 'true' }, '+') : null)); // 分隔符占位
|
||||
keys.forEach((k, i) => parts[i * 2 + 1] = h('kbd', { class: 'ua-kbd-key' }, k)); // 键帽交错插入:sep, key, sep, key…
|
||||
return h('span', { class: 'ua-kbd' }, ...parts);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { h, signal } from '@ui-arch/frame';
|
||||
import type { LayoutProps } from './types.js';
|
||||
|
||||
// 侧栏展开态缺省宽度(px):够放下四个汉字的菜单项,再窄就要靠折叠态图标栏
|
||||
const SIDER_DEFAULT = 220;
|
||||
|
||||
/*** 布局骨架:页头 / 侧栏 / 内容 / 页脚四区一次成型,侧栏可折叠(受控或自管);只管骨架与间距,导航内容交给 menu / anchor ***/
|
||||
export function layout(props: LayoutProps, ...children: unknown[]): HTMLElement {
|
||||
const collapsed = props.collapsed ?? signal(false); // 折叠态:外部给了信号就双向绑定,没给就内部自管
|
||||
const width = props.siderWidth ?? SIDER_DEFAULT; // 侧栏宽度:折叠时由样式表收到 0,这里只给展开值
|
||||
const toggle = (): void => { collapsed(!collapsed()); }; // 折叠开关:只翻信号,视觉由类名响应
|
||||
const rootCls = ['ua-layout', props.sticky ? 'is-sticky' : ''].filter(Boolean).join(' '); // 根类名:吸顶页头是同一个容器上的修饰类
|
||||
const header = props.header === undefined
|
||||
? null // 没有页头就不渲染容器,避免多一层空盒子影响 :first-child 选择器
|
||||
: h(
|
||||
'header',
|
||||
{ class: 'ua-layout-header' },
|
||||
props.collapsible ? h('button', { class: 'ua-layout-toggle', type: 'button', 'aria-expanded': () => String(!collapsed()), 'aria-label': '折叠或展开侧栏', onClick: toggle }, '⟨⟩') : null,
|
||||
props.header,
|
||||
);
|
||||
const sider = props.sider === undefined
|
||||
? null // 没有侧栏时内容区独占整行,省掉一条 220px 的空白
|
||||
: h('aside', {
|
||||
class: () => `ua-layout-sider${collapsed() ? ' is-collapsed' : ''}`,
|
||||
style: `width:${width}px`, // 展开宽度内联,折叠后的收拢宽度交给类名(0 + overflow hidden)
|
||||
'aria-hidden': () => (collapsed() ? 'true' : null), // 折叠后视觉消失,读屏也不再报里面的菜单
|
||||
}, props.sider);
|
||||
const content = h('main', { class: 'ua-layout-content' }, ...children);
|
||||
const body = sider ? h('div', { class: 'ua-layout-body' }, sider, content) : content; // 无侧栏时不套 body 层
|
||||
const footer = props.footer === undefined ? null : h('footer', { class: 'ua-layout-footer' }, props.footer);
|
||||
return h('div', { class: rootCls }, header, body, footer) as HTMLElement;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { LinkProps } from './types.js';
|
||||
|
||||
/*** 链接:站内跳转与站外跳转统一入口;external 自动补 ↗ 与 rel,disabled 去掉 href 并拦点击 ***/
|
||||
export function link(props: LinkProps): HTMLElement {
|
||||
const external = Boolean(props.external) || props.target === '_blank'; // 新窗口一律按站外处理
|
||||
const cls = ['ua-link', props.muted ? 'is-muted' : '', props.disabled ? 'is-disabled' : '', external ? 'is-external' : '']
|
||||
.filter(Boolean).join(' ');
|
||||
const a = h('a', {
|
||||
class: cls,
|
||||
href: props.disabled ? null : props.href, // 禁用态去掉 href:键盘也不再当链接用
|
||||
target: external ? '_blank' : props.target ?? null,
|
||||
rel: external ? 'noopener noreferrer' : null, // 新窗口必须断开 opener,防目标页反向操控
|
||||
'aria-disabled': props.disabled ? 'true' : null,
|
||||
onclick: (e: Event) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault(); // 保留可聚焦但不可跳转
|
||||
return;
|
||||
}
|
||||
props.onClick?.(e);
|
||||
},
|
||||
}, props.label, external ? h('span', { class: 'ua-link-icon', 'aria-hidden': 'true' }, '↗') : null);
|
||||
if (external) a.append(h('span', { class: 'ua-visually-hidden' }, '(在新窗口打开)')); // 读屏需知道开了新窗
|
||||
return a;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { ListProps } from './types.js';
|
||||
import { empty } from './empty.js';
|
||||
|
||||
/*** 列表:header/footer 外壳 + 逐条 items(avatar / title / description / extra),传 onItemClick 时条目可点击,无数据回落空状态 ***/
|
||||
export function list(props: ListProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const clickable = typeof props.onItemClick === 'function'; // 仅提供回调时才加可点击样式
|
||||
const rootClass = props.bordered ? 'ua-list ua-list-bordered' : 'ua-list'; // 外边框由 bordered 决定
|
||||
const root = h('div', { class: rootClass }) as HTMLElement; // 列表根容器
|
||||
if (props.header) root.append(h('div', { class: 'ua-list-header' }, props.header));
|
||||
if (!props.items.length) {
|
||||
root.append(empty({ title: props.emptyText ?? '暂无数据' })); // 空列表复用空状态组件
|
||||
return root;
|
||||
}
|
||||
const body = h('div', { class: 'ua-list-body', role: 'list' }) as HTMLElement; // 条目容器,供读屏识别列表语义
|
||||
props.items.forEach((item, index) => {
|
||||
const row = h(
|
||||
'div',
|
||||
{ class: clickable ? 'ua-list-item ua-list-clickable' : 'ua-list-item', role: 'listitem' },
|
||||
item.avatar ? h('div', { class: 'ua-list-avatar' }, item.avatar) : null, // 左侧头像/图标,缺省不留位
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-list-main' },
|
||||
h('div', { class: 'ua-list-title' }, item.title),
|
||||
item.description ? h('div', { class: 'ua-list-desc' }, item.description) : null,
|
||||
),
|
||||
item.extra ? h('div', { class: 'ua-list-extra' }, item.extra) : null, // 右侧附加(时间、标签)
|
||||
);
|
||||
if (clickable) row.addEventListener('click', () => props.onItemClick?.(item, index)); // 点击回传条目与下标
|
||||
body.append(row);
|
||||
});
|
||||
root.append(body);
|
||||
if (props.footer) root.append(h('div', { class: 'ua-list-footer' }, props.footer));
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import { spinner } from './spinner.js';
|
||||
import type { LoadingProps } from './types.js';
|
||||
|
||||
// 缺省延迟:120ms 内的请求不闪遮罩,否则「点了就闪一下」比不显示更闹
|
||||
const DELAY = 120;
|
||||
|
||||
/*** 区域加载遮罩:包住一块内容,active 为真时盖上层遮罩 + 转圈 + 文案;带延迟出场,快请求不闪、慢请求必有反馈 ***/
|
||||
export function loading(props: LoadingProps, ...children: unknown[]): HTMLElement {
|
||||
const delay = props.delay ?? DELAY; // 延迟毫秒
|
||||
let timer = 0; // 延迟计时器
|
||||
const read = (): boolean => (typeof props.active === 'function' ? Boolean(props.active()) : props.active === true); // 布尔与信号同一口径
|
||||
const mask = h(
|
||||
'div',
|
||||
{ class: 'ua-loading-mask' },
|
||||
spinner({ size: props.spinnerSize ?? 20, label: props.text ?? '内容加载中' }), // 转圈自带 role=status,这里只用它的可访问名
|
||||
props.text ? h('span', { class: 'ua-loading-text' }, props.text) : null,
|
||||
);
|
||||
const wrap = h(
|
||||
'div',
|
||||
{ class: 'ua-loading' },
|
||||
h('div', { class: 'ua-loading-body' }, ...children), // 内容层:遮罩出现时整体降透明度,但仍留在原位不塌陷
|
||||
mask,
|
||||
);
|
||||
if (props.minHeight !== undefined) wrap.style.minHeight = `${props.minHeight}px`; // 内容为空时也要撑出高度,遮罩才盖得住
|
||||
|
||||
/*** 状态落点:is-active 控制遮罩显隐,aria-busy 让读屏知道这块在等数据 ***/
|
||||
const paint = (on: boolean): void => {
|
||||
wrap.classList.toggle('is-active', on);
|
||||
wrap.setAttribute('aria-busy', String(on));
|
||||
};
|
||||
effect(() => {
|
||||
window.clearTimeout(timer);
|
||||
if (!read()) { paint(false); return; } // 关得快:立刻收,不等延迟
|
||||
timer = window.setTimeout(() => paint(true), delay); // 开得慢:先等一会儿,避免快速请求闪一下
|
||||
});
|
||||
paint(read()); // 首帧:初值为真时直接亮(延迟只管后续变化)
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { MasonryProps } from './types.js';
|
||||
|
||||
/*** 瀑布流:CSS 多列布局,列数按容器宽度自适应或显式指定;每项单独包一层才能 break-inside 不拆断 ***/
|
||||
export function masonry(props: MasonryProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const adaptive = !props.columns; // 未指定列数即自适应(column-width 让浏览器自己算列数)
|
||||
const width = props.minColumnWidth ?? 240; // 自适应时的最小列宽(像素)
|
||||
const gap = props.gap ?? 'var(--ua-space-3)'; // 列间距与行间距同源,避免两处各写一份
|
||||
const root = h('div', { class: 'ua-masonry' }) as HTMLElement;
|
||||
root.style.columnGap = gap;
|
||||
if (adaptive) root.style.columnWidth = `${width}px`; // 自适应:容器越宽列数越多,窄屏自动降到一列
|
||||
else root.style.columnCount = String(props.columns); // 显式列数:与 column-width 互斥,固定几列就几列
|
||||
|
||||
props.items.forEach((item) => {
|
||||
const cell = h('div', { class: 'ua-masonry-item' }, item);
|
||||
cell.style.marginBottom = gap; // 行间距与列间距同值,视觉上才是均匀网格
|
||||
root.append(cell);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { MentionItem, MentionsProps } from './types.js';
|
||||
|
||||
// 每个实例的 listbox id 前缀,保证 aria-activedescendant 唯一
|
||||
let seq = 0;
|
||||
|
||||
/*** 取光标前的 @ 触发词(不含 @ 本身):没有未闭合的 @ 片段时返回 null ***/
|
||||
function tokenBefore(text: string, caret: number): string | null {
|
||||
const match = /@([^\s@]*)$/.exec(text.slice(0, caret));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/*** 用选中的名字替换光标前的 @片段,返回替换后的全文与新光标位置 ***/
|
||||
function applyPick(text: string, caret: number, name: string): { text: string; caret: number } {
|
||||
const head = text.slice(0, caret).replace(/@[^\s@]*$/, `@${name} `);
|
||||
return { text: head + text.slice(caret), caret: head.length };
|
||||
}
|
||||
|
||||
/*** @提及输入:textarea 内键入 @ 弹候选,↑↓ 环形移动、Enter/Tab 选中、Esc 只收本层;弹层贴输入框下沿,选中按光标替换 @片段 ***/
|
||||
export function mentions(props: MentionsProps): HTMLElement {
|
||||
const uid = `ua-mentions-${++seq}`;
|
||||
const items = props.items;
|
||||
const value = props.value ?? signal('');
|
||||
const open = signal(false); // 弹层开合
|
||||
const query = signal(''); // 当前 @ 后的过滤词
|
||||
const active = signal(0); // 高亮项下标
|
||||
const input = h('textarea', {
|
||||
class: 'ua-mentions-input',
|
||||
rows: String(props.rows ?? 3),
|
||||
placeholder: props.placeholder ?? '',
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-controls': uid,
|
||||
'aria-expanded': 'false',
|
||||
}) as HTMLTextAreaElement;
|
||||
input.value = value();
|
||||
const list = h('div', { class: 'ua-mentions-pop', role: 'listbox', 'aria-label': '提及建议', id: uid });
|
||||
const wrap = h('div', { class: 'ua-mentions' }, input, list);
|
||||
|
||||
// 候选:空过滤词给全量,否则按名字 / 关键词做包含匹配
|
||||
const filtered = (): MentionItem[] => (query()
|
||||
? items.filter((it) => it.name.includes(query()) || (it.keywords ?? []).some((k) => k.includes(query())))
|
||||
: items);
|
||||
|
||||
/*** 重绘弹层:同步显隐、高亮项与 aria-activedescendant(无候选时不开层) ***/
|
||||
const paint = (): void => {
|
||||
const shown = filtered();
|
||||
const visible = open() && shown.length > 0;
|
||||
wrap.classList.toggle('is-open', visible);
|
||||
input.setAttribute('aria-expanded', String(visible));
|
||||
list.replaceChildren(...shown.map((item, i) => h('div', {
|
||||
class: `ua-mentions-item${i === active() ? ' is-active' : ''}`,
|
||||
role: 'option',
|
||||
id: `${uid}-${i}`,
|
||||
'aria-selected': i === active() ? 'true' : 'false',
|
||||
onmousedown: (e: Event) => { e.preventDefault(); pick(item); }, // 阻止默认让 textarea 不失焦
|
||||
}, h('span', { class: 'ua-mentions-name' }, item.label),
|
||||
item.description ? h('span', { class: 'ua-mentions-desc' }, item.description) : null)));
|
||||
if (visible) input.setAttribute('aria-activedescendant', `${uid}-${active()}`);
|
||||
else input.removeAttribute('aria-activedescendant');
|
||||
};
|
||||
|
||||
/*** 选中候选:替换光标前的 @片段、写回信号、光标落到插入文本之后 ***/
|
||||
const pick = (item: MentionItem): void => {
|
||||
const caret = input.selectionStart ?? input.value.length;
|
||||
const next = applyPick(input.value, caret, item.name);
|
||||
input.value = next.text;
|
||||
value(next.text);
|
||||
input.setSelectionRange(next.caret, next.caret);
|
||||
input.focus();
|
||||
open(false);
|
||||
query('');
|
||||
paint();
|
||||
props.onSelect?.(item);
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
value(input.value);
|
||||
props.onInput?.(input.value);
|
||||
const token = tokenBefore(input.value, input.selectionStart ?? input.value.length);
|
||||
query(token ?? '');
|
||||
active(0); // 每次输入重新从第一项开始高亮
|
||||
open(token !== null);
|
||||
paint();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (!open()) return; // 未开层时所有按键交给浏览器
|
||||
const shown = filtered();
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 只收本层,不连带外层弹层
|
||||
open(false);
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
active((active() + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length); // 环形移动
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'Enter' || e.key === 'Tab') && shown.length) {
|
||||
e.preventDefault();
|
||||
pick(shown[Math.min(active(), shown.length - 1)]);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => { open(false); paint(); }); // 失焦收起
|
||||
effect(() => {
|
||||
const outer = value();
|
||||
if (outer !== input.value) input.value = outer; // 外部改信号时同步输入框
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { MenuItem, MenuProps } from './types.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
|
||||
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 + Home/End 跳转 ***/
|
||||
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 + Home/End 跳转 + 键入快跳 ***/
|
||||
export function menu(props: MenuProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层开合
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
|
||||
const focusables: HTMLButtonElement[] = []; // 可聚焦项(跳过分隔线与禁用项),供键盘导航
|
||||
const labels: string[] = []; // 与 focusables 平行的条目文案,供键入快跳
|
||||
|
||||
const pick = (item: MenuItem): void => {
|
||||
open(false); // 选中即收起
|
||||
@@ -27,7 +32,7 @@ export function menu(props: MenuProps): HTMLElement {
|
||||
h('span', { class: 'ua-menu-label' }, item.label),
|
||||
item.shortcut ? h('kbd', { class: 'ua-menu-key' }, item.shortcut) : null, // 快捷键只做提示,不拦截按键
|
||||
) as HTMLButtonElement;
|
||||
if (!item.disabled) focusables.push(btn); // 禁用项不参与导航
|
||||
if (!item.disabled) { focusables.push(btn); labels.push(item.label); } // 禁用项不参与导航
|
||||
return btn;
|
||||
});
|
||||
|
||||
@@ -47,7 +52,7 @@ export function menu(props: MenuProps): HTMLElement {
|
||||
const pop = h(
|
||||
'div',
|
||||
{
|
||||
class: () => `ua-menu-pop${open() ? ' ua-open' : ''}${props.align === 'end' ? ' is-end' : ''}`,
|
||||
class: () => `ua-menu-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}${props.align === 'end' ? ' is-end' : ''}`,
|
||||
role: 'menu',
|
||||
},
|
||||
...rows,
|
||||
@@ -55,15 +60,26 @@ export function menu(props: MenuProps): HTMLElement {
|
||||
|
||||
const root = h('div', { class: () => `ua-menu${open() ? ' ua-open' : ''}` }, trigger, pop);
|
||||
|
||||
// —— 键入快跳缓冲:与 select 的行为保持一致 ——
|
||||
let typed = ''; // 最近键入的字母串
|
||||
let typedAt = 0; // 最近键入时间戳,超 1 秒重置
|
||||
const jumpByTyped = (ch: string): void => {
|
||||
const now = Date.now();
|
||||
typed = now - typedAt > 1000 ? ch : typed + ch; // 超时重新起词,否则追加
|
||||
typedAt = now;
|
||||
const at = focusables.indexOf(document.activeElement as HTMLButtonElement); // 当前焦点序号
|
||||
const order = focusables.map((_, i) => (at + 1 + i) % focusables.length); // 从下一项开始环形排列
|
||||
const hit = order.find((i) => labels[i].toLowerCase().startsWith(typed.toLowerCase())); // 前缀匹配
|
||||
if (hit != null) focusables[hit]?.focus(); // 命中即聚焦;无命中留在原地
|
||||
};
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
open(false);
|
||||
trigger.focus(); // 收起后焦点回到触发器,键盘流不断
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' && open()) { e.preventDefault(); focusables[0]?.focus(); return; } // Home 跳首项
|
||||
if (e.key === 'End' && open()) { e.preventDefault(); focusables[focusables.length - 1]?.focus(); return; } // End 跳末项
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; // 卫语句:只接管上下键
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
|
||||
if (open() && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳
|
||||
return; // 卫语句:其余按键不接管
|
||||
}
|
||||
e.preventDefault();
|
||||
if (!open()) {
|
||||
open(true);
|
||||
@@ -75,11 +91,25 @@ export function menu(props: MenuProps): HTMLElement {
|
||||
focusables[(at + step + focusables.length) % focusables.length]?.focus(); // 环形移动
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部收起
|
||||
});
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
|
||||
effect(() => open()); // 建立依赖,保证开合信号生效
|
||||
effect(() => {
|
||||
if (open()) {
|
||||
releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
|
||||
} else {
|
||||
releaseEsc?.();
|
||||
releaseEsc = null;
|
||||
}
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
}); // 每次打开重算方位:空间不足自动翻转
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,71 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ModalProps } from './types.js';
|
||||
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
|
||||
import { button } from './button.js';
|
||||
|
||||
let seq = 0; // 实例序号:给标题生成唯一 id,供 aria-labelledby 关联
|
||||
|
||||
/*** 弹窗:open 信号驱动显隐,点遮罩、× 或 Esc 触发 onClose;Tab 焦点陷阱 + 关闭归还焦点 ***/
|
||||
export function modal(props: ModalProps, ...children: unknown[]): HTMLElement {
|
||||
const titleId = `ua-modal-title-${++seq}`; // 标题 id:对话框的可访问名
|
||||
const close = () => {
|
||||
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
|
||||
props.open(false); // 写回信号触发隐藏
|
||||
props.onClose?.();
|
||||
};
|
||||
/*** 内置页脚:取消直接关;确认若返回 Promise,则按钮转「处理中…」并禁用,成功才收窗、失败留在窗口重试 ***/
|
||||
const builtinFoot = (): HTMLElement => {
|
||||
const ok = button({ label: props.confirmText ?? '确定' }) as HTMLButtonElement;
|
||||
const cancel = button({ label: props.cancelText ?? '取消', variant: 'ghost', onClick: close }) as HTMLButtonElement;
|
||||
const idle = ok.textContent; // 记下常态文案,异步结束后还原
|
||||
ok.addEventListener('click', () => {
|
||||
const out = props.onConfirm?.();
|
||||
if (!(out instanceof Promise)) { close(); return; } // 同步确认:直接收窗
|
||||
ok.disabled = true;
|
||||
ok.textContent = '处理中…';
|
||||
out.then(() => { if (props.open()) close(); }) // 成功才关;onConfirm 里已手动关掉就不重复触发
|
||||
.catch(() => { /* 失败什么都不做:保持窗口打开让用户重试,提示交给调用方的 toast */ })
|
||||
.finally(() => { ok.disabled = false; ok.textContent = idle; });
|
||||
});
|
||||
return h('div', { class: 'ua-modal-actions' }, cancel, ok);
|
||||
};
|
||||
const foot = props.footer ? h('div', { class: 'ua-modal-foot' }, props.footer) : props.confirmText || props.onConfirm ? h('div', { class: 'ua-modal-foot' }, builtinFoot()) : null; // footer 优先,其次内置按钮组
|
||||
const overlay = h('div', {
|
||||
class: 'ua-modal-overlay',
|
||||
onClick: (e: Event) => {
|
||||
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
|
||||
if (e.target !== overlay) return; // 卫语句:只处理点在遮罩本身的
|
||||
if (props.closeOnOverlay === false) return; // 显式禁止时防误关,× 与 Esc 仍可用
|
||||
close();
|
||||
},
|
||||
});
|
||||
const box = h(
|
||||
'div',
|
||||
{ class: `ua-modal ua-modal-${props.size ?? 'md'}`, role: 'dialog', 'aria-modal': 'true' }, // 对话框语义,供读屏识别
|
||||
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title' }, props.title), h('button', { type: 'button', class: 'ua-modal-close', 'aria-label': '关闭弹窗', onClick: close }, '×')),
|
||||
{ class: `ua-modal ua-modal-${props.size ?? 'md'}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId }, // 对话框语义 + 标题关联
|
||||
h('div', { class: 'ua-modal-head' }, h('h3', { class: 'ua-modal-title', id: titleId }, props.title), h('button', { type: 'button', class: 'ua-modal-close', 'aria-label': '关闭弹窗', onClick: close }, '×')),
|
||||
h('div', { class: 'ua-modal-body' }, ...children),
|
||||
props.footer ? h('div', { class: 'ua-modal-foot' }, props.footer) : null, // 底部操作区:右对齐,仅传了才渲染
|
||||
foot, // 底部操作区:自定义 footer 或内置按钮组,都没有则不渲染
|
||||
);
|
||||
overlay.append(box);
|
||||
let lastFocus: HTMLElement | null = null; // 打开前焦点宿主,关闭时归还
|
||||
const focusables = (): HTMLElement[] =>
|
||||
Array.from(box.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((el) => !el.hasAttribute('disabled')); // 弹窗内可聚焦元素(排除禁用)
|
||||
let release: (() => void) | null = null; // Esc 栈注销函数,关闭时释放
|
||||
let releaseLock: (() => void) | null = null; // 背景滚动锁解锁函数,全关才还原
|
||||
effect(() => {
|
||||
const open = props.open();
|
||||
overlay.classList.toggle('ua-open', open); // 显隐跟随信号
|
||||
if (open) {
|
||||
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
|
||||
release ??= pushOverlay(close); // 入 Esc 栈:多弹层时只关最上层
|
||||
releaseLock ??= lockScroll(); // 模态期锁背景滚动
|
||||
(box.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
|
||||
} else if (lastFocus) {
|
||||
lastFocus.focus(); // 关闭时归还焦点
|
||||
} else {
|
||||
release?.(); // 出 Esc 栈
|
||||
release = null;
|
||||
releaseLock?.(); // 解锁背景滚动
|
||||
releaseLock = null;
|
||||
lastFocus?.focus(); // 关闭时归还焦点
|
||||
lastFocus = null;
|
||||
}
|
||||
});
|
||||
overlay.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !props.open()) return; // 仅处理打开中的 Tab
|
||||
const els = focusables();
|
||||
if (els.length === 0) { e.preventDefault(); return; } // 无可聚焦元素:拦截 Tab 防焦点逃逸
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
|
||||
e.preventDefault(); last.focus(); // Shift+Tab 到首位时回绕到末尾
|
||||
} else if (!e.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
|
||||
e.preventDefault(); first.focus(); // Tab 到末位时回绕到开头
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.open()) close(); // Esc 关闭当前打开的弹窗
|
||||
});
|
||||
trapFocus(box, () => props.open()); // Tab 焦点陷阱:与 drawer 共用同一份实现
|
||||
return overlay;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import type { MultiSelectProps } from './types.js';
|
||||
|
||||
/*** 多选下拉:标签式回显 + 可搜索弹层,value 数组信号双向绑定,到 max 未选项自动禁用 ***/
|
||||
export function multiSelect(props: MultiSelectProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value: Signal<string[]> = props.value ?? signal<string[]>([]); // 选中值
|
||||
const open = signal(false); // 弹层开合
|
||||
const query = signal(''); // 搜索词
|
||||
const active = signal(-1); // 键盘高亮下标
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际方位
|
||||
const searchable = props.searchable !== false; // 缺省可搜索
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数
|
||||
|
||||
/*** 按搜索词过滤候选:禁用项仍展示(便于解释为何点不了) ***/
|
||||
const visible = () => {
|
||||
const q = query().trim().toLowerCase();
|
||||
return q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : props.options;
|
||||
};
|
||||
|
||||
/*** 提交变更:写信号 + 回调(上限由弹层禁用项兜住,这里不再拒绝) ***/
|
||||
const update = (next: string[]): void => {
|
||||
value(next);
|
||||
props.onChange?.([...next]);
|
||||
};
|
||||
|
||||
/*** 单项切换:已选则移除,未选且未到上限则加入 ***/
|
||||
const toggle = (v: string): void => {
|
||||
const cur = new Set(value());
|
||||
if (cur.has(v)) cur.delete(v);
|
||||
else if (props.max == null || cur.size < props.max) cur.add(v);
|
||||
update([...cur]);
|
||||
};
|
||||
|
||||
/*** 全选 / 反选:只作用于当前过滤后的候选,上限时按顺序补到 max 为止 ***/
|
||||
const toggleAll = (): void => {
|
||||
const opts = visible().filter((o) => !o.disabled);
|
||||
const cur = new Set(value());
|
||||
const allOn = opts.length > 0 && opts.every((o) => cur.has(o.value));
|
||||
if (allOn) opts.forEach((o) => cur.delete(o.value)); // 全选态再点即清空这批
|
||||
else for (const o of opts) {
|
||||
if (props.max != null && cur.size >= props.max) break; // 到上限就停,不静默超选
|
||||
cur.add(o.value);
|
||||
}
|
||||
update([...cur]);
|
||||
};
|
||||
|
||||
const tagWrap = h('span', { class: 'ua-multiselect-tags' }) as HTMLElement; // 标签区:每次重绘只换这里
|
||||
const box = h('div', { class: 'ua-multiselect-box' }) as HTMLElement; // 标签 + 输入框容器
|
||||
const input = h('input', {
|
||||
type: 'text',
|
||||
class: 'ua-multiselect-input',
|
||||
placeholder: '',
|
||||
'aria-label': props.placeholder ?? '多选下拉',
|
||||
role: 'combobox',
|
||||
'aria-expanded': () => String(open()),
|
||||
disabled: props.disabled,
|
||||
oninput: (e: Event) => {
|
||||
query((e.target as HTMLInputElement).value);
|
||||
active(-1);
|
||||
open(true); // 输入即展开,便于边打边选
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
box.append(tagWrap, input); // 标签区在前、输入框在后,横向排布
|
||||
const list = h('ul', { class: 'ua-multiselect-list', role: 'listbox', 'aria-label': '选项列表', 'aria-multiselectable': 'true' }) as HTMLElement;
|
||||
const pop = h('div', { class: () => `ua-multiselect-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}` }, list) as HTMLElement;
|
||||
const clearBtn = h('button', { type: 'button', class: 'ua-select-clear', 'aria-label': '清空所选', hidden: true, onClick: (e: Event) => { e.stopPropagation(); update([]); input.focus(); } }, '×') as HTMLElement; // 复用 select 清空钮样式;阻止冒泡免得清完又展开
|
||||
const root = h('div', { class: () => `ua-multiselect${open() ? ' ua-open' : ''}` }, box, props.clearable ? clearBtn : null, pop) as HTMLElement;
|
||||
effect(() => { clearBtn.hidden = !value().length; }); // 有值才显示(clearable 未开时按钮根本不挂载)
|
||||
|
||||
/*** 重绘标签区与候选:标签带删除钮,候选带勾选态与禁用态 ***/
|
||||
const render = (): void => {
|
||||
const cur = value();
|
||||
const full = props.max != null && cur.length >= props.max; // 是否已达上限
|
||||
const cut = props.maxTagCount != null && cur.length > props.maxTagCount ? cur.slice(0, props.maxTagCount) : cur; // 折叠后仍显示的标签
|
||||
const labelOf = (v: string): string => props.options.find((o) => o.value === v)?.label ?? v;
|
||||
const tags = cut.map((v) => {
|
||||
const label = labelOf(v);
|
||||
const del = h('button', { type: 'button', class: 'ua-multiselect-x', 'aria-label': `移除 ${label}`, onClick: () => toggle(v) }, '×');
|
||||
return h('span', { class: 'ua-multiselect-tag' }, h('span', {}, label), del);
|
||||
});
|
||||
if (cut.length < cur.length) { // 溢出项折成一枚 +N,悬停给全量、点击展开弹层
|
||||
const rest = cur.slice(cut.length).map(labelOf).join('、');
|
||||
tags.push(h('span', { class: 'ua-multiselect-tag is-more', title: rest, 'aria-label': `另有 ${cur.length - cut.length} 项:${rest}`, onClick: () => open(true) }, `+${cur.length - cut.length}`));
|
||||
}
|
||||
tagWrap.replaceChildren(...tags); // 只换标签,输入框保持挂载(重建会丢焦点与输入内容)
|
||||
input.placeholder = cur.length ? '' : props.placeholder ?? '请选择'; // 有标签时不挤占占位文案
|
||||
const rows: HTMLElement[] = []; // 候选行:分组小标题与选项混排(小标题不可聚焦,不占 active 下标)
|
||||
if (props.selectAll) { // 全选行:占 active 下标 -1,键盘 ↑↓ 能走到,不用鼠标也能全选
|
||||
const opts = visible();
|
||||
const picked = opts.filter((o) => cur.includes(o.value)).length;
|
||||
const allOn = opts.length > 0 && picked === opts.length;
|
||||
rows.push(h(
|
||||
'li',
|
||||
{
|
||||
class: () => `ua-multiselect-opt ua-multiselect-all${open() && active() === -1 ? ' is-active' : ''}${opts.length ? '' : ' is-disabled'}`,
|
||||
role: 'option',
|
||||
'aria-selected': String(allOn),
|
||||
onmousedown: (e: Event) => { e.preventDefault(); if (opts.length) toggleAll(); }, // 阻止默认:保住输入焦点
|
||||
},
|
||||
h('span', { class: 'ua-multiselect-check', 'aria-hidden': 'true' }, allOn ? '✓' : picked ? '–' : ''), // – 为半选:部分可选
|
||||
h('span', {}, opts.length ? `全选(${picked}/${opts.length})` : '全选'),
|
||||
));
|
||||
}
|
||||
let prevGroup = ''; // 上一个候选的分组名,只在组首插一次标题
|
||||
visible().forEach((o, i) => {
|
||||
if (o.group && o.group !== prevGroup) rows.push(h('li', { class: 'ua-multiselect-group', role: 'presentation' }, o.group));
|
||||
prevGroup = o.group ?? ''; // 无分组的候选打断连续段,后续同名组会重新起标题
|
||||
const on = cur.includes(o.value);
|
||||
const dead = o.disabled === true || (full && !on); // 到上限:未选项不可再点
|
||||
rows.push(h(
|
||||
'li',
|
||||
{
|
||||
class: () => `ua-multiselect-opt${on ? ' is-selected' : ''}${active() === i ? ' is-active' : ''}${dead ? ' is-disabled' : ''}`,
|
||||
role: 'option',
|
||||
'aria-selected': String(on),
|
||||
onmousedown: (e: Event) => { e.preventDefault(); if (!dead) toggle(o.value); }, // 阻止默认:保住输入焦点,弹层不收起
|
||||
},
|
||||
h('span', { class: 'ua-multiselect-check', 'aria-hidden': 'true' }, on ? '✓' : ''),
|
||||
h('span', {}, o.label),
|
||||
));
|
||||
});
|
||||
if (!rows.length) rows.push(h('li', { class: 'ua-multiselect-empty' }, '无匹配项'));
|
||||
list.replaceChildren(...rows);
|
||||
};
|
||||
|
||||
box.addEventListener('click', () => { if (!(props.disabled === true)) { open(true); input.focus(); } }); // 点空白处展开并聚焦输入
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const opts = visible();
|
||||
const base = props.selectAll ? -1 : 0; // 全选行占 -1 号位:环形导航的起点随之左移一格
|
||||
const slots = opts.length + (props.selectAll ? 1 : 0); // 可停留的位置总数(含全选行)
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!open()) { open(true); active(base); return; }
|
||||
if (!slots) return; // 无候选也没全选行:不动
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
active(base + (((active() - base) + dir + slots) % slots)); // 环形移动,越界回绕
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && open() && slots && active() >= base) {
|
||||
e.preventDefault();
|
||||
if (active() === -1) toggleAll(); // 全选行
|
||||
else toggle(opts[active()].value);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && !input.value && value().length) { update(value().slice(0, -1)); return; } // 空输入时退格删末个标签
|
||||
if (e.key === 'Escape') { open(false); return; }
|
||||
});
|
||||
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(render);
|
||||
effect(() => {
|
||||
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
|
||||
else { releaseEsc?.(); releaseEsc = null; }
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(box, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(box, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { h, signal } from '@ui-arch/frame';
|
||||
import type { NoticeBarProps } from './types.js';
|
||||
|
||||
// 语义色配符号:只靠颜色区分在灰度/色弱下会失效,符号是第二条线索
|
||||
const ICONS: Record<string, string> = { info: 'ℹ', success: '✓', warning: '!', danger: '×' };
|
||||
const EXIT_MS = 200; // 退场时长:与 alert 的关闭过渡同档,两条播报件消失手感一致
|
||||
|
||||
/*** 通知条:单行横条,语义色 + 可选跑马灯与关闭;关闭先淡出再隐藏并回调 ***/
|
||||
export function noticeBar(props: NoticeBarProps): HTMLElement {
|
||||
const variant = props.variant ?? 'info';
|
||||
const open = signal(true); // 关闭状态:退场结束后置 false(隐藏而非摘节点,调用方仍持有句柄)
|
||||
const close = (): void => {
|
||||
el.classList.add('is-out'); // 挂退场类:淡出 + 轻微上移,先给观感再隐藏
|
||||
props.onClose?.();
|
||||
window.setTimeout(() => open(false), EXIT_MS); // 与 CSS 过渡时长同源,避免节点在半路消失
|
||||
};
|
||||
const el = h(
|
||||
'div',
|
||||
{
|
||||
class: `ua-notice ua-notice-${variant}${props.scroll ? ' is-roll' : ''}`,
|
||||
role: 'status',
|
||||
title: props.text, // 滚动/裁切时仍能悬停读到完整原文
|
||||
hidden: () => !open(),
|
||||
},
|
||||
h('span', { class: 'ua-notice-icon', 'aria-hidden': 'true' }, props.icon ?? ICONS[variant]),
|
||||
h('div', { class: 'ua-notice-body' }, h('span', { class: 'ua-notice-text' }, props.text)),
|
||||
props.closable === false
|
||||
? null
|
||||
: h('button', { type: 'button', class: 'ua-notice-close', 'aria-label': '关闭通知', onClick: close }, '×'),
|
||||
);
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { NotificationHandle, NotificationProps } from './types.js';
|
||||
|
||||
const hosts = new Map<string, HTMLElement>(); // 每个角落一个堆叠容器,首次用到该角落时懒创建
|
||||
const byKey = new Map<string, NotificationHandle>(); // 同 key 只留一张卡,二次调用走原地更新
|
||||
const MAX_STACK = 3; // 同一角落最多条数,超出挤掉最早一条
|
||||
|
||||
/*** 确保指定角落的堆叠容器存在:fixed 挂 body 末尾,与 toast 的右上角分区不打架 ***/
|
||||
function ensureHost(place: string): HTMLElement {
|
||||
const hit = hosts.get(place);
|
||||
if (hit) return hit;
|
||||
const el = h('div', { class: `ua-notice-host is-${place}` });
|
||||
document.body.appendChild(el);
|
||||
hosts.set(place, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/*** 退场并移除:摘 ua-open 触发过渡,transitionend 与 320ms 超时二选一兜底 ***/
|
||||
function dismiss(item: HTMLElement): void {
|
||||
item.classList.remove('ua-open');
|
||||
item.addEventListener('transitionend', () => item.remove(), { once: true });
|
||||
window.setTimeout(() => item.isConnected && item.remove(), 320); // reduced-motion 下不触发 transition
|
||||
}
|
||||
|
||||
/*** 通知卡片:标题 + 描述 + 可选操作区,返回 { el, close, update } 句柄;duration 为 0 表示常驻,非 0 时悬停 / 聚焦自动暂停计时;带 key 时二次调用原地更新同一张卡 ***/
|
||||
export function notification(props: NotificationProps): NotificationHandle {
|
||||
const dup = props.key ? byKey.get(props.key) : undefined;
|
||||
if (dup) { dup.update(props); return dup; } // 同 key:只更新内容,不新增一条
|
||||
|
||||
// —— 状态集中声明 ——
|
||||
const duration = props.duration ?? 0; // 缺省常驻:通知通常需要用户读完
|
||||
const closable = props.closable ?? true; // 缺省带关闭按钮
|
||||
let timer = 0; // 自动关闭计时器句柄
|
||||
let remain = duration; // 剩余毫秒:暂停时扣减,续计时接着走
|
||||
let startedAt = 0; // 本轮计时起点,用于折算暂停前已流逝的时间
|
||||
let paused = false; // 是否因悬停 / 聚焦而暂停
|
||||
const closeBtn = h('button', { type: 'button', class: 'ua-notice-close', 'aria-label': '关闭通知' }, '×') as HTMLButtonElement; // 关闭按钮,回调在下方绑定
|
||||
const titleEl = h('span', { class: 'ua-notice-title' }, props.title) as HTMLElement;
|
||||
const actionEl = props.action ? h('div', { class: 'ua-notice-action' }, props.action) : null;
|
||||
let descEl = props.description ? h('div', { class: 'ua-notice-desc' }, props.description) : null;
|
||||
|
||||
/*** 换语义色:整串重设 class,保住已挂上的入场态 ***/
|
||||
const paint = (variant: string): void => {
|
||||
el.className = `ua-notice ua-notice-${variant}${el.classList.contains('ua-open') ? ' ua-open' : ''}`;
|
||||
};
|
||||
const el = h(
|
||||
'div',
|
||||
{ class: `ua-notice ua-notice-${props.variant ?? 'info'}`, role: 'alert' },
|
||||
h('div', { class: 'ua-notice-head' }, h('span', { class: 'ua-notice-dot', 'aria-hidden': 'true' }), titleEl, closable ? closeBtn : null),
|
||||
descEl,
|
||||
actionEl,
|
||||
) as HTMLElement;
|
||||
|
||||
/*** 关闭:清计时器后走退场动画,顺手释放 key 登记 ***/
|
||||
const close = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (props.key && byKey.get(props.key) === handle) byKey.delete(props.key);
|
||||
dismiss(el);
|
||||
};
|
||||
closeBtn.addEventListener('click', () => close());
|
||||
|
||||
/*** 原地更新:改标题 / 描述 / 语义色;描述按需插入到操作区之前 ***/
|
||||
const update = (next: Partial<NotificationProps>): void => {
|
||||
if (next.title != null) titleEl.textContent = next.title;
|
||||
if (next.variant) paint(next.variant);
|
||||
if (next.description != null) {
|
||||
if (!descEl) { // 首次补描述:插在操作区之前,没有操作区就追加
|
||||
descEl = h('div', { class: 'ua-notice-desc' });
|
||||
if (actionEl) el.insertBefore(descEl, actionEl); else el.appendChild(descEl);
|
||||
}
|
||||
descEl.textContent = next.description;
|
||||
}
|
||||
};
|
||||
|
||||
/*** 起表:按剩余毫秒设定时器 ***/
|
||||
const arm = () => { if (remain <= 0) return; startedAt = Date.now(); timer = window.setTimeout(() => dismiss(el), remain); };
|
||||
/*** 暂停计时:扣掉已流逝部分,剩余时间留给续计 ***/
|
||||
const pause = () => { if (paused || remain <= 0) return; paused = true; window.clearTimeout(timer); remain -= Date.now() - startedAt; };
|
||||
/*** 续计:离开后按剩余毫秒重新起表 ***/
|
||||
const resume = () => { if (!paused) return; paused = false; arm(); };
|
||||
el.addEventListener('pointerenter', pause); // 悬停阅读时不倒计时
|
||||
el.addEventListener('pointerleave', resume);
|
||||
el.addEventListener('focusin', pause); // 键盘聚焦到关闭/操作按钮同样暂停
|
||||
el.addEventListener('focusout', resume);
|
||||
|
||||
const stack = ensureHost(props.placement ?? 'bottom-right'); // 缺省右下,与右上角 toast 错开
|
||||
if (stack.childElementCount >= MAX_STACK) stack.firstElementChild?.remove(); // 超出上限挤掉最早一条
|
||||
stack.appendChild(el);
|
||||
const handle: NotificationHandle = { el, close, update };
|
||||
if (props.key) byKey.set(props.key, handle);
|
||||
requestAnimationFrame(() => el.classList.add('ua-open')); // 下一帧入场,保证过渡生效
|
||||
if (duration > 0) arm(); // 常驻(0)不设定时器
|
||||
return handle;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { effect } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
|
||||
/*** 弹层键盘栈:维护打开顺序,Esc 只关最上层,避免多个弹层(含嵌套)被一次全关 ***/
|
||||
|
||||
// —— 状态集中声明 ——
|
||||
const stack: { close: () => void }[] = []; // 打开中的弹层,后进先出
|
||||
let bound = false; // 全局 keydown 监听只挂一次,栈空时摘除
|
||||
|
||||
/*** 全局 Esc 分发:只把事件交给栈顶弹层,其余保持不动 ***/
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return; // 卫语句:只关心 Esc
|
||||
const top = stack[stack.length - 1];
|
||||
if (!top) return; // 卫语句:栈空无事可做
|
||||
e.preventDefault();
|
||||
top.close();
|
||||
};
|
||||
|
||||
/*** 点外部关闭:仅在展开期间挂全局 click,收起即摘(避免逐实例常驻监听,组件卸载后不再残留) ***/
|
||||
export function bindOutside(root: HTMLElement, isOpen: Signal<boolean>, close: () => void): void {
|
||||
// —— 状态集中声明 ——
|
||||
let release: (() => void) | null = null; // 当前监听的注销函数,未挂载时为 null
|
||||
const handler = (e: Event): void => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
close();
|
||||
};
|
||||
effect(() => {
|
||||
if (isOpen() && !release) {
|
||||
document.addEventListener('click', handler);
|
||||
release = () => document.removeEventListener('click', handler);
|
||||
} else if (!isOpen() && release) {
|
||||
release(); // 收起即摘
|
||||
release = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*** 注册一个打开的弹层,返回注销函数(弹层关闭时务必调用,否则栈不清理) ***/
|
||||
export function pushOverlay(close: () => void): () => void {
|
||||
if (!bound) {
|
||||
document.addEventListener('keydown', onKeydown); // 首个弹层才挂全局监听
|
||||
bound = true;
|
||||
}
|
||||
const entry = { close };
|
||||
stack.push(entry);
|
||||
return () => {
|
||||
const i = stack.indexOf(entry);
|
||||
if (i >= 0) stack.splice(i, 1); // 出栈:允许非栈顶关闭
|
||||
if (!stack.length && bound) {
|
||||
document.removeEventListener('keydown', onKeydown); // 栈空摘监听,避免泄漏
|
||||
bound = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// —— 背景滚动锁:模态弹层(modal/drawer/image 预览)引用计数,全关才解锁 ——
|
||||
let locks = 0; // 活跃模态层数
|
||||
|
||||
/*** 模态层打开时调用,返回解锁函数;嵌套叠加时只由最外层还原 body 内联样式 ***/
|
||||
export function lockScroll(): () => void {
|
||||
if (locks === 0) document.body.style.overflow = 'hidden'; // 第一层才锁,避免相互覆盖
|
||||
locks++;
|
||||
return () => {
|
||||
locks--;
|
||||
if (locks === 0) document.body.style.overflow = ''; // 全关解锁
|
||||
};
|
||||
}
|
||||
|
||||
/*** 焦点陷阱:Tab / Shift+Tab 在容器内首尾环形循环,容器内无可用焦点时整体拦截;焦点已在容器外则按方向拉回首尾 ***/
|
||||
export function trapFocus(box: HTMLElement, isOpen: () => boolean): void {
|
||||
box.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !isOpen()) return; // 卫语句:只处理打开中的 Tab
|
||||
const els = Array.from(box.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
||||
.filter((el) => !el.hasAttribute('disabled')); // 容器内可聚焦元素(排除禁用)
|
||||
if (!els.length) { e.preventDefault(); return; } // 卫语句:无可用焦点就整体拦截,防焦点逃逸
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
const active = document.activeElement;
|
||||
const outside = !box.contains(active); // 焦点落在容器外:按方向重新拉回,而不是让它继续往外跑
|
||||
if (e.shiftKey && (active === first || outside)) {
|
||||
e.preventDefault();
|
||||
last.focus(); // Shift+Tab 到首位回绕到末尾
|
||||
} else if (!e.shiftKey && (active === last || outside)) {
|
||||
e.preventDefault();
|
||||
first.focus(); // Tab 到末位回绕到开头
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { h, renderList } from '@ui-arch/frame';
|
||||
import { effect, h, renderList, signal } from '@ui-arch/frame';
|
||||
import type { PaginationProps } from './types.js';
|
||||
import { select } from './select.js';
|
||||
|
||||
/*** 页码序列:≤7 页全展开;否则保留首尾与当前页 ±1 窗口,缺口折叠为省略号(null 占位) ***/
|
||||
function pageSequence(page: number, totalPages: number): (number | null)[] {
|
||||
@@ -15,27 +16,78 @@ function pageSequence(page: number, totalPages: number): (number | null)[] {
|
||||
return seq;
|
||||
}
|
||||
|
||||
/*** 独立分页器:受控组件,page 为双向绑定信号;页码过多折叠省略号,首末页禁用翻页,原生 button 保证键盘可达 ***/
|
||||
/*** 独立分页器:受控组件,page 为双向绑定信号;页码过多折叠省略号,首末页禁用翻页,jumpable 可跳页,pageSizes 可切每页条数,simple 走极简、hideOnSinglePage 单页隐藏 ***/
|
||||
|
||||
export function pagination(props: PaginationProps): HTMLElement {
|
||||
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize)); // 总页数
|
||||
// —— 状态集中声明 ——
|
||||
const size = signal(props.pageSize); // 每页条数:可从 switcher 改动,故用信号持有
|
||||
const readTotal = (): number => (typeof props.total === 'function' ? props.total() : props.total); // 数字 / 信号同一口径
|
||||
const totalPages = () => Math.max(1, Math.ceil(readTotal() / size())); // 总页数随时可重算
|
||||
const pages = h('div', { class: 'ua-pagination-pages' }); // 页码区,随页码重绘
|
||||
const tail = h('div', { class: 'ua-pagination-tail' }); // 附加区(条数切换 + 跳页),只创建一次
|
||||
const go = (p: number) => {
|
||||
const next = Math.min(totalPages, Math.max(1, p)); // 钳制到有效页码范围
|
||||
const next = Math.min(totalPages(), Math.max(1, p)); // 钳制到有效页码范围
|
||||
props.page(next);
|
||||
props.onChange?.(next);
|
||||
};
|
||||
// 总量变小(筛选 / 删除)后把越界页码写回父级:否则父级 state 停在旧页,显示与数据对不上
|
||||
effect(() => {
|
||||
const max = totalPages();
|
||||
const cur = props.page();
|
||||
if (cur <= max && cur >= 1) return; // 卫语句:页码合法就不动手,避免无意义回调
|
||||
const fixed = Math.min(max, Math.max(1, cur));
|
||||
props.page(fixed);
|
||||
props.onChange?.(fixed);
|
||||
});
|
||||
|
||||
const nav = h('nav', { class: 'ua-pagination', 'aria-label': '分页' });
|
||||
renderList(nav, () => {
|
||||
const page = Math.min(totalPages, Math.max(1, props.page()));
|
||||
// —— 每页条数切换器:传了候选才渲染,切换即回第 1 页 ——
|
||||
if ((props.pageSizes ?? []).length > 1) {
|
||||
const sizeValue = signal(String(size()));
|
||||
tail.append(
|
||||
select({
|
||||
options: props.pageSizes!.map((n) => ({ value: String(n), label: `${n} 条/页` })),
|
||||
value: sizeValue,
|
||||
onChange: (v) => {
|
||||
size(Number(v));
|
||||
props.onPageSizeChange?.(Number(v));
|
||||
go(1); // 条数变了旧页码无意义,统一回首页
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// —— 跳页输入:jumpable 且多页时渲染,回车跳转 ——
|
||||
if (props.jumpable) {
|
||||
const jump = h('input', { class: 'ua-page-jump', inputmode: 'numeric', 'aria-label': '跳转到指定页' }) as HTMLInputElement;
|
||||
jump.addEventListener('change', () => {
|
||||
const n = parseInt(jump.value, 10);
|
||||
jump.value = ''; // 跳转后清空输入,避免残留旧页码
|
||||
if (!Number.isNaN(n)) go(n); // 非数字忽略,由 go 钳制范围
|
||||
});
|
||||
tail.append(h('span', { class: 'ua-page-jump-wrap' }, jump, h('span', { class: 'ua-page-total' }, () => `/ ${totalPages()} 页`)));
|
||||
}
|
||||
|
||||
const nav = h('nav', { class: 'ua-pagination', 'aria-label': '分页' }, pages, tail);
|
||||
renderList(pages, () => {
|
||||
const totalNow = totalPages();
|
||||
const page = Math.min(totalNow, Math.max(1, props.page()));
|
||||
nav.hidden = Boolean(props.hideOnSinglePage) && totalNow <= 1; // 单页时整块隐藏,工具栏不必为它留位
|
||||
if (props.simple) { // 极简态:页码不给点,只留前后翻页与进度文字
|
||||
return [
|
||||
h('button', { class: 'ua-page-btn', disabled: page <= 1, 'aria-label': '上一页', onClick: () => go(page - 1) }, '‹'),
|
||||
h('span', { class: 'ua-page-simple', 'aria-live': 'polite' }, `${page} / ${totalNow}`),
|
||||
h('button', { class: 'ua-page-btn', disabled: page >= totalNow, 'aria-label': '下一页', onClick: () => go(page + 1) }, '›'),
|
||||
];
|
||||
}
|
||||
const items: HTMLElement[] = [h('button', { class: 'ua-page-btn', disabled: page <= 1, 'aria-label': '上一页', onClick: () => go(page - 1) }, '‹')];
|
||||
for (const it of pageSequence(page, totalPages)) {
|
||||
for (const it of pageSequence(page, totalNow)) {
|
||||
items.push(
|
||||
it == null
|
||||
? h('span', { class: 'ua-page-gap', 'aria-hidden': 'true' }, '…')
|
||||
: h('button', { class: `ua-page-btn${it === page ? ' ua-active' : ''}`, disabled: it === page, 'aria-current': it === page ? 'page' : null, 'aria-label': `第 ${it} 页`, onClick: () => go(it) }, String(it)),
|
||||
);
|
||||
}
|
||||
items.push(h('button', { class: 'ua-page-btn', disabled: page >= totalPages, 'aria-label': '下一页', onClick: () => go(page + 1) }, '›'));
|
||||
items.push(h('button', { class: 'ua-page-btn', disabled: page >= totalNow, 'aria-label': '下一页', onClick: () => go(page + 1) }, '›'));
|
||||
return items;
|
||||
});
|
||||
return nav;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { PinInputProps } from './types.js';
|
||||
|
||||
/*** 验证码输入:N 格单字符数字输入,输入自动进格、退格回删、方向键移格、粘贴自动分发,value 信号双向绑定 ***/
|
||||
export function pinInput(props: PinInputProps): HTMLDivElement {
|
||||
const length = props.length ?? 6; // 格数缺省 6
|
||||
const sanitize = (s: string): string => String(s).replace(/\D/g, '').slice(0, length); // 只留数字并截到格数
|
||||
const inputs: HTMLInputElement[] = []; // 格子引用(格子建一次不重渲染,焦点天然不丢)
|
||||
const focusAt = (i: number): void => inputs[Math.max(0, Math.min(length - 1, i))]?.focus(); // 目标格钳制到首尾
|
||||
|
||||
/*** 写回单一来源:拼接各格内容赋回外部信号(同值短路防 effect 环),并回调当前拼接串 ***/
|
||||
const commit = (): void => {
|
||||
const next = inputs.map((el) => el.value).join('');
|
||||
if (props.value() !== next) props.value(next);
|
||||
if (props.onChange) props.onChange(next);
|
||||
};
|
||||
|
||||
/*** 单格构造:输入进格 / 退格回删 / 方向键移格 / 粘贴分发,非数字键一律拦下 ***/
|
||||
const cell = (i: number): HTMLInputElement => h('input', {
|
||||
class: 'ua-input ua-pin-cell',
|
||||
type: props.mask ? 'password' : 'text',
|
||||
inputmode: 'numeric',
|
||||
autocomplete: props.mask ? 'one-time-code' : 'off',
|
||||
maxlength: 1,
|
||||
'aria-label': `第 ${i + 1} 位`,
|
||||
onFocus: () => inputs[i].select(), // 聚焦全选:直接敲键即覆盖旧值
|
||||
onInput: () => {
|
||||
inputs[i].value = sanitize(inputs[i].value).slice(-1); // 单格只留最后敲入的一位
|
||||
if (inputs[i].value && i < length - 1) focusAt(i + 1); // 敲完进下一格(末格不进)
|
||||
commit();
|
||||
},
|
||||
onKeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Backspace') { e.preventDefault(); if (inputs[i].value) { inputs[i].value = ''; } else if (i > 0) { inputs[i - 1].value = ''; focusAt(i - 1); } commit(); return; } // 本格有值删本格,空格回删上一格并左移
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); focusAt(i - 1); return; }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); focusAt(i + 1); return; }
|
||||
if (e.key.length === 1 && !/\d/.test(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault(); // 只放行数字与控制键(粘贴由 onPaste 接管)
|
||||
},
|
||||
onPaste: (e: ClipboardEvent) => {
|
||||
const digits = sanitize(e.clipboardData?.getData('text') ?? '');
|
||||
if (!digits) return;
|
||||
e.preventDefault();
|
||||
const from = digits.length >= length ? 0 : i; // 整段验证码从首格铺起,零散片段从当前格续
|
||||
for (let k = 0; k < digits.length && from + k < length; k++) inputs[from + k].value = digits[k]; // 逐格分发
|
||||
focusAt(Math.min(length - 1, from + digits.length)); // 落到末个空格,接着手输也顺手
|
||||
commit();
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
|
||||
const root = h('div', { class: 'ua-pin-input', role: 'group' }, ...Array.from({ length }, (_, i) => cell(i))) as HTMLDivElement;
|
||||
root.querySelectorAll('input').forEach((el) => inputs.push(el as HTMLInputElement));
|
||||
|
||||
effect(() => { // 外部信号 → 格子回写(同值不写避免打断输入法),禁用态支持布尔或信号
|
||||
const v = sanitize(props.value());
|
||||
const dis = typeof props.disabled === 'function' ? props.disabled() : !!props.disabled;
|
||||
inputs.forEach((el, k) => {
|
||||
const ch = v[k] ?? '';
|
||||
if (el.value !== ch) el.value = ch;
|
||||
el.disabled = dis;
|
||||
});
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import { button } from './button.js';
|
||||
import { popover } from './popover.js';
|
||||
import type { PopconfirmProps } from './types.js';
|
||||
|
||||
/*** 二次确认气泡:危险操作就地确认,不打断页面;确定后收起再回调,取消 / 点外部 / Esc 都走 onCancel ***/
|
||||
export function popconfirm(props: PopconfirmProps, trigger: HTMLElement): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = props.open ?? signal(false); // 开合信号:自己持有一份,确认与取消都要能写回
|
||||
let accepted = false; // 本次收起是否由「确定」触发:用来区分确认与取消
|
||||
|
||||
/*** 收起弹层:写回信号即触发 popover 的显隐与 Esc 栈清理 ***/
|
||||
const close = (): void => { open(false); };
|
||||
|
||||
const idle = props.confirmText ?? '确定'; // 确认按钮常态文案,异步结束后还原
|
||||
|
||||
/*** 确认:同步回调先收起再执行(避免回调里改数据时弹层还开着);返回 Promise 时就地 loading,成功才收起、失败留在弹层重试 ***/
|
||||
const accept = (): void => {
|
||||
const out = props.onConfirm();
|
||||
if (!(out instanceof Promise)) { accepted = true; close(); return; }
|
||||
okBtn.disabled = true;
|
||||
okBtn.textContent = '处理中…';
|
||||
out.then(() => { accepted = true; close(); })
|
||||
.catch(() => { /* 失败保持展开:提示交给调用方的 toast */ })
|
||||
.finally(() => { okBtn.disabled = false; okBtn.textContent = idle; });
|
||||
};
|
||||
const okBtn = button({ label: idle, variant: props.variant === 'danger' ? 'danger' : 'primary', onClick: () => accept() }) as HTMLButtonElement;
|
||||
|
||||
const body = h(
|
||||
'div',
|
||||
{ class: 'ua-popconfirm' },
|
||||
h('div', { class: 'ua-popconfirm-text' }, props.description ?? '该操作不可撤销,确定继续吗?'),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'ua-popconfirm-actions' },
|
||||
button({ label: props.cancelText ?? '取消', variant: 'ghost', onClick: () => close() }),
|
||||
okBtn,
|
||||
),
|
||||
);
|
||||
|
||||
const wrap = popover({
|
||||
title: props.title,
|
||||
content: body,
|
||||
placement: props.placement ?? 'top', // 缺省压在上方:确认气泡不该盖住被操作的那一行
|
||||
trigger: 'click',
|
||||
open,
|
||||
onOpenChange: (next) => {
|
||||
if (next || accepted) { accepted = false; return; } // 展开、或刚由确认收起:不回调取消
|
||||
props.onCancel?.(); // 点外部 / Esc 收起等同取消
|
||||
},
|
||||
}, trigger);
|
||||
|
||||
effect(() => {
|
||||
if (!open()) return; // 卫语句:收起时不动焦点
|
||||
requestAnimationFrame(() => (body.querySelector('button') as HTMLElement | null)?.focus()); // 展开即落在「取消」上,误按回车不会误伤
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { PopoverProps } from './types.js';
|
||||
import { flip } from './position.js';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
|
||||
let seq = 0; // 全局自增序号:保证每个弹层 id 唯一,供 aria-controls 关联
|
||||
|
||||
/*** 气泡卡片:包裹触发器,默认点击开合;内容可交互,点外部 / Esc / 再次点触发器收起 ***/
|
||||
export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层显隐
|
||||
const open = props.open ?? signal(false); // 弹层显隐:外部传了信号就交给它控制(实现受控开合)
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
|
||||
const place = signal(props.placement ?? 'bottom'); // 实际生效方位:打开时按视口空间自动翻转
|
||||
const id = `ua-pop-${++seq}`; // 弹层唯一 id
|
||||
let timer: number | null = null; // hover 模式下的出入场计时器
|
||||
|
||||
@@ -24,7 +28,7 @@ export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement
|
||||
'div',
|
||||
{
|
||||
id,
|
||||
class: () => `ua-pop ua-pop-${props.placement ?? 'bottom'}${open() ? ' ua-open' : ''}`,
|
||||
class: () => `ua-pop ua-pop-${place()}${open() ? ' ua-open' : ''}`,
|
||||
role: 'dialog',
|
||||
'aria-hidden': () => String(!open()),
|
||||
onClick: (e: Event) => e.stopPropagation(), // 弹层内点击不触发外部关闭
|
||||
@@ -49,12 +53,15 @@ export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement
|
||||
toggle();
|
||||
});
|
||||
}
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || !open()) return; // 卫语句:未展开时 Esc 不处理
|
||||
setOpen(false);
|
||||
trigger.focus(); // 收起后焦点回触发器
|
||||
bindOutside(root, open, () => setOpen(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(() => {
|
||||
if (open()) {
|
||||
releaseEsc ??= pushOverlay(() => { setOpen(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
|
||||
} else {
|
||||
releaseEsc?.();
|
||||
releaseEsc = null;
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', () => setOpen(false)); // 点外部收起(弹层内已阻止冒泡)
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*** 浮层上下翻转判定:按触发器与面板的视口几何判断首选方位是否放得下,放不下且另一侧更宽敞则翻转;返回实际生效方位 'top' | 'bottom' ***/
|
||||
export function flip(trigger: HTMLElement, panel: HTMLElement, preferred: 'top' | 'bottom'): 'top' | 'bottom' {
|
||||
const GAP = 8; // 与 CSS 预留的浮层间距一致(popover 为 8px)
|
||||
const MIN = 24; // 兜底:面板尚未量出高度时按最小可读高度估算
|
||||
const t = trigger.getBoundingClientRect(); // 触发器视口矩形
|
||||
const h = panel.offsetHeight || MIN; // 面板高度(visibility:hidden 仍占布局,可直接量)
|
||||
const up = t.top; // 触发器上方可用空间
|
||||
const down = window.innerHeight - t.bottom; // 触发器下方可用空间
|
||||
if (preferred === 'top') return up >= h + GAP || up >= down ? 'top' : 'bottom'; // 首选上方:放得下、或不比下方更局促就保持
|
||||
return down >= h + GAP || down >= up ? 'bottom' : 'top'; // 首选下方:对称判定
|
||||
}
|
||||
|
||||
/*** 浮层方位判定(四向):水平方向按宽度判左右空间,垂直方向复用 flip 的上下一套逻辑 ***/
|
||||
export function flipSide(trigger: HTMLElement, panel: HTMLElement, preferred: 'top' | 'bottom' | 'left' | 'right'): 'top' | 'bottom' | 'left' | 'right' {
|
||||
const GAP = 8; // 与样式表预留的间距一致
|
||||
const MIN = 24; // 兜底:面板尚未量出宽度时按最小可读宽度估算
|
||||
if (preferred === 'top' || preferred === 'bottom') return flip(trigger, panel, preferred); // 垂直交回原判定
|
||||
const t = trigger.getBoundingClientRect();
|
||||
const w = panel.offsetWidth || MIN;
|
||||
const left = t.left; // 触发器左侧可用空间
|
||||
const right = window.innerWidth - t.right; // 触发器右侧可用空间
|
||||
if (preferred === 'left') return left >= w + GAP || left >= right ? 'left' : 'right'; // 对称判定
|
||||
return right >= w + GAP || right >= left ? 'right' : 'left';
|
||||
}
|
||||
|
||||
/*** 浮层出场期间的位移跟随:滚动 / 缩放时回调重算(rAF 节流);返回摘除函数,收起时调用 ***/
|
||||
export function trackShift(onShift: () => void): () => void {
|
||||
let raf = 0; // rAF 节流句柄:滚动事件高频,一帧最多重算一次
|
||||
const run = (): void => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
onShift();
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', run, true); // 捕获阶段:滚动容器的 scroll 不冒泡,只有捕获收得到
|
||||
window.addEventListener('resize', run);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', run, true);
|
||||
window.removeEventListener('resize', run);
|
||||
if (raf) cancelAnimationFrame(raf); // 摘除时撤销待执行帧,避免对已关闭的浮层重算
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { Signal } from '@ui-arch/frame';
|
||||
import type { ProgressProps } from './types.js';
|
||||
|
||||
/*** 进度条:value 信号驱动 0-100 定量态,缺省 value 则退化为不确定态(无限往复);满格切 success 色并停条纹 ***/
|
||||
/*** 进度条:value 信号驱动 0-100 定量态(缺省则不确定态),type='circle' 且传 value 时出环形;满格切 success 色,format 可自定义文本 ***/
|
||||
export function progress(props: ProgressProps = {}): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const value = props.value; // 可选:不传即不确定态
|
||||
@@ -9,22 +10,28 @@ export function progress(props: ProgressProps = {}): HTMLElement {
|
||||
const track = h('div', { class: 'ua-progress-track' }, fill);
|
||||
const text = h('span', { class: 'ua-progress-text' });
|
||||
const withText = props.showText !== false && !!value; // 不确定态没有百分比可显示
|
||||
const label = props.label; // 可访问名:读屏念「上传进度 50%」而不是干巴巴的百分比
|
||||
const variant = props.variant ?? 'default'; // 语义色:default 之外都带类名,满格时仍以 variant 为准
|
||||
const cls = `ua-progress${variant === 'default' ? '' : ` ua-progress-${variant}`}`;
|
||||
const textOf = (v: number): string => (props.format ? props.format(v) : `${Math.round(v)}%`); // 进度文本统一出口:format 优先
|
||||
|
||||
if (!value) {
|
||||
fill.classList.add('ua-indeterminate'); // 宽度与往复动画交给 CSS
|
||||
return h('div', { class: 'ua-progress', role: 'progressbar', 'aria-label': '进行中' }, track);
|
||||
return h('div', { class: cls, role: 'progressbar', 'aria-label': label ?? '进行中' }, track);
|
||||
}
|
||||
if (props.type === 'circle') return ring(value, label, variant, withText, textOf); // 环形:SVG 版
|
||||
effect(() => {
|
||||
const v = clamp(value()); // 依赖收集:信号变化自动写回
|
||||
fill.style.width = `${v}%`;
|
||||
fill.classList.toggle('ua-done', v >= 100); // 满格切语义色
|
||||
text.textContent = `${Math.round(v)}%`;
|
||||
text.textContent = textOf(v);
|
||||
});
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'ua-progress',
|
||||
class: cls,
|
||||
role: 'progressbar',
|
||||
'aria-label': label,
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuemax': '100',
|
||||
'aria-valuenow': () => String(Math.round(clamp(value()))),
|
||||
@@ -39,3 +46,33 @@ function clamp(v: number): number {
|
||||
if (Number.isNaN(v)) return 0; // 卫语句:非法值归零
|
||||
return Math.min(100, Math.max(0, v));
|
||||
}
|
||||
|
||||
/*** 环形进度:SVG 双圆用 dashoffset 表达弧长(必须 createElementNS,h() 造不出可渲染的 SVG) ***/
|
||||
function ring(value: Signal<number>, label: string | undefined, variant: string, withText: boolean, textOf: (v: number) => string): HTMLElement {
|
||||
const NS = 'http://www.w3.org/2000/svg'; // SVG 命名空间
|
||||
const R = 26; // 半径:viewBox 64 两侧留出线宽
|
||||
const C = 2 * Math.PI * R; // 周长:dasharray 的基准长度
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 64 64');
|
||||
svg.setAttribute('class', `ua-progress-ring${variant === 'default' ? '' : ` is-${variant}`}`);
|
||||
svg.setAttribute('role', 'img');
|
||||
const base = { cx: '32', cy: '32', r: String(R), fill: 'none', 'stroke-width': '6' }; // 两圆共用几何
|
||||
const track = document.createElementNS(NS, 'circle');
|
||||
const arc = document.createElementNS(NS, 'circle');
|
||||
for (const [k, v] of Object.entries({ ...base, class: 'ua-progress-ring-track' })) track.setAttribute(k, v);
|
||||
for (const [k, v] of Object.entries({ ...base, class: 'ua-progress-ring-arc', 'stroke-dasharray': String(C), transform: 'rotate(-90 32 32)' })) arc.setAttribute(k, v); // 从 12 点起画
|
||||
svg.append(track, arc);
|
||||
const txt = document.createElementNS(NS, 'text');
|
||||
txt.setAttribute('x', '32');
|
||||
txt.setAttribute('y', '36');
|
||||
txt.setAttribute('text-anchor', 'middle');
|
||||
txt.setAttribute('class', 'ua-progress-ring-text');
|
||||
if (withText) svg.append(txt);
|
||||
effect(() => {
|
||||
const v = clamp(value());
|
||||
arc.setAttribute('stroke-dashoffset', String(C * (1 - v / 100))); // 弧长按比例收缩
|
||||
txt.textContent = textOf(v);
|
||||
svg.setAttribute('aria-label', `${label ?? '进度'} ${textOf(v)}`); // 环形没有原生 progressbar,名称自己拼
|
||||
});
|
||||
return h('div', { class: 'ua-progress ua-progress-circle' }, svg as unknown as HTMLElement) as HTMLElement;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { ProgressRingProps } from './types.js';
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
// 缺省尺寸与环宽:96px 装得下百分比数字,8px 环宽在小尺寸下也不显笨
|
||||
const SIZE = 96;
|
||||
const THICK = 8;
|
||||
|
||||
/*** 建 SVG 元素并写属性:svg 必须 createElementNS,属性只能 setAttribute ***/
|
||||
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||
const el = document.createElementNS(NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||
return el;
|
||||
}
|
||||
|
||||
/*** 环形进度:SVG 双圆(底环 + 进度弧)靠 dasharray 画弧,value 变化时弧长与文字同步 ***/
|
||||
export function progressRing(props: ProgressRingProps): HTMLElement {
|
||||
const size = props.size ?? SIZE;
|
||||
const thick = props.thickness ?? THICK;
|
||||
const mid = size / 2;
|
||||
const r = (size - thick) / 2; // 半径要扣掉环宽,否则弧会超出画布
|
||||
const c = 2 * Math.PI * r; // 周长:dasharray 的分母
|
||||
const read = (): number => (typeof props.value === 'function' ? props.value() : props.value); // 数值 / 信号同一口径
|
||||
const svg = svgEl('svg', { viewBox: `0 0 ${size} ${size}`, width: size, height: size, 'aria-hidden': 'true' });
|
||||
const track = svgEl('circle', { cx: mid, cy: mid, r, fill: 'none', 'stroke-width': thick, class: 'ua-ring-track' });
|
||||
// 弧从 12 点方向顺时针长:整圆旋转 -90°,否则默认从 3 点位置起步
|
||||
const arc = svgEl('circle', {
|
||||
cx: mid, cy: mid, r, fill: 'none', 'stroke-width': thick,
|
||||
class: 'ua-ring-arc', 'stroke-dasharray': `${0} ${c}`, 'stroke-linecap': 'round',
|
||||
transform: `rotate(-90 ${mid} ${mid})`,
|
||||
});
|
||||
const text = h('span', { class: 'ua-ring-text' });
|
||||
const wrap = h(
|
||||
'div',
|
||||
{
|
||||
class: 'ua-ring',
|
||||
role: 'progressbar',
|
||||
'aria-valuemin': '0',
|
||||
'aria-valuemax': '100',
|
||||
style: `width:${size}px;height:${size}px`,
|
||||
},
|
||||
svg,
|
||||
text,
|
||||
);
|
||||
svg.append(track, arc); // 底环先画,进度弧盖在上面
|
||||
|
||||
/*** 重绘:把百分比夹到 0-100,弧长按比例裁,文字与 aria-valuenow 同步 ***/
|
||||
const paint = (): void => {
|
||||
const pct = Math.min(100, Math.max(0, Math.round(read())));
|
||||
arc.setAttribute('stroke-dasharray', `${(c * pct) / 100} ${c}`); // 只画这一段,剩下留空
|
||||
if (props.showText !== false) text.textContent = props.label ?? `${pct}%`;
|
||||
wrap.setAttribute('aria-valuenow', String(pct));
|
||||
};
|
||||
effect(paint);
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { RateProps } from './types.js';
|
||||
|
||||
/*** 评分:星标点选(allowHalf 可记半星,allowClear 再点当前分清零),hover 预览移出还原,键盘 ←→/↑↓ 调节,value 信号双向绑定 ***/
|
||||
export function rate(props: RateProps): HTMLDivElement {
|
||||
const value = props.value;
|
||||
const max = props.max ?? 5; // 星标总数缺省 5
|
||||
const step = props.allowHalf ? 0.5 : 1; // 半星模式步进 0.5
|
||||
const hover = signal(0); // 预览值(1 基,0 表示无预览)
|
||||
const emit = (v: number, allowZero = false): void => { const n = Math.round(Math.min(max, Math.max(allowZero ? 0 : 1, v)) * 2) / 2; const q = props.allowHalf ? n : Math.round(n); value(q); if (props.onChange) props.onChange(q); }; // 写回信号并回调(半星按 0.5 量化;清零仅在 allowZero 时放行)
|
||||
|
||||
/*** 计算星标命中值:allowHalf 时左半区记 0.5 分 ***/
|
||||
const hitOf = (e: MouseEvent, i: number): number => props.allowHalf && e.offsetX < (e.currentTarget as HTMLElement).offsetWidth / 2 ? i - 0.5 : i;
|
||||
|
||||
/*** 键盘调节:focus 在整组上,方向键按步进调节(重渲染后仍生效) ***/
|
||||
const keyNav = (e: KeyboardEvent): void => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; // 卫语句:只拦截方向键
|
||||
e.preventDefault();
|
||||
emit(value() + (e.key === 'ArrowRight' || e.key === 'ArrowUp' ? step : -step)); // →/↑ 加一步,←/↓ 减一步
|
||||
};
|
||||
|
||||
const render = (): HTMLElement => {
|
||||
const shown = hover() || value(); // 预览优先,无预览显示当前值
|
||||
const stars = [];
|
||||
for (let i = 1; i <= max; i++) {
|
||||
const frac = shown - i + 1; // 该星填充比例(1 满格 / 0.5 半格 / 0 灭)
|
||||
stars.push(h('button', {
|
||||
class: `ua-rate-star${frac >= 1 ? ' is-on' : frac > 0 ? ' is-half' : ''}`,
|
||||
type: 'button',
|
||||
disabled: props.disabled,
|
||||
'aria-label': `${i} 星`,
|
||||
onClick: (e: MouseEvent) => { const hit = hitOf(e, i); if (props.allowClear && hit === value()) emit(0, true); else emit(hit); }, // 再点当前分清零(allowClear)
|
||||
onMouseMove: (e: MouseEvent) => hover(hitOf(e, i)), // 半星模式下按悬停位置细分预览
|
||||
}, '★'));
|
||||
}
|
||||
const node = h('div', { class: 'ua-rate', role: 'slider', tabindex: '0', 'aria-label': '评分', 'aria-valuemin': props.allowClear ? 0 : 1, 'aria-valuemax': max, 'aria-valuenow': value(), onMouseLeave: () => hover(0) }, ...stars) as HTMLDivElement;
|
||||
node.addEventListener('keydown', keyNav); // 每次渲染都挂:节点被替换后键盘仍可用
|
||||
return node;
|
||||
};
|
||||
|
||||
let el!: HTMLElement; // 当前渲染产物(effect 首跑赋值)
|
||||
effect(() => {
|
||||
const next = render();
|
||||
if (el) {
|
||||
const hadFocus = el.contains(document.activeElement); // 重渲染前焦点是否在本组内
|
||||
el.replaceWith(next);
|
||||
if (hadFocus) (next as HTMLElement).focus(); // 归还焦点,方向键可继续连按
|
||||
}
|
||||
el = next;
|
||||
}); // value/hover 变化整条重渲染(星标少,开销可忽略)
|
||||
return el as HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { RelativeTimeProps } from './types.js';
|
||||
|
||||
// 缺省刷新间隔与切换绝对日期的阈值:30s 够用又不吵,7 天后再用「N 天前」已不如日期直观
|
||||
const TICK = 30_000;
|
||||
const MAX_AGO = 7 * 86_400;
|
||||
|
||||
/*** 目标时间归一成毫秒时间戳;非法输入给 NaN,调用方按「未知」处理 ***/
|
||||
function toMs(value: string | number | Date): number {
|
||||
if (value instanceof Date) return value.getTime();
|
||||
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value; // 10 位按秒、13 位按毫秒
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
/*** 相对文案:刚刚 / N 分钟前 / N 小时前 / N 天前;未来时间反过来说「还有」 ***/
|
||||
function phrase(diffSec: number): string {
|
||||
const future = diffSec < 0;
|
||||
const s = Math.abs(diffSec);
|
||||
const unit = (n: number, name: string): string => (future ? `还有 ${n} ${name}` : `${n} ${name}前`);
|
||||
if (s < 60) return future ? '即将' : '刚刚';
|
||||
if (s < 3600) return unit(Math.floor(s / 60), '分钟');
|
||||
if (s < 86400) return unit(Math.floor(s / 3600), '小时');
|
||||
return unit(Math.floor(s / 86400), '天');
|
||||
}
|
||||
|
||||
/*** 补零:绝对日期要定宽,列表里才不会一行一个宽度 ***/
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/*** 绝对日期:超出阈值后显示,同时作悬停提示(精确到分钟就够,秒是噪音) ***/
|
||||
function absolute(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/*** 相对时间:把时间戳渲染成「3 分钟前」并按间隔自动刷新;超阈值改显示绝对日期,title 始终给精确值 ***/
|
||||
export function relativeTime(props: RelativeTimeProps): HTMLElement {
|
||||
const ms = toMs(props.value); // 目标时间(毫秒)
|
||||
const tick = props.refresh ?? TICK; // 自动刷新间隔;传 0 关闭
|
||||
const maxAgo = props.threshold ?? MAX_AGO; // 超过多少秒改用绝对日期
|
||||
const el = h('time', { class: 'ua-reltime', datetime: Number.isNaN(ms) ? '' : new Date(ms).toISOString() }) as HTMLTimeElement;
|
||||
if (Number.isNaN(ms)) { el.textContent = props.fallback ?? '—'; return el; } // 非法时间:不猜,给占位(缺省破折号)
|
||||
el.title = absolute(ms); // 悬停即精确时间:相对文案永远不需要「点开看」
|
||||
if (props.ariaLabel) el.setAttribute('aria-label', props.ariaLabel);
|
||||
|
||||
/*** 一次重绘:算差值 → 超阈值用绝对日期,否则相对文案 ***/
|
||||
const paint = (): void => {
|
||||
const diffSec = Math.round((Date.now() - ms) / 1000);
|
||||
el.textContent = Math.abs(diffSec) > maxAgo ? absolute(ms) : phrase(diffSec);
|
||||
};
|
||||
paint();
|
||||
if (tick > 0) {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!el.isConnected) { window.clearInterval(timer); return; } // 已卸载:自断,避免列表反复挂载累积计时器
|
||||
paint();
|
||||
}, tick);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { h } from '@ui-arch/frame';
|
||||
import type { ResultProps } from './types.js';
|
||||
|
||||
// 语义状态对应的符号(纯装饰,读屏跳过)
|
||||
const GLYPH: Record<string, string> = { success: '✓', warning: '!', danger: '×', info: 'i' };
|
||||
|
||||
/*** 结果页:状态符号 + 标题 + 说明 + 操作区,用于提交完成 / 校验失败 / 无权限等终态页面 ***/
|
||||
export function result(props: ResultProps): HTMLElement {
|
||||
const status = props.status ?? 'info'; // 缺省中性信息
|
||||
return h(
|
||||
'div',
|
||||
{ class: `ua-result ua-result-${status}`, role: 'status' }, // role=status 由读屏播报终态
|
||||
h('div', { class: 'ua-result-icon', 'aria-hidden': 'true' }, GLYPH[status] ?? 'i'),
|
||||
h('div', { class: 'ua-result-title' }, props.title),
|
||||
props.description ? h('div', { class: 'ua-result-desc' }, props.description) : null,
|
||||
props.extra ? h('div', { class: 'ua-result-extra' }, props.extra) : null, // 操作区:常见为主次按钮
|
||||
) as HTMLElement;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { effect, h } from '@ui-arch/frame';
|
||||
import type { SegmentedProps } from './types.js';
|
||||
import { trackSize } from './watch.js';
|
||||
|
||||
/*** 分段控制器:胶囊分段条 + 滑动滑块,value 信号双向绑定,←→/Home/End 环形导航,整组或单项可禁用 ***/
|
||||
export function segmented(props: SegmentedProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const buttons: HTMLButtonElement[] = []; // 分段按钮,顺序与 options 一致
|
||||
const thumb = h('span', { class: 'ua-seg-thumb', 'aria-hidden': 'true' }); // 滑块:仅装饰,不参与可访问性树
|
||||
const root = h('div', { class: `ua-segmented ua-segmented-${props.size ?? 'md'}`, role: 'radiogroup', 'aria-label': '分段选择' }, thumb);
|
||||
|
||||
/*** 整组禁用判定:布尔直接取,信号在此读取以纳入 effect 依赖 ***/
|
||||
const groupOff = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : props.disabled === true);
|
||||
|
||||
/*** 同步选中态、禁用态与滑块几何(挂载前量不到宽度,由 rAF 二次测量兜底) ***/
|
||||
const sync = (): void => {
|
||||
const off = groupOff();
|
||||
const box = root.getBoundingClientRect();
|
||||
root.classList.toggle('is-disabled', off);
|
||||
for (const btn of buttons) {
|
||||
const on = btn.dataset.value === props.value();
|
||||
const dead = off || btn.dataset.dead === '1'; // 整组禁用或单项禁用
|
||||
btn.classList.toggle('is-active', on);
|
||||
btn.setAttribute('aria-checked', String(on));
|
||||
btn.setAttribute('aria-disabled', String(dead));
|
||||
btn.disabled = dead;
|
||||
btn.tabIndex = on ? 0 : -1; // 漫游 tabindex:仅选中项可 Tab 聚焦
|
||||
if (!on) continue; // 滑块只跟选中项走
|
||||
const rect = btn.getBoundingClientRect();
|
||||
thumb.style.width = `${rect.width}px`;
|
||||
thumb.style.transform = `translateX(${rect.left - box.left - root.clientLeft}px)`; // 减去容器边框,滑块与按钮严丝合缝
|
||||
}
|
||||
};
|
||||
|
||||
for (const opt of props.options) {
|
||||
const btn = h('button', { type: 'button', class: 'ua-seg-btn', role: 'radio' }, opt.label) as HTMLButtonElement;
|
||||
btn.dataset.value = opt.value;
|
||||
if (opt.disabled) btn.dataset.dead = '1'; // 单项禁用标记,交给 sync 统一置 disabled
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return; // 卫语句:禁用项不响应
|
||||
props.value(opt.value); // 用户切换写回信号
|
||||
props.onChange?.(opt.value);
|
||||
});
|
||||
buttons.push(btn);
|
||||
root.append(btn);
|
||||
}
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const map: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }; // 左右移动,首尾环形
|
||||
const steer = map[e.key];
|
||||
if (!steer) return; // 卫语句:非方向键放行
|
||||
const list = buttons.filter((b) => !b.disabled);
|
||||
const i = list.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (i < 0) return; // 卫语句:焦点不在分段项上
|
||||
e.preventDefault();
|
||||
const next = list[(i + steer + list.length) % list.length]; // 环形取模
|
||||
next.focus();
|
||||
next.click();
|
||||
});
|
||||
|
||||
effect(sync);
|
||||
requestAnimationFrame(sync); // 挂载后再量一次布局,否则滑块宽度为 0
|
||||
trackSize(root, sync); // 尺寸变化后重算滑块位置与宽度
|
||||
return root;
|
||||
}
|
||||
+125
-18
@@ -1,14 +1,22 @@
|
||||
import { effect, h, signal } from '@ui-arch/frame';
|
||||
import type { SelectProps } from './types.js';
|
||||
import { flip, trackShift } from './position.js';
|
||||
import { bindOutside, pushOverlay } from './overlay.js';
|
||||
|
||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;支持 ↑↓/Home/End 键盘导航,点选项 / 点外部 / Esc 关闭 ***/
|
||||
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
|
||||
export function select(props: SelectProps): HTMLElement {
|
||||
// —— 状态集中声明 ——
|
||||
const open = signal(false); // 弹层开合
|
||||
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
|
||||
const open = signal(false); // 弹层开合
|
||||
const place = signal<'top' | 'bottom'>('bottom'); // 实际生效方位:打开时按视口空间自动翻转
|
||||
const query = signal(''); // 搜索关键字(仅 searchable 时使用)
|
||||
const value = props.value ?? signal(''); // 选中值;未传信号则内部持有
|
||||
|
||||
let releaseEsc: (() => void) | null = null; // Esc 栈注销函数:展开期间占一层,收起即释放
|
||||
const labels = props.options.map((o) => o.label); // 文案缓存,供搜索与键入快跳共用
|
||||
const labelOf = (v: string) => props.options.find((o) => o.value === v)?.label ?? '';
|
||||
const current = () => labelOf(value()); // 触发器显示文案(响应式)
|
||||
const match = (i: number) => !query() || labels[i].toLowerCase().includes(query().toLowerCase()); // 关键字包含匹配
|
||||
const visibleIdx = () => props.options.map((_, i) => i).filter(match); // 当前可见选项的下标序列
|
||||
|
||||
const pick = (v: string) => {
|
||||
value(v);
|
||||
@@ -16,14 +24,22 @@ export function select(props: SelectProps): HTMLElement {
|
||||
props.onChange?.(v);
|
||||
};
|
||||
|
||||
/*** 清空:可选筛选项回到「未选」态,信号与回调同源写回 ***/
|
||||
const clear = () => {
|
||||
if (!value()) return; // 卫语句:本来就没选,不重复派发回调
|
||||
value('');
|
||||
props.onChange?.('');
|
||||
};
|
||||
|
||||
// —— 触发器 ——
|
||||
const trigger = h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'ua-select-trigger',
|
||||
class: `ua-select-trigger${props.size && props.size !== 'md' ? ` is-${props.size}` : ''}`,
|
||||
'aria-haspopup': 'listbox',
|
||||
'aria-expanded': () => String(open()),
|
||||
'aria-label': props.ariaLabel ?? null, // 无可见 label 时由调用方给名字,null 让 h() 不落属性
|
||||
disabled: props.disabled, // 布尔或信号禁用,h() 直接透传(原生 button 拦截点击)
|
||||
onClick: () => open(!open()),
|
||||
},
|
||||
@@ -39,34 +55,125 @@ export function select(props: SelectProps): HTMLElement {
|
||||
type: 'button',
|
||||
class: () => `ua-option${opt.value === value() ? ' ua-option-selected' : ''}`,
|
||||
role: 'option',
|
||||
'aria-selected': () => String(opt.value === value()),
|
||||
'aria-selected': () => String(value() === opt.value),
|
||||
onClick: () => pick(opt.value),
|
||||
},
|
||||
opt.label,
|
||||
),
|
||||
);
|
||||
const pop = h('div', { class: () => `ua-select-pop${open() ? ' ua-open' : ''}`, role: 'listbox' }, ...options);
|
||||
// —— 分组:相邻同组选项合成一段,组内首项前插小标题;记下区间供过滤时整组显隐 ——
|
||||
const nodes: HTMLElement[] = []; // 弹层内容序列(小标题与选项混排)
|
||||
const runs: { el: HTMLElement; from: number; to: number }[] = []; // 每段标题及其首尾选项下标
|
||||
props.options.forEach((opt, i) => {
|
||||
let run = runs[runs.length - 1] ?? null;
|
||||
if (opt.group && (!run || run.to !== i - 1)) { // 新起一段:标题插在本组首项之前
|
||||
const el = h('div', { class: 'ua-option-group', role: 'presentation' }, opt.group);
|
||||
run = { el, from: i, to: i };
|
||||
runs.push(run);
|
||||
nodes.push(el);
|
||||
} else if (opt.group && run) run.to = i; // 同组续接:只拉长区间
|
||||
nodes.push(options[i]);
|
||||
});
|
||||
const search = h('input', {
|
||||
type: 'search',
|
||||
class: 'ua-select-search',
|
||||
placeholder: '搜索…',
|
||||
'aria-label': '搜索选项',
|
||||
oninput: (e: Event) => query((e.target as HTMLInputElement).value), // 输入即过滤
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); options[visibleIdx()[0] ?? 0]?.focus(); } // ↓ 从搜索框进选项
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
const blank = h('div', { class: 'ua-select-blank' }, '无匹配选项'); // 过滤空结果提示
|
||||
const pop = h(
|
||||
'div',
|
||||
{ class: () => `ua-select-pop${open() ? ' ua-open' : ''}${place() === 'top' ? ' is-flip' : ''}`, role: 'listbox', 'aria-label': '选项列表' },
|
||||
props.searchable ? search : null,
|
||||
...nodes,
|
||||
blank,
|
||||
);
|
||||
|
||||
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, pop); // 根带开合类,供箭头旋转
|
||||
// —— 清空按钮:clearable 且有值时出现,绝对定位压在触发器右缘(与箭头错开)——
|
||||
const clearBtn = h('button', {
|
||||
type: 'button',
|
||||
class: 'ua-select-clear',
|
||||
'aria-label': '清空选择',
|
||||
tabindex: '-1', // 不进 Tab 序:清空是鼠标 / 触屏便利,键盘用 Delete / Backspace
|
||||
onClick: () => clear(),
|
||||
}, '×');
|
||||
effect(() => { clearBtn.hidden = !props.clearable || !value(); }); // 无值或未开启时藏起来(不进 Tab 序,隐藏即失效)
|
||||
|
||||
const root = h('div', { class: () => `ua-select${open() ? ' ua-open' : ''}` }, trigger, props.clearable ? clearBtn : null, pop); // 根带开合类,供箭头旋转
|
||||
|
||||
// —— 键入快跳缓冲(非 searchable 时生效,逻辑与 menu 一致)——
|
||||
let typed = ''; // 最近键入的字母串,用于前缀匹配
|
||||
let typedAt = 0; // 最近键入时间戳,超 1 秒重置缓冲
|
||||
const jumpByTyped = (ch: string): void => {
|
||||
const now = Date.now();
|
||||
typed = now - typedAt > 1000 ? ch : typed + ch; // 超时重新起词,否则追加
|
||||
typedAt = now;
|
||||
const list = visibleIdx();
|
||||
const at = list.findIndex((i) => options[i] === document.activeElement); // 当前焦点在可见集中的序号
|
||||
const order = list.length ? list.map((_, k) => list[(at + 1 + k) % list.length]) : []; // 从下一项开始环形排列
|
||||
const hit = order.find((i) => labels[i].toLowerCase().startsWith(typed.toLowerCase())); // 前缀匹配(忽略大小写)
|
||||
if (hit != null) options[hit]?.focus(); // 命中即聚焦;无命中留在原地
|
||||
};
|
||||
|
||||
root.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { open(false); trigger.focus(); return; } // Esc 关闭并回焦触发器
|
||||
const list = visibleIdx();
|
||||
if (props.clearable && !open() && (e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault(); clear(); return; } // 键盘等价:收起态下 Delete / Backspace 清空
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault(); // 阻止页面滚动
|
||||
if (!open()) { open(true); options[0]?.focus(); return; } // 未展开:展开并聚焦首个选项
|
||||
const idx = options.indexOf(document.activeElement as HTMLElement); // 展开中:在选项间环形移动
|
||||
if (!open()) { open(true); options[list[0] ?? 0]?.focus(); return; } // 未展开:展开并聚焦首个可见选项
|
||||
const at = list.findIndex((i) => options[i] === document.activeElement); // 展开中:在可见选项间环形移动
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const next = (idx + dir + options.length) % options.length;
|
||||
options[next]?.focus();
|
||||
options[list[(at + dir + list.length) % list.length]]?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' && open()) { e.preventDefault(); options[0]?.focus(); return; } // Home 跳首项
|
||||
if (e.key === 'End' && open()) { e.preventDefault(); options[options.length - 1]?.focus(); } // End 跳末项
|
||||
if (e.key === 'Home' && open()) { e.preventDefault(); options[list[0]]?.focus(); return; } // Home 跳首个可见项
|
||||
if (e.key === 'End' && open()) { e.preventDefault(); options[list[list.length - 1]]?.focus(); return; } // End 跳末个可见项
|
||||
if (open() && !props.searchable && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳(搜索模式由输入框接管)
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
|
||||
open(false); // 点外部关闭
|
||||
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
|
||||
effect(() => {
|
||||
const q = query();
|
||||
let shown = 0;
|
||||
options.forEach((el, i) => {
|
||||
const on = match(i);
|
||||
el.hidden = !on; // 不匹配即折叠,键盘只在可见项之间移动
|
||||
if (on) shown += 1;
|
||||
});
|
||||
for (const run of runs) { // 整组都无命中时连小标题一起藏,不留孤零零的分组名
|
||||
let any = false;
|
||||
for (let i = run.from; i <= run.to; i++) if (match(i)) { any = true; break; }
|
||||
run.el.hidden = !any;
|
||||
}
|
||||
blank.hidden = shown > 0 || !q; // 仅「有关键字且无命中」时才提示
|
||||
});
|
||||
effect(() => {
|
||||
if (open() && props.searchable) {
|
||||
query(''); // 每次打开重置关键字
|
||||
search.value = '';
|
||||
requestAnimationFrame(() => search.focus()); // 搜索模式:打开即聚焦输入框
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (open()) {
|
||||
releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
|
||||
} else {
|
||||
releaseEsc?.();
|
||||
releaseEsc = null;
|
||||
}
|
||||
});
|
||||
let releaseShift: (() => void) | null = null; // 展开期间的位移跟随释放函数(滚动 / 缩放重算翻转)
|
||||
effect(() => {
|
||||
if (!open()) { // 收起即摘监听:弹层不在,重算方位没有意义
|
||||
releaseShift?.();
|
||||
releaseShift = null;
|
||||
return;
|
||||
}
|
||||
place(flip(trigger, pop, 'bottom')); // 打开先算一次:空间不足自动翻转
|
||||
releaseShift ??= trackShift(() => place(flip(trigger, pop, 'bottom'))); // 展开期间跟着滚动重算:视口空间会变
|
||||
});
|
||||
effect(() => open()); // 建立依赖,保证开合信号生效
|
||||
return root;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user