Compare commits
720 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb7ca2df3 | |||
| ccbb0415e4 | |||
| 496f54cedd | |||
| 83b5ecebeb | |||
| 53b5cbed07 | |||
| 352fe79791 | |||
| a95d5a96a0 | |||
| ece7bb9a94 | |||
| d42859b7b2 | |||
| f5655ad20b | |||
| d3463f009b | |||
| bb883ce341 | |||
| d9703d3ce3 | |||
| 7b5ba74d8b | |||
| a61f57db13 | |||
| c33ad2e405 | |||
| 4190324cb4 | |||
| 1b108fcc8c | |||
| 0b2675c7e5 | |||
| 12b0aa0aad | |||
| 987ae70e7a | |||
| 3ba673282a | |||
| 20a52d1b3e | |||
| dafcf3834c | |||
| 639672358a | |||
| 671fb7dc66 | |||
| b92966ef28 | |||
| c1102634f3 | |||
| ee470775b2 | |||
| ba0f1602a1 | |||
| 682955212e | |||
| 0410f6c196 | |||
| 24aa7588c5 | |||
| b46fe8fe93 | |||
| b47c2053b5 | |||
| 16bf8001ae | |||
| 792e77f824 | |||
| 9b39196195 | |||
| ad59e3d334 | |||
| 0de4283fae | |||
| 6f9c92a866 | |||
| 0ec2f2aebb | |||
| cd22106597 | |||
| a212536cfa | |||
| 18297d54c4 | |||
| f790ca38d0 | |||
| ce2b42ecd5 | |||
| 09e299bc2e | |||
| bbc7dfe29a | |||
| 49b9e833e8 | |||
| f739bb608e | |||
| 286a6f9088 | |||
| e32b9589a5 | |||
| 6427510c98 | |||
| cf92a423cf | |||
| 3f3677ebaa | |||
| edc15a727c | |||
| 960085145d | |||
| 7fdb4f19a8 | |||
| e21fb79731 | |||
| 05f669a7bd | |||
| 8137d79e18 | |||
| 3b474b7dcc | |||
| e449b413d1 | |||
| 8918dc94bd | |||
| 2c595bf803 | |||
| 75f31a6cec | |||
| b211c0d068 | |||
| 911159ee55 | |||
| c0dbc3c0d0 | |||
| 7eea21c9d4 | |||
| 2f17dea480 | |||
| ce33aff843 | |||
| 09eea844d7 | |||
| 956edf0d63 | |||
| 1db74177b3 | |||
| 1c25554c38 | |||
| 7d1e06701b | |||
| aae4427281 | |||
| 911c51d078 | |||
| 2c12c22666 | |||
| 60a811fd18 | |||
| 9a9aea56da | |||
| 49ad998b2c | |||
| 5066681b3a | |||
| ee22879c00 | |||
| 9b0ff2d856 | |||
| 7e14645ed7 | |||
| 811737adcd | |||
| 7b6c135cd3 | |||
| 46065b2424 | |||
| e76a6c3632 | |||
| 896bc2bbb1 | |||
| 296d254ba2 | |||
| ecad05098f | |||
| 956964f5b9 | |||
| ed73e16bbb | |||
| 7817b4a440 | |||
| 03f25b7f10 | |||
| 24957f02d4 | |||
| fe3cd0591f | |||
| 56f5f5887b | |||
| 2e0bf26301 | |||
| 3d7f5253e8 | |||
| 669f12e822 | |||
| 8b870a8e46 | |||
| 9148f0595a | |||
| ea7da1c9b9 | |||
| 3e81f54e99 | |||
| 65aa9f3c06 | |||
| 82ebd9c556 | |||
| 50aa071e2e | |||
| 807e1ff733 | |||
| 4146a348ab | |||
| bd10b4e64d | |||
| 243ecddd42 | |||
| d7b690621e | |||
| 60951330d1 | |||
| 7095197d07 | |||
| 3ee48e80ad | |||
| 00ad2b0563 | |||
| a57005a49b | |||
| d67a66662d | |||
| c75c5bcd3b | |||
| ad0864cddf | |||
| 9985c29a84 | |||
| 1dcaccdb6d | |||
| 35eb410051 | |||
| 10c43ecd59 | |||
| 9df4a09414 | |||
| 7cbc941407 | |||
| b31f306106 | |||
| 1dbbac450c | |||
| b5a2bd7436 | |||
| 931a760ee1 | |||
| 27414e0284 | |||
| d63bc762d0 | |||
| 505e40a57f | |||
| d1ea10d8c6 | |||
| 1038759d8b | |||
| ab9b545c9a | |||
| e1329ecd7a | |||
| 167df241b7 | |||
| b41e9f31e7 | |||
| 02f25aa02e | |||
| 312fc4ba90 | |||
| 56d7b44b01 | |||
| f72c9fad3a | |||
| d48fd667a2 | |||
| 979877b3b0 | |||
| 342bd7d7c2 | |||
| 4d42911198 | |||
| 3ea7186d6c | |||
| 09e35d0245 | |||
| 4a26307e1b | |||
| 113c013ea9 | |||
| 0571d5bf4b | |||
| 5f86fdba72 | |||
| 474385a939 | |||
| 71d64fccb8 | |||
| e9541da8ff | |||
| 68b4e9ec8e | |||
| 856d354b5a | |||
| 89a4a15e78 | |||
| fca3638f7f | |||
| 90fc8bed35 | |||
| bd223f77d0 | |||
| 1041814823 | |||
| 366544befc | |||
| 3b93bd63a7 | |||
| ca525ce7e3 | |||
| 83f153f654 | |||
| 75637c7793 | |||
| e0a125c9bd | |||
| 4b2178cedd | |||
| 08a4c361fa | |||
| 35a648d450 | |||
| 1c76ade150 | |||
| 8b02c5aea3 | |||
| c82c407350 | |||
| 169f74aa2e | |||
| e4a042907a | |||
| 7ce282c500 | |||
| 302777feff | |||
| cdcd4f79c8 | |||
| f2e6342a61 | |||
| 0f02e7d00f | |||
| a1079cbbdd | |||
| 58af08cb0d | |||
| 6626726029 | |||
| f3afbb2e48 | |||
| fbd52ee9a5 | |||
| 3284d91c2a | |||
| 22e6b74c4f | |||
| 4de835474b | |||
| 024d8af40d | |||
| 808b74fa17 | |||
| 202881ef1a | |||
| 7de3d451ad | |||
| f0e0430016 | |||
| 873579fc97 | |||
| d321db363d | |||
| 73c1874e3f | |||
| 1aa06398a0 | |||
| 99b23236a1 | |||
| d1e7e5447c | |||
| 4f22a98b78 | |||
| eb09aee264 | |||
| c3fca1db36 | |||
| 2a5e6ee37a | |||
| 41e2125dc7 | |||
| 2a76b67e9a | |||
| d697958536 | |||
| 1789807f90 | |||
| 03315db863 | |||
| 79b1a4ea9f | |||
| 8fb5e2e2a2 | |||
| 640a69f4cd | |||
| bdb666cbe2 | |||
| 8a1d830376 | |||
| c1e8f8c2a6 | |||
| a8f0e5659e | |||
| cd3c7c8e63 | |||
| 5b4319432c | |||
| e33f4e7a70 | |||
| f101df9329 | |||
| d926f5c5e4 | |||
| 8ad754c9bc | |||
| ed20e04e96 | |||
| daef1aa841 | |||
| 339ea2d7d4 | |||
| 036bba44ae | |||
| 48fbeb397d | |||
| 346abfa685 | |||
| f1123f319f | |||
| ac15da9c82 | |||
| b9432c8489 | |||
| b35b1fbae7 | |||
| e39590df2c | |||
| fad7fda2a6 | |||
| 987f557c60 | |||
| 4eef9fc731 | |||
| cd86001713 | |||
| f7e4582fde | |||
| 4635e3fce5 | |||
| af3dc5c466 | |||
| 12861b2230 | |||
| b7f672e0f2 | |||
| fcb44dfd24 | |||
| f17b880b59 | |||
| 68785d9a72 | |||
| ab4396297a | |||
| ef369f2955 | |||
| 1e73a9527b | |||
| 23a4faa5d1 | |||
| b0020ace16 | |||
| bb78d32dbf | |||
| e83ad8d504 | |||
| 765b01afe0 | |||
| 00e34e7e6c | |||
| bf2ee25390 | |||
| bf6d8d0bc6 | |||
| 3399004e75 | |||
| 6c2f36f020 | |||
| 71f4d44782 | |||
| 6df2eb5acc | |||
| 469f8e0f21 | |||
| 3712f6ef90 | |||
| d2646cd62c | |||
| f29ca0ba0b | |||
| 0c273a818d | |||
| 6e8099c6f4 | |||
| 07c68b82a4 | |||
| afd19dc912 | |||
| f02572665f | |||
| f93082e9b0 | |||
| 08f3bad5f9 | |||
| 563958813e | |||
| 1ae1703133 | |||
| d2771dfc31 | |||
| dd46d3e2f4 | |||
| ae641801e1 | |||
| 719e63c667 | |||
| e26da1bb8f | |||
| f5fad07038 | |||
| 68992301ff | |||
| d529c27a68 | |||
| 56e8d868c9 | |||
| 14e1d4a9f5 | |||
| efe49469aa | |||
| 39500f802a | |||
| a64d38dbb1 | |||
| e2d3ae8949 | |||
| 1c4fd0a2da | |||
| d0e85da308 | |||
| 6a0b0aa741 | |||
| 4c620f480c | |||
| b7ebcc4cef | |||
| e6275bfeb3 | |||
| d4585654b6 | |||
| 1a8562dc6f | |||
| 2148959c35 | |||
| 28a84f7c1a | |||
| e935dc3983 | |||
| ab30b51c9e | |||
| 8377d94f81 | |||
| 14ebeeaee8 | |||
| fdc8cb44ab | |||
| f72936d1a7 | |||
| 04170d4834 | |||
| 9006e023bd | |||
| 8c8d5e9329 | |||
| f4fee46439 | |||
| af2ee9a6a5 | |||
| a3012b5ec1 | |||
| 0f414ae24a | |||
| 1fa9b410ed | |||
| 90c7890cbf | |||
| 55e601b806 | |||
| 45952914ba | |||
| 5f83b01f0e | |||
| c03563f2fc | |||
| edf50d2f8d | |||
| d0789ab279 | |||
| 55bac1bf7b | |||
| 2957121473 | |||
| 020a1bfb75 | |||
| bb246c4f8e | |||
| cf258a8d59 | |||
| 38b4df1ce3 | |||
| 2f7fdc16c7 | |||
| f4b426bc7f | |||
| 9577cc9ebf | |||
| be5124217a | |||
| d029a6c346 | |||
| 69bb03dcdf | |||
| 5eb2f4cebc | |||
| c021a84788 | |||
| 321ce99338 | |||
| 6cfe89645c | |||
| 11f900beeb | |||
| 39ca21e57c | |||
| 87872191e2 | |||
| 2d3fd74333 | |||
| eebe898dcc | |||
| ca73d00f68 | |||
| 14bdc46073 | |||
| bf04ce6a8f | |||
| 9dc6dab3a5 | |||
| a21a3b6918 | |||
| 6ae21d73aa | |||
| 941871991f | |||
| 497c38f426 | |||
| bfaa1623d9 | |||
| 5ffa5a2adc | |||
| 16d1375e47 | |||
| 30eda34f37 | |||
| 2cc7eb1ead | |||
| 64f5bee801 | |||
| 9d57f983dd | |||
| 8ccd21df36 | |||
| c22c994d12 | |||
| 01369444b7 | |||
| 4beb15f70c | |||
| 30170bcd2e | |||
| f29a8de504 | |||
| 965ab02315 | |||
| 991784eae6 | |||
| b474b7986c | |||
| 98542252cb | |||
| 0d78deadf2 | |||
| 5108f47e56 | |||
| 7d4e5f2ca7 | |||
| c0ad0f4570 | |||
| d56eb602a9 | |||
| f1c791eb12 | |||
| e872188be7 | |||
| 2d712e78b0 | |||
| ebeecd0686 | |||
| 1d09f994c6 | |||
| 686c3714fc | |||
| e29086036c | |||
| 78e24aa720 | |||
| 7c8876c835 | |||
| 2222fb1e01 | |||
| 46b30a1ef0 | |||
| d49d5f70ef | |||
| 76a950e2ba | |||
| 077aba5e58 | |||
| 857362423f | |||
| e7232b6a53 | |||
| 2eb26544b3 | |||
| a40834d1cf | |||
| 0c1159d4c7 | |||
| 4584765046 | |||
| 5817068cb5 | |||
| 8fa01fbaad | |||
| 9cdae8ccdb | |||
| 7e1c5f3dbb | |||
| 1f3f17247c | |||
| f07f2bb95e | |||
| 0b2f6715e0 | |||
| e473364d40 | |||
| 554b72b075 | |||
| 2c34ec8b39 | |||
| e6b8e2de19 | |||
| a99d270ef1 | |||
| 93ee42135c | |||
| f3ca4a114a | |||
| 7515b824eb | |||
| 18b98b831a | |||
| d99fc8bde9 | |||
| f8d5f86814 | |||
| 9c1de08b4b | |||
| 7f26337e1b | |||
| 49f3fc8feb | |||
| 5613ad7fa6 | |||
| 5af43900c4 | |||
| c5598f95ef | |||
| 13d0cf729c | |||
| 032e928dde | |||
| 14ab3682f2 | |||
| c554e730a6 | |||
| 4d5a490e80 | |||
| 7708c89fe1 | |||
| 0a0be3e357 | |||
| 7b7c469fab | |||
| d852186888 | |||
| 6cfda1ebf3 | |||
| 8e0062fdd5 | |||
| 844cc30551 | |||
| 64074e37fc | |||
| cd9b028e9b | |||
| 81da871e38 | |||
| 90e78a2e31 | |||
| 5cec1fea73 | |||
| 52130d67e2 | |||
| a73ad40eb1 | |||
| 6589818b0b | |||
| 555fa32ac2 | |||
| 50a84f2422 | |||
| c724a3e85b | |||
| b47530e254 | |||
| b80fdf113e | |||
| 963edbffa3 | |||
| f3687f724f | |||
| a736ee9800 | |||
| bea2047092 | |||
| 7d98ac8f38 | |||
| 32244bb450 | |||
| b154631c77 | |||
| 466a24ac10 | |||
| 7db91a2fe6 | |||
| 46652dec6f | |||
| 50e591b80c | |||
| 6710a163a9 | |||
| a39f43f79a | |||
| 964520a2f9 | |||
| 6e680085a4 | |||
| 286a843b67 | |||
| df7c5ebafc | |||
| 9927323a9d | |||
| 66f3e66c8b | |||
| c68b0c5090 | |||
| 53ac03507d | |||
| 0031b51bcf | |||
| 67a8c6e591 | |||
| 806390a068 | |||
| 508b18bc3b | |||
| c2499ea507 | |||
| c0c91aec94 | |||
| a27008a295 | |||
| 7e11627618 | |||
| 968ce63a53 | |||
| 7c0eb3290f | |||
| 62403625ba | |||
| 9a34b03540 | |||
| 8bb63df7e3 | |||
| 65cd73845a | |||
| 8534bc254b | |||
| 2a2fd324a0 | |||
| 58e3de2559 | |||
| c30736870d | |||
| cfd48de885 | |||
| 462df2b648 | |||
| f64da93cf9 | |||
| a55db621ef | |||
| c033bdfc3b | |||
| 2610e56ec1 | |||
| 08aa9e3fe4 | |||
| 411ae7ee07 | |||
| 41700c1eb1 | |||
| 8d7bac9793 | |||
| 0229eefa4d | |||
| 61f646743a | |||
| e3babde7e8 | |||
| c389e43e93 | |||
| 1511db4eea | |||
| d713756034 | |||
| 17d224332d | |||
| 32dd5e769b | |||
| 12ace00a90 | |||
| bbc09330c9 | |||
| 82ead7bd1a | |||
| f8f9b150b8 | |||
| 494e8b7e26 | |||
| c76d364071 | |||
| 760e0085f7 | |||
| 6890ca1f1f | |||
| 9ca000cf06 | |||
| 1c0f5129a9 | |||
| 69e17949f4 | |||
| bcfd3495dd | |||
| 03a46a72c5 | |||
| 2465ce5f9b | |||
| 00ac83f205 | |||
| 67065b1ffb | |||
| 783c10479f | |||
| d4eae1cd9e | |||
| 4e674f67c5 | |||
| 889a543780 | |||
| fc5f3a9576 | |||
| 66644364b5 | |||
| 51febdae06 | |||
| 745ff299dc | |||
| 3606d60ba6 | |||
| 8970a79141 | |||
| 566a7ce148 | |||
| 362bef15e3 | |||
| 446c494863 | |||
| dbe2f2f217 | |||
| 02ca92a431 | |||
| a9015e787c | |||
| 9cd28fa819 | |||
| 7b3793e943 | |||
| a22c04d98d | |||
| 5367512292 | |||
| 9c387f8ace | |||
| c3ec288d09 | |||
| 9d952afebb | |||
| 621262fde7 | |||
| e3e79d9a11 | |||
| d08a92814e | |||
| a6f277250e | |||
| 09cb82d587 | |||
| 316e2a0b27 | |||
| e0d2679801 | |||
| 107800a057 | |||
| 4ee8512ee4 | |||
| f75a3714ae | |||
| d6047f2e78 | |||
| 03769c8412 | |||
| 1a782eb4ee | |||
| a27adf25cb | |||
| 96e237b9af | |||
| 710e06b2f1 | |||
| a3bc25c4ac | |||
| ee5b712a37 | |||
| 879ae5ad91 | |||
| 2bbda416b2 | |||
| de88957857 | |||
| aa0c63d569 | |||
| d843f27fd3 | |||
| a26755b7d3 | |||
| 345a562a21 | |||
| 3fae99554d | |||
| ed30ed3f8f | |||
| beef47959b | |||
| aec2cf18fd | |||
| 0f45633666 | |||
| 8588888ce7 | |||
| 2a62a635cd | |||
| 0eb2557956 | |||
| 9960bf7018 | |||
| f26f0d0450 | |||
| 53fadfcbd5 | |||
| 497d3a3ac5 | |||
| 37524765ae | |||
| ab0219d3e4 | |||
| 7cefd9cba5 | |||
| 4cf5ca2d7f | |||
| a9791220fb | |||
| 4aed14c7a2 | |||
| 49d1cba3fd | |||
| 9bacca3070 | |||
| 42e7ae6d6b | |||
| a8b244520c | |||
| 9879a2cb6a | |||
| e2a54c6f18 | |||
| dfd13e641a | |||
| 2284159b72 | |||
| 1de86c6bfa | |||
| 62087a686a | |||
| 572deb990e | |||
| 719c63a092 | |||
| 9d9700214f | |||
| bc82e110ef | |||
| fe15ebe82d | |||
| 1b8577d300 | |||
| ef4cfb81d8 | |||
| abea5942b7 | |||
| a6274e9a2d | |||
| 8b3752f586 | |||
| c8e1d24224 | |||
| dbd7748ac0 | |||
| 68984d5702 | |||
| 5237439f88 | |||
| c3df73616f | |||
| a03b095d14 | |||
| 9a18658e09 | |||
| 2910757e8c | |||
| a00bacd4ff | |||
| 01963447dd | |||
| 5d1d1ba4ba | |||
| 7885422033 | |||
| fb48425c13 | |||
| 3792c5edf5 | |||
| 2abd91ab4a | |||
| 310910e8ee | |||
| 3301ad1556 | |||
| 0f8a7a5a7b | |||
| f1a0c5741c | |||
| cbf60db9fa | |||
| 34d5bda579 | |||
| 916ba68c94 | |||
| 46fc396772 | |||
| 0e77baf600 | |||
| 89fd8b5080 | |||
| 884f9725b5 | |||
| 48e093b1ba | |||
| 2b3875d738 | |||
| 279d1c2f3f | |||
| 29f2839d5b | |||
| 96683b4380 | |||
| 36fbf7f29e | |||
| 67d4c216ed | |||
| 9d51cdd480 | |||
| d6679ca41f | |||
| baadd1e06b | |||
| 600ab56026 | |||
| ae40a9f541 | |||
| 48f7768b96 | |||
| de3f9fcb34 | |||
| 313009d3ff | |||
| 83ede1edea | |||
| ec50b4b3a0 | |||
| 4fbb1caaeb | |||
| 41a2529275 | |||
| 41ff71e6b0 | |||
| 2d07765c16 | |||
| 15db9fb6d9 | |||
| 73d92624de | |||
| 0d77685338 | |||
| 05bbfccd22 | |||
| 5cca89de37 | |||
| ab5b7a5b89 | |||
| 253d5623c4 | |||
| 1048192bfa | |||
| 289e6d37fa | |||
| 7308c1454f | |||
| cfc880d7c2 | |||
| c32cff8126 | |||
| 4526c0c365 | |||
| 45e7f39af0 | |||
| 26b72fbccf | |||
| 8acf3f4196 | |||
| 75c8b409cc | |||
| 6509ddb81c | |||
| 7cc0173e3c | |||
| f797d71e52 | |||
| 54db88e4b4 | |||
| 93c73c36c8 | |||
| 9c9bd4a180 | |||
| 0a54de3e59 | |||
| d6b4b59e6d | |||
| c4236d28c6 | |||
| 5f1b29f742 | |||
| e7978a22e4 | |||
| 15de86774b | |||
| 8f57647c85 | |||
| 59c5bcabd5 | |||
| cae421e140 | |||
| 05b2b09395 | |||
| 4dbd4f84c1 | |||
| bb0ca2f19d | |||
| 55175f9ac7 | |||
| 0be0f9fa34 | |||
| 7baab5d7ea | |||
| c3bdec176b | |||
| 2a84e2c270 | |||
| c86f7d466c | |||
| 9a6db7d882 | |||
| b69b1179a1 | |||
| 0df9fe750c | |||
| 0d05bfa5ff | |||
| 4dc9e04c2e | |||
| 5341a0d6b6 | |||
| 615bea9fc4 | |||
| e518bc6976 | |||
| 076f8c7a91 | |||
| b016b959cf | |||
| eca68e28a2 | |||
| 68a164aac2 | |||
| 9b5448e096 | |||
| 232d063485 | |||
| 6a357cb227 | |||
| 7b8218ab77 | |||
| 5b6436b023 | |||
| 57edbb872b | |||
| 76f82fe15e | |||
| 0b03718f4a | |||
| 2754de26d8 | |||
| abc91600e2 | |||
| 6a8a4aa4d2 | |||
| 6a6f3b1907 | |||
| 0dc8e21e07 | |||
| 68ccc02699 | |||
| ed1c18b8ac | |||
| e8cc4c7c90 | |||
| 187ebed3ab |
66
.gitea/workflows/default_nottags.yaml
Normal file
66
.gitea/workflows/default_nottags.yaml
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Default (not tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm and npmci
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
|
||||
- name: Run npm prepare
|
||||
run: npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm build
|
||||
124
.gitea/workflows/default_tags.yaml
Normal file
124
.gitea/workflows/default_tags.yaml
Normal file
@@ -0,0 +1,124 @@
|
||||
name: Default (tags)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
env:
|
||||
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
|
||||
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
|
||||
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
|
||||
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Audit production dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --prod
|
||||
continue-on-error: true
|
||||
|
||||
- name: Audit development dependencies
|
||||
run: |
|
||||
npmci command npm config set registry https://registry.npmjs.org
|
||||
npmci command pnpm audit --audit-level=high --dev
|
||||
continue-on-error: true
|
||||
|
||||
test:
|
||||
if: ${{ always() }}
|
||||
needs: security
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Test stable
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm test
|
||||
|
||||
- name: Test build
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
npmci npm build
|
||||
|
||||
release:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm publish
|
||||
|
||||
metadata:
|
||||
needs: test
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ${{ env.IMAGE }}
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install -g pnpm
|
||||
pnpm install -g @shipzone/npmci
|
||||
npmci npm prepare
|
||||
|
||||
- name: Code quality
|
||||
run: |
|
||||
npmci command npm install -g typescript
|
||||
npmci npm install
|
||||
|
||||
- name: Trigger
|
||||
run: npmci trigger
|
||||
|
||||
- name: Build docs and upload artifacts
|
||||
run: |
|
||||
npmci node install stable
|
||||
npmci npm install
|
||||
pnpm install -g @git.zone/tsdoc
|
||||
npmci command tsdoc
|
||||
continue-on-error: true
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,7 +3,6 @@
|
||||
# artifacts
|
||||
coverage/
|
||||
public/
|
||||
pages/
|
||||
|
||||
# installs
|
||||
node_modules/
|
||||
|
||||
140
.gitlab-ci.yml
140
.gitlab-ci.yml
@@ -1,140 +0,0 @@
|
||||
# gitzone ci_default
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .npmci_cache/
|
||||
key: '$CI_BUILD_STAGE'
|
||||
|
||||
stages:
|
||||
- security
|
||||
- test
|
||||
- release
|
||||
- metadata
|
||||
|
||||
before_script:
|
||||
- npm install -g @shipzone/npmci
|
||||
|
||||
# ====================
|
||||
# security stage
|
||||
# ====================
|
||||
mirror:
|
||||
stage: security
|
||||
script:
|
||||
- npmci git mirror
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
auditProductionDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci command npm install --production --ignore-scripts
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command npm audit --audit-level=high --only=prod --production
|
||||
tags:
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
auditDevDependencies:
|
||||
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
|
||||
stage: security
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci command npm install --ignore-scripts
|
||||
- npmci command npm config set registry https://registry.npmjs.org
|
||||
- npmci command npm audit --audit-level=high --only=dev
|
||||
tags:
|
||||
- docker
|
||||
allow_failure: true
|
||||
|
||||
# ====================
|
||||
# test stage
|
||||
# ====================
|
||||
|
||||
testStable:
|
||||
stage: test
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci npm test
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
testBuild:
|
||||
stage: test
|
||||
script:
|
||||
- npmci npm prepare
|
||||
- npmci node install stable
|
||||
- npmci npm install
|
||||
- npmci command npm run build
|
||||
coverage: /\d+.?\d+?\%\s*coverage/
|
||||
tags:
|
||||
- docker
|
||||
|
||||
release:
|
||||
stage: release
|
||||
script:
|
||||
- npmci node install stable
|
||||
- npmci npm publish
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
# ====================
|
||||
# metadata stage
|
||||
# ====================
|
||||
codequality:
|
||||
stage: metadata
|
||||
allow_failure: true
|
||||
only:
|
||||
- tags
|
||||
script:
|
||||
- npmci command npm install -g typescript
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- priv
|
||||
|
||||
trigger:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci trigger
|
||||
only:
|
||||
- tags
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
|
||||
pages:
|
||||
stage: metadata
|
||||
script:
|
||||
- npmci node install lts
|
||||
- npmci command npm install -g @gitzone/tsdoc
|
||||
- npmci npm prepare
|
||||
- npmci npm install
|
||||
- npmci command tsdoc
|
||||
tags:
|
||||
- lossless
|
||||
- docker
|
||||
- notpriv
|
||||
only:
|
||||
- tags
|
||||
artifacts:
|
||||
expire_in: 1 week
|
||||
paths:
|
||||
- public
|
||||
allow_failure: true
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
6
.serena/memories/done_checklist.md
Normal file
6
.serena/memories/done_checklist.md
Normal file
@@ -0,0 +1,6 @@
|
||||
Before finishing a task:
|
||||
- Run `pnpm run build` to ensure TypeScript compile + bundling succeed.
|
||||
- Verify `dist_ts_web/` and `dist_bundle/bundle.js` updated.
|
||||
- Optionally run `pnpm run test` and inspect failures.
|
||||
- Avoid changing public APIs unless required; keep changes scoped.
|
||||
- Update readme or inline docs only if user-facing behavior changes.
|
||||
11
.serena/memories/project_overview.md
Normal file
11
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,11 @@
|
||||
Project: @design.estate/dees-catalog
|
||||
Purpose: A component library of dynamic Web Components (TypeScript) for building modern web apps.
|
||||
Tech stack: TypeScript (ES2022, NodeNext), decorators, custom elements via @design.estate/dees-element (Lit-style), bundling with esbuild via @git.zone/tsbundle, TypeScript building via @git.zone/tsbuild (tsfolders), tests with @git.zone/tstest, various UI libs (tiptap, apexcharts, monaco-editor runtime via CDN), DOM helpers via @design.estate/dees-domtools.
|
||||
Structure:
|
||||
- ts_web/: source of web components and pages
|
||||
- dist_ts_web/: transpiled TS output
|
||||
- dist_bundle/: production bundle (bundle.js + map)
|
||||
- test/: tests
|
||||
- html/: static demo assets
|
||||
Key configs: tsconfig.json sets ES2022, NodeNext module/resolution, decorators enabled, skipLibCheck enabled to avoid third-party d.ts issues.
|
||||
Entrypoints: ts_web/index.ts for bundling; custom elements annotated with @customElement.
|
||||
5
.serena/memories/style_and_conventions.md
Normal file
5
.serena/memories/style_and_conventions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Language: TypeScript, ES2022 target, NodeNext module + resolution.
|
||||
Patterns: Web Components with @customElement decorators; class-based components extending DeesElement; styles via css/cssManager; template render via html tagged literal.
|
||||
Typing: Prefer explicit types where practical; tolerate `any` for external browser-injected libs (e.g., monaco) to keep build healthy.
|
||||
Config: skipLibCheck enabled to avoid third-party d.ts breakages; exclude built declaration outputs.
|
||||
Formatting/Linting: Not explicitly configured; follow existing style (2-space indents, single quotes often, semicolons present).
|
||||
6
.serena/memories/suggested_commands.md
Normal file
6
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,6 @@
|
||||
Build: pnpm run build
|
||||
Watch: pnpm run watch
|
||||
Test: pnpm run test
|
||||
Docs: pnpm run buildDocs
|
||||
Inspect bundle size: ls -lh dist_bundle/bundle.js
|
||||
Open demo (if applicable): serve static `html/` with any web server
|
||||
67
.serena/project.yml
Normal file
67
.serena/project.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "dees-catalog"
|
||||
751
changelog.md
Normal file
751
changelog.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-09-23 - 1.12.5 - fix(ci)
|
||||
Add local permissions settings for development
|
||||
|
||||
- Adds a new local settings file: .claude/settings.local.json
|
||||
- Provides explicit permission entries for development tasks (allow running pnpm scripts, reading files, searching/replacing patterns, activating project, and helper tooling)
|
||||
- Intended for local dev environment to enable tool automation without changing repository code
|
||||
|
||||
## 2025-09-20 - 1.12.4 - fix(ci)
|
||||
Add local assistant settings to enable permitted dev tooling commands
|
||||
|
||||
- Add a local assistant settings file to configure allowed development tooling commands.
|
||||
- Allows running pnpm scripts, file read/search/replace operations and other local project helper actions.
|
||||
- Local configuration only — does not change library code or public API.
|
||||
|
||||
## 2025-09-19 - 1.12.3 - fix(dees-input-fileupload)
|
||||
Show selected files inside dropzone and improve file upload UX
|
||||
|
||||
- Render the selected file list inside the dropzone container so files are displayed inline with the drop area
|
||||
- Add dropzone--has-files class and styles to visually indicate when files are present
|
||||
- Avoid opening the file selector when clicking on the browse button or inside the file list (prevents accidental re-opening)
|
||||
- Refine file list and file-row styles (sizes, paddings, border radius, hover/background behavior and thumbnail/icon sizes) for a more compact and consistent appearance
|
||||
- Simplify empty-state handling by returning an empty template when no files are present (file list is only rendered when files exist)
|
||||
|
||||
## 2025-09-18 - 1.12.2 - fix(dees-input-wysiwyg)
|
||||
Integrate output format preview into WYSIWYG demo; update plan and add local dev settings
|
||||
|
||||
- Wire output format preview into the WYSIWYG demo (ts_web/elements/dees-input-wysiwyg.demo.ts) by calling setupOutputFormatDemo(editors.meeting, editors.recipe) so HTML/Markdown preview controls are initialized.
|
||||
- Update readme.plan.md: mark the Output Formats review tasks as completed and document that preview controls were added.
|
||||
- Add a local settings file to allow running local tooling tasks (grants permission for pnpm run scripts and related local commands).
|
||||
- No library API or runtime component behavior changed — this is a demo/documentation and local-settings update.
|
||||
|
||||
## 2025-09-18 - 1.12.1 - fix(ci)
|
||||
Add local settings to allow running pnpm scripts and enable dev chat permission
|
||||
|
||||
- Add a repository-local settings file granting permission to run pnpm scripts (Bash(pnpm run:*)) for development tooling.
|
||||
- Enable the mcp__zen__chat permission for local dev workflows.
|
||||
|
||||
## 2025-09-18 - 1.12.0 - feat(dees-stepper)
|
||||
Revamp dees-stepper: modern styling, new steps and improved navigation/validation
|
||||
|
||||
- Visual refresh for dees-stepper: updated card shapes, shadows, refined borders and stronger selected-state visuals for a modern shadcn-inspired look
|
||||
- Improved transitions and animations (transform, box-shadow, filter) for smoother step selection and show/hide behavior
|
||||
- Expanded default/demo steps: replaced small sample with a richer multi-step flow (Account Setup, Profile Details, Contact Information, Team Size, Goals, Brand Preferences, Integrations, Review & Launch)
|
||||
- Enhanced step interactions: safer goNext/goBack handling with boundary checks and reset of validation flags to avoid stale validation state
|
||||
- Better toolbar/controls placement for stepper demo (spacing, counters, accessible back control) and improved keyboard/UX affordances
|
||||
- Minor documentation and meta updates: readme.plan.md extended with dees-stepper plan items and added .claude/settings.local.json
|
||||
|
||||
## 2025-09-18 - 1.11.8 - fix(ci)
|
||||
Add local tool permissions config to allow running pnpm scripts and enable mcp__zen__chat
|
||||
|
||||
- Add local settings file to grant permission to run pnpm scripts (Bash(pnpm run:*))
|
||||
- Enable mcp__zen__chat permission in local tool settings
|
||||
|
||||
## 2025-09-16 - 1.11.7 - fix(readme)
|
||||
Expand README with comprehensive component documentation, examples and developer guide; add local Claude settings
|
||||
|
||||
- Expanded README substantially: installation, component overview, detailed component docs, usage examples, demos and developer guidance
|
||||
- Updated many example snippets and API usage examples (icons, inputs, editor, forms, overlays, charts, etc.) to be more explicit and consistent
|
||||
- Added .claude/settings.local.json to configure local Claude permissions for repository tooling
|
||||
- No runtime or library code changes — documentation and demo content only
|
||||
|
||||
## 2025-09-16 - 1.11.6 - fix(dees-table)
|
||||
Improve Lucene range comparisons, pin monaco-editor to 0.52.2, and add local dev metadata
|
||||
|
||||
- Fix lucene inRange behavior to correctly compare homogeneous types (strings, numbers, dates) and fall back to string comparison when needed (ts_web/elements/dees-table/lucene.ts).
|
||||
- Pin monaco-editor to 0.52.2 in package.json to avoid a breaking upgrade regression observed with ^0.53.0.
|
||||
- Add local development/tooling metadata and conveniences: .claude/settings.local.json (tool permissions) and .serena/ memory files (done_checklist, project_overview, style_and_conventions, suggested_commands).
|
||||
- Minor housekeeping: update project dev docs / memories to capture build/test/checklist guidance.
|
||||
|
||||
## 2025-09-16 - 1.11.5 - fix(ci)
|
||||
Add local Claude agent settings for CI tooling
|
||||
|
||||
- Add .claude/settings.local.json to configure local Claude agent permissions
|
||||
- Allow Bash commands matching pnpm run:* and the mcp__zen__chat permission for development tooling
|
||||
|
||||
## 2025-09-10 - 1.11.4 - fix(readme)
|
||||
Rewrite and expand README with Quick Start, feature highlights, demos and usage examples; add local Claude settings file
|
||||
|
||||
- Completely rewritten and reorganized README: added Quick Start, component highlights, usage examples, demos, development workflow, troubleshooting and links.
|
||||
- Added .claude/settings.local.json with local Claude permission configuration.
|
||||
|
||||
## 2025-09-08 - 1.11.3 - fix(dees-input-list)
|
||||
Prevent list animations from affecting scroll bounds and fix content-visibility issues in dees-input-list; add local developer settings
|
||||
|
||||
- dees-input-list: add overflow:hidden to list items to prevent animations from altering scroll bounds and causing visual/scroll glitches
|
||||
- dees-input-list: force content-visibility/contain to visible/none to avoid unexpected scrolling/layout issues when items animate
|
||||
- Add .claude/settings.local.json with local developer permissions (allows running pnpm scripts via Claude-local tooling)
|
||||
|
||||
## 2025-09-07 - 1.11.2 - fix(DeesFormSubmit)
|
||||
Make form submit robust by locating nearest dees-form via closest(); add local CLAUDE settings
|
||||
|
||||
- Fix: DeesFormSubmit.submit now walks up the DOM with closest('dees-form') to find and call gatherAndDispatch on the parent form. This fixes cases where the submit button is slotted or not a direct child of the form.
|
||||
- Chore: Add .claude/settings.local.json to permit running pnpm scripts in the local CLAUDE environment (allows Bash(pnpm run:*)).
|
||||
|
||||
## 2025-09-06 - 1.11.1 - fix(dees-input-text)
|
||||
Normalize Lucide icon names for password toggle
|
||||
|
||||
- Updated password visibility toggle icons in dees-input-text from 'lucide:eye'/'lucide:eye-off' to 'lucide:Eye'/'lucide:EyeOff' to match Lucide exports and avoid missing icon rendering.
|
||||
|
||||
## 2025-09-05 - 1.11.0 - feat(dees-icon)
|
||||
Add full icon list and improve dees-icon demo with copy-all functionality and UI tweaks
|
||||
|
||||
- Added readme.icons.md containing 1900+ icon identifiers (FontAwesome + Lucide) for easy reference and tooling
|
||||
- Enhanced ts_web/elements/dees-icon.demo.ts: added a 'Copy All Icon Names' button that copies prefixed icon names (fa:..., lucide:...) to the clipboard and shows temporary feedback
|
||||
- Updated demo presentation: prefixed displayed icon names (fa: / lucide:), improved search-container spacing and added button styling for better UX
|
||||
- Changes are documentation/demo only — no production runtime component logic changed
|
||||
|
||||
## 2025-09-05 - 1.10.12 - fix(dees-simple-appdash)
|
||||
Fix icon rendering in dees-simple-appdash to respect provided icon strings
|
||||
|
||||
- dees-simple-appdash: stop forcing a 'lucide:' prefix when rendering view icons — use the icon string as provided.
|
||||
- Prevents incorrect/missing icons when the iconName already includes a library prefix (e.g. 'fa:' or 'lucide:').
|
||||
|
||||
## 2025-09-05 - 1.10.11 - fix(dees-simple-appdash)
|
||||
Bump deps and fix dees-simple-appdash icon binding and terminal sizing
|
||||
|
||||
- Updated runtime dependencies: @design.estate/dees-element -> ^2.1.2, @design.estate/dees-wcctools -> ^1.1.1, @fortawesome/* -> ^7.0.1, apexcharts -> ^5.3.4, lucide -> ^0.542.0 (compatibility/security/stability updates)
|
||||
- Updated dev tooling: @git.zone/tsbuild -> ^2.6.8, @git.zone/tstest -> ^2.3.6, @git.zone/tswatch -> ^2.2.1
|
||||
- Fix: dees-simple-appdash — use proper string interpolation for lucide icon properties (prevents incorrect icon rendering)
|
||||
- Fix: dees-simple-appdash — enforce terminal maxWidth/maxHeight to avoid overflow and improve layout stability
|
||||
- Cosmetic: small style/behavior tweaks to dees-simple-appdash (logout/terminal/wifi icon bindings corrected)
|
||||
|
||||
## 2025-06-29 - 1.10.10 - improve(dees-dashboardgrid, dees-input-wysiwyg)
|
||||
Enhanced dashboard grid component with advanced spacing and layout features inspired by gridstack.js
|
||||
|
||||
Dashboard Grid improvements:
|
||||
- Improved margin system supporting uniform or individual margins (top, right, bottom, left)
|
||||
- Added collision detection to prevent widget overlap during drag operations
|
||||
- Implemented auto-positioning for new widgets to find first available space
|
||||
- Added compact() method to eliminate gaps and compress layout vertically or horizontally
|
||||
- Enhanced resize constraints with minW, maxW, minH, maxH support
|
||||
- Added optional grid lines visualization for better layout understanding
|
||||
- Improved resize handles with better visibility and hover states
|
||||
- Added RTL (right-to-left) layout support
|
||||
- Implemented cellHeightUnit option supporting 'px', 'em', 'rem', or 'auto' (square cells)
|
||||
- Added configurable animation with enableAnimation property
|
||||
- Enhanced demo with interactive controls for testing all features
|
||||
- Better calculation of widget positions accounting for margins between cells
|
||||
- Added findAvailablePosition() for intelligent widget placement
|
||||
- Improved drag and resize calculations for pixel-perfect positioning
|
||||
|
||||
WYSIWYG editor drag and drop fixes:
|
||||
- Fixed drop indicator positioning to properly account for block margins
|
||||
- Added defensive checks in drag event handlers to prevent potential crashes
|
||||
- Improved updateBlockPositions with null checks and error handling
|
||||
- Updated drop indicator calculation to use simplified margin approach
|
||||
- Fixed drop indicator height to match the exact space occupied by dragged blocks
|
||||
- Improved drop indicator positioning algorithm to accurately show where blocks will land
|
||||
- Simplified visual block position calculations accounting for CSS transforms
|
||||
- Enhanced margin calculation to use correct values based on block type (16px for paragraphs, 24px for headings, 20px for code/quotes)
|
||||
- Fixed index calculation issue when dragging blocks downward by adjusting target index for excluded dragged block
|
||||
|
||||
## 2025-06-28 - 1.10.9 - feat(dees-dashboardgrid)
|
||||
Add new dashboard grid component with drag-and-drop and resize capabilities
|
||||
|
||||
- Created dees-dashboardgrid component for building flexible dashboard layouts
|
||||
- Features drag-and-drop functionality for rearranging widgets
|
||||
- Includes resize handles for adjusting widget dimensions
|
||||
- Supports configurable grid properties (columns, cell height, gap)
|
||||
- Provides widget locking and editable mode controls
|
||||
- Styled with shadcn design principles
|
||||
- No external dependencies - built with native browser APIs
|
||||
- Emits events for widget movements and resizes
|
||||
- Includes comprehensive demo with sample dashboard widgets
|
||||
|
||||
## 2025-06-27 - 1.10.8 - feat(ui-components)
|
||||
Update multiple components with shadcn-aligned styling and improved animations
|
||||
|
||||
- Updated dees-modal with shadcn colors, borders, and subtle shadows
|
||||
- Updated dees-chips with shadcn styling and fixed selection logic bug
|
||||
- Updated dees-dataview-codebox with shadcn syntax highlighting colors and responsive label layout
|
||||
- Updated dees-input-multitoggle with transparent blue indicator and smooth animations
|
||||
- Updated dees-appui-tabs with animated sliding indicator for both horizontal and vertical layouts
|
||||
- Fixed indicator positioning to be perfectly centered on tab content
|
||||
- Indicator width is content width + 8px for minimal visual padding
|
||||
- Fixed tab content centering by using consistent padding (12px → 16px on all sides)
|
||||
- Fixed icon rendering by correcting property name from .iconName to .icon
|
||||
- Added visual separators between tabs for better distinction
|
||||
- Added subtle hover backgrounds for improved interactivity
|
||||
- Refactored tabs component code for better maintainability and elegance
|
||||
- Updated dees-appui-activitylog with shadcn-aligned styling:
|
||||
- Updated background and text colors to match shadcn palette
|
||||
- Enhanced topbar with better spacing and typography
|
||||
- Improved activity entries with subtle hover states and better spacing
|
||||
- Added activity type icons with color-coded backgrounds (login, logout, view, create, update)
|
||||
- Added date separators ("Today", "Yesterday") for better temporal organization
|
||||
- Enhanced streaming indicators with animated pulse effect
|
||||
- Redesigned searchbox with modern input styling, search icon, and focus states
|
||||
- Added custom scrollbar styling for consistency
|
||||
- Updated timestamps to be more subtle with tabular number formatting
|
||||
- Refined shadow effects for better visual hierarchy
|
||||
- Added subtle box shadow to component for depth
|
||||
- Added fade-in animation for new activity entries
|
||||
- Improved user name highlighting with better typography
|
||||
- Updated context menu with more relevant actions
|
||||
- Improved overall spacing and visual consistency across components
|
||||
|
||||
## 2025-06-27 - 1.10.1 - fix(modal)
|
||||
Improve modal overscroll behavior by adding 'overscroll-behavior: contain' to content container
|
||||
|
||||
- Added 'overscroll-behavior: contain' to .modal .content to ensure proper scroll containment
|
||||
- Applied overscroll-behavior in modal container for enhanced responsiveness on mobile and desktop
|
||||
|
||||
## 2025-06-26 - 1.10.0 - feat(dees-modal)
|
||||
Add mobileFullscreen option to modals for full-screen mobile support
|
||||
|
||||
- Introduced a new boolean property 'mobileFullscreen' in ts_web/elements/dees-modal.ts
|
||||
- Updated modal CSS under the media query to apply 'mobile-fullscreen' class, allowing full viewport modals on mobile devices
|
||||
- Extended modal style rules to include adjustments for margin, border-radius, and maximum heights on smaller screens
|
||||
|
||||
## 2025-06-26 - 1.9.9 - fix(dees-input-multitoggle, dees-input-typelist)
|
||||
Replace dynamic import with static import for demo functions in dees-input-multitoggle and dees-input-typelist
|
||||
|
||||
- Converted `await import('./dees-input-multitoggle.demo.js')` to a direct static import.
|
||||
- Converted `await import('./dees-input-typelist.demo.js')` to a direct static import to improve build performance and clarity.
|
||||
|
||||
## 2025-06-26 - 1.9.8 - fix(deps, windowlayer)
|
||||
Update dependency versions and adjust dees-windowlayer CSS to add pointer-events fix
|
||||
|
||||
- Bump @design.estate/dees-wcctools from ^1.0.98 to ^1.0.101
|
||||
- Bump @tiptap packages from 2.22.3 to 2.23.0
|
||||
- Bump lucide from ^0.522.0 to ^0.523.0
|
||||
- Bump @git.zone/tsbundle from ^2.4.0 to ^2.5.1 and tswatch from ^2.0.37 to ^2.1.2
|
||||
- Add 'pointer-events: none' to dees-windowlayer CSS to improve overlay behavior
|
||||
|
||||
## 2025-06-22 - 1.9.0 - feat(form-inputs)
|
||||
Improve form input consistency and auto spacing across inputs and buttons
|
||||
|
||||
- Add an 'insideForm' property to dees-button for auto-detection and proper margin adjustment in forms.
|
||||
- Update dees-input-radio to include a 'name' property so that radio buttons in the same group are mutually exclusive.
|
||||
- Enhance dees-form to group radio inputs properly when collecting form data.
|
||||
- Revise readme.hints.md and readme.plan.md to document changes and provide guidance for dees-input-radio.
|
||||
- Update demos for dees-button and dees-form to showcase correct spacing in vertical and horizontal layouts.
|
||||
|
||||
## 2025-06-20 - 1.8.20 - fix(deps)
|
||||
Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0
|
||||
|
||||
- Upgrade @design.estate/dees-domtools from ^2.1.1 to ^2.3.3
|
||||
- Upgrade @design.estate/dees-element from ^2.0.42 to ^2.0.44
|
||||
- Upgrade lucide from ^0.515.0 to ^0.518.0
|
||||
- Upgrade @git.zone/tsbundle from ^2.0.15 to ^2.4.0
|
||||
|
||||
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
|
||||
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.
|
||||
|
||||
- Center-align tile header elements by setting align-items to center and ensuring full width.
|
||||
- Increase tile content height to 90px and center its content.
|
||||
- Update gauge visualization: reduce circle radius from 40 to 30, adjust stroke dasharray (from 251.2 to 188.5), and decrease gauge text font size.
|
||||
- Refine trend chart layout: set trend-svg height to 40px, center trend value and adjust typography to larger, bolder text.
|
||||
- Ensure overall grid responsiveness with adjusted gap and column sizing.
|
||||
|
||||
## 2025-04-25 - 1.8.0 - feat(dees-pagination)
|
||||
Add new pagination component to the library along with its demo and integration in the main export.
|
||||
|
||||
- Introduced dees-pagination component with support for various page range scenarios.
|
||||
- Created demo file to showcase pagination with both small and large sets of pages.
|
||||
- Updated the module's index to export the new pagination component.
|
||||
|
||||
## 2025-04-22 - 1.7.0 - feat(dees-searchbar)
|
||||
Add dees-searchbar component with live search and filter demo
|
||||
|
||||
- Introduces a new dees-searchbar element with an input field, a search button, and filters
|
||||
- Wires up events for 'search-changed' and 'search-submit' to provide real‐time feedback
|
||||
- Adds a demo file to showcase usage and logging of search events
|
||||
|
||||
## 2025-04-22 - 1.6.0 - feat(documentation/dees-heading)
|
||||
Add codex documentation overview and dees-heading component demo
|
||||
|
||||
- Introduce contributor overview doc (`codex.md`, now consolidated into `readme.info.md`) to provide a high-level overview of project layout, component patterns, and build workflow
|
||||
- Add and update dees-heading component with demo to support multiple heading levels and horizontal rule styles
|
||||
- Update component export index to include dees-heading
|
||||
|
||||
## 2025-04-18 - 1.5.6 - fix(dependencies)
|
||||
Bump dependency versions and update demo code references
|
||||
|
||||
- Upgrade @design.estate/dees-element from ^2.0.39 to ^2.0.41
|
||||
- Upgrade @tsclass/tsclass from ^4.4.0 to ^9.0.0
|
||||
- Upgrade lucide from ^0.488.0 to ^0.501.0
|
||||
- Update @types/node from ^22.10.7 to ^22.14.1
|
||||
- Update dees-icon demo: scope search to demo container and adjust hover scaling
|
||||
- Replace resolveExec with directives.resolveExec in dees-table for proper rendering
|
||||
|
||||
## 2025-04-12 - 1.5.5 - fix(catalog)
|
||||
No code or documentation changes were detected. This commit records an empty update in commit information and confirms that the current state remains stable.
|
||||
|
||||
- Verified that there are no modifications in source, documentation, or demos
|
||||
- Commit metadata and build configuration remain unchanged
|
||||
|
||||
## 2025-04-11 - 1.5.4 - fix(readme)
|
||||
Update readme with company and trademark guidelines, clarifying legal usage without exposing licensing details.
|
||||
|
||||
- Added sections detailing company information and trademark guidelines.
|
||||
- Outlined legal disclaimers for trademark usage.
|
||||
|
||||
## 2025-04-11 - 1.5.3 - fix(readme)
|
||||
Update readme.md: remove redundant usage section and refine component documentation with improved examples.
|
||||
|
||||
- Removed the standalone manual import and usage example for components.
|
||||
- Added refined examples demonstrating both basic and option-based usage (e.g. for DeesButton).
|
||||
- Improved markdown formatting and consistency across component documentation.
|
||||
|
||||
## 2025-04-11 - 1.5.3 - fix(readme)
|
||||
Update readme.md for clearer documentation: removed redundant 'Usage' section and refined component examples (e.g., DeesButton's basic and options usage) for improved clarity and consistency.
|
||||
|
||||
- Removed standalone usage example showing manual import and creation of components
|
||||
- Added refined examples demonstrating both basic and option-based usage of components
|
||||
- Improved overall readme formatting and consistency across component documentation
|
||||
|
||||
## 2025-04-11 - 1.5.2 - fix(ci)
|
||||
Remove obsolete GitLab CI configuration file
|
||||
|
||||
- Deleted .gitlab-ci.yml as the CI pipeline configuration is now managed elsewhere.
|
||||
- Cleaned up CI stages for security, testing, release, and metadata.
|
||||
|
||||
## 2025-04-11 - 1.5.1 - fix(readme)
|
||||
Update readme with comprehensive reference documentation: add a usage snippet for components like DeesButton, introduce a detailed overview table of all component categories, and enhance documentation sections for each component group.
|
||||
|
||||
- Added a code example showing how to import and use DeesButton.
|
||||
- Introduced a components overview table that categorizes Core UI, Forms, Layout, Data Display, Visualization, Dialogs & Overlays, Navigation, and Development components.
|
||||
- Expanded detailed documentation with usage examples for each component type.
|
||||
- Reorganized content to improve clarity and ease of navigation for developers.
|
||||
|
||||
## 2025-04-11 - 1.5.0 - feat(badge)
|
||||
Add dees-badge component with demo file and update packageManager field in package.json
|
||||
|
||||
- Introduce a new badge component allowing different types (default, primary, success, warning, error) with an optional rounded style
|
||||
- Provide a demo for the badge component
|
||||
- Export the badge component in the main elements index
|
||||
- Update package.json to include an explicit packageManager field
|
||||
|
||||
## 2025-01-20 - 1.4.1 - fix(dependencies)
|
||||
Update dependency versions for smartpromise, webcontainer/api, tapbundle, and @types/node
|
||||
|
||||
- Update @push.rocks/smartpromise to version ^4.2.0
|
||||
- Downgrade @webcontainer/api to version 1.2.0
|
||||
- Update @push.rocks/tapbundle to version ^5.5.6
|
||||
- Update @types/node to version ^22.10.7
|
||||
|
||||
## 2025-01-20 - 1.4.0 - feat(dees-terminal)
|
||||
Enhanced the dees-terminal component to support environment variable settings and improved setup command execution.
|
||||
|
||||
- Added environment property to pass custom environment variables.
|
||||
- Introduced webcontainerDeferred to handle the promise for web container creation.
|
||||
- Enhanced demo to illustrate environment variable usage.
|
||||
- Improved async interaction with the terminal for setting environment variables and executing setup commands.
|
||||
|
||||
## 2025-01-15 - 1.3.4 - fix(chart)
|
||||
Fix chart rendering and appearance issues in the DeesChartArea component.
|
||||
|
||||
- Resolved issues with chart dimensions calculation based on padding.
|
||||
- Adjusted grid and axis lines appearance for better visibility.
|
||||
- Updated tooltip and grid line styling for better accessibility.
|
||||
- Improved series data representation as time-series for more accurate display.
|
||||
|
||||
## 2024-12-17 - 1.3.3 - fix(dees-input-multitoggle)
|
||||
Add missing TypeScript declaration for dees-input-multitoggle
|
||||
|
||||
- Added a missing declaration to the HTMLElementTagNameMap for 'dees-input-multitoggle' element.
|
||||
|
||||
## 2024-12-09 - 1.3.2 - fix(metadata)
|
||||
Updated package metadata and readme for better project description and structure.
|
||||
|
||||
- Updated package.json and npmextra.json with a detailed project description and list of keywords.
|
||||
- Enhanced readme.md with installation instructions, component usage examples, and detailed component descriptions for clarity.
|
||||
|
||||
## 2024-11-07 - 1.3.1 - fix(DeesSimpleAppDash)
|
||||
Fix: add border to controlbar in DeesSimpleAppDash
|
||||
|
||||
- Fixed the missing border at the top of the controlbar in DeesSimpleAppDash.
|
||||
|
||||
## 2024-11-07 - 1.3.0 - feat(dees-simple-appdash)
|
||||
Enhance responsive styling and terminal setup command
|
||||
|
||||
- Added a new property `terminalSetupCommand` for configuring terminal setup commands.
|
||||
- Improved responsive styling and positioning for components to achieve a fluid layout.
|
||||
- Fixed layout shifts by switching positions to `absolute` for `appbar` and `appcontent`.
|
||||
|
||||
## 2024-10-07 - 1.2.0 - feat(index.ts)
|
||||
Add export for colors module in index.ts
|
||||
|
||||
- The index.ts file now exports the colors module, making color utilities available for external use.
|
||||
|
||||
## 2024-10-06 - 1.1.13 - fix(dees-button)
|
||||
Fix styling issue in button component.
|
||||
|
||||
- Moved the .button.disabled styling block to its correct position after the .button.highlighted block.
|
||||
|
||||
## 2024-10-06 - 1.1.12 - fix(dees-button)
|
||||
Fix reflect attribute for disabled property on dees-button component
|
||||
|
||||
- Added reflect: true to the 'disabled' property ensuring changes reflect in the DOM attribute.
|
||||
|
||||
## 2024-10-05 - 1.1.11 - fix(DeesStepper)
|
||||
Adjusted CSS properties in DeesStepper component
|
||||
|
||||
- Increased border-radius from 8px to 16px for step container elements
|
||||
- Adjusted font-size and font-weight for the title in the step container to improve readability
|
||||
|
||||
## 2024-10-04 - 1.1.10 - fix(dependencies)
|
||||
Reverted @webcontainer/api version
|
||||
|
||||
- Changed @webcontainer/api version from ^1.3.0 to 1.2.0 in package.json
|
||||
|
||||
## 2024-10-04 - 1.1.9 - fix(dependencies)
|
||||
Update various dependencies for compatibility and stability.
|
||||
|
||||
- Update @design.estate/dees-domtools to version ^2.0.61
|
||||
- Update @design.estate/dees-element to version ^2.0.39
|
||||
- Update @webcontainer/api to version ^1.3.0
|
||||
- Update apexcharts to version ^3.54.0
|
||||
- Update monaco-editor to version ^0.52.0
|
||||
- Update pdfjs-dist to version ^4.6.82
|
||||
- Update @push.rocks/tapbundle to version ^5.3.0
|
||||
- Update @types/node to version ^22.7.4
|
||||
|
||||
## 2024-09-02 - 1.1.8 - fix(dees-simple-appdash)
|
||||
Corrected viewTab active background color
|
||||
|
||||
- Corrected the background color of the viewTab in active state for better visual consistency.
|
||||
|
||||
## 2024-09-02 - 1.1.7 - fix(dependencies)
|
||||
Update dependencies to their latest versions
|
||||
|
||||
- Update dependency versions in package.json:
|
||||
- - @design.estate/dees-element from ^2.0.34 to ^2.0.36
|
||||
- - @fortawesome/fontawesome-svg-core from ^6.5.2 to ^6.6.0
|
||||
- - @fortawesome/free-brands-svg-icons from ^6.5.2 to ^6.6.0
|
||||
- - @fortawesome/free-regular-svg-icons from ^6.5.2 to ^6.6.0
|
||||
- - @fortawesome/free-solid-svg-icons from ^6.5.2 to ^6.6.0
|
||||
- - @tsclass/tsclass from ^4.0.63 to ^4.1.2
|
||||
- - apexcharts from ^3.49.2 to ^3.53.0
|
||||
- - highlight.js from 11.9.0 to 11.10.0
|
||||
- - monaco-editor from ^0.50.0 to ^0.51.0
|
||||
- - pdfjs-dist from ^4.3.136 to ^4.5.136
|
||||
- - @git.zone/tsbuild from ^2.1.82 to ^2.1.84
|
||||
- - @push.rocks/tapbundle from ^5.0.23 to ^5.0.24
|
||||
- - @types/node from ^20.14.9 to ^22.5.2
|
||||
|
||||
## 2024-07-01 - 1.1.6 - fix(dees-dataview-codebox)
|
||||
Corrected the font-family order for better font rendering.
|
||||
|
||||
- Corrected the font-family order in dees-dataview-codebox.ts to ensure 'Intel One Mono' is prioritized over 'Geist Mono'.
|
||||
|
||||
## 2024-07-01 - 1.1.5 - fix(dees-dataview-codebox)
|
||||
Adjusted line number font weight in codebox
|
||||
|
||||
- Changed the line number font weight from 400 to 200 in the codebox for better visual alignment.
|
||||
|
||||
## 2024-07-01 - 1.1.4 - fix(UI)
|
||||
Fixed font-family order for code and value elements
|
||||
|
||||
- Updated font-family order in dees-dataview-codebox.ts
|
||||
- Updated font-family order in dees-dataview-statusobject.ts
|
||||
|
||||
## 2024-07-01 - 1.1.3 - fix(dees-dataview-codebox)
|
||||
Adjusted codebox font weight and font family.
|
||||
|
||||
- Changed font weight from 200 to 400 for better readability.
|
||||
- Updated font-family to include 'Geist Mono' for uniform appearance.
|
||||
|
||||
## 2024-06-30 - 1.1.2 - fix(elements)
|
||||
Fix various UI components and improve styles
|
||||
|
||||
- Updated styles for multiple components for better appearance in dark mode.
|
||||
- Added missing event handling in `dees-input-fileupload` component.
|
||||
- Implemented tooltip support in `dees-label` component.
|
||||
- Fixed alignment issues in `dees-appui-*` components.
|
||||
- Resolved various accessibility issues in form elements.
|
||||
|
||||
## 2024-06-30 - 1.1.1 - fix(build)
|
||||
Fix build script by updating source folders.
|
||||
|
||||
- Updated build script to correctly reference 'tsfolders' folder.
|
||||
|
||||
## 2024-06-30 - 1.1.0 - feat(project dependencies)
|
||||
Updated various dependencies and internal fonts
|
||||
|
||||
- Updated dependencies in package.json with the latest versions.
|
||||
- Updated font-family references from 'Roboto' and 'Inter' to 'Geist Sans' across multiple components.
|
||||
|
||||
## 2024-04-20 - 1.0.289 - Documentation Update
|
||||
Updated the project documentation.
|
||||
|
||||
## 2024-02-05 to 2024-02-13 - 1.0.277 to 1.0.288 - Core Fixes
|
||||
Series of core updates and patches.
|
||||
|
||||
## 2024-01-22 to 2024-02-05 - 1.0.257 to 1.0.276 - Core Fixes
|
||||
More core updates and patches.
|
||||
|
||||
## 2024-01-21 - 1.0.256 to 1.0.257 - Core Fixes
|
||||
Minor bugfixes for core functionalities.
|
||||
|
||||
## 2024-01-18 - 1.0.246 - Core Fixes
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2024-01-15 - 1.0.242 to 1.0.245 - Core Fixes
|
||||
Various core bugfixes.
|
||||
|
||||
## 2024-01-11 - 1.0.241 to 1.0.242 - Core Fixes
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2024-01-10 - 1.0.240 to 1.0.241 - Core Fixes
|
||||
Minor core fixes and improvements.
|
||||
|
||||
## 2024-01-09 - 1.0.239 to 1.0.240 - Core Fixes
|
||||
Core updates and patches.
|
||||
|
||||
## 2023-12-26 - 1.0.238 to 1.0.239 - Core Fixes
|
||||
Updates to core functionalities.
|
||||
|
||||
## 2023-12-20 - 1.0.236 to 1.0.238 - Core Fixes
|
||||
Bugfixes and updates for core functionalities.
|
||||
|
||||
## 2023-12-08 - 1.0.233 to 1.0.236 - Core Fixes
|
||||
Multiple updates to core functionalities.
|
||||
|
||||
## 2023-11-29 - 1.0.231 to 1.0.232 - Core Fixes
|
||||
Minor core patches.
|
||||
|
||||
## 2023-10-31 - 1.0.229 to 1.0.230 - Core Fixes
|
||||
Various updates and fixes for core.
|
||||
|
||||
## 2023-10-24 - 1.0.228 to 1.0.229 - Core Fixes
|
||||
More core updates and patches.
|
||||
|
||||
## 2023-10-23 - 1.0.226 to 1.0.228 - Core Fixes
|
||||
Series of updates and fixes for core functionalities.
|
||||
|
||||
## 2023-10-20 - 1.0.223 to 1.0.225 - Core Fixes
|
||||
Minor updates and fixes for core.
|
||||
|
||||
## 2023-10-18 - 1.0.222 to 1.0.223 - Core Fixes
|
||||
Core patches and bugfixes.
|
||||
|
||||
## 2023-10-17 - 1.0.221 to 1.0.222 - Core Fixes
|
||||
Further updates to core functionalities.
|
||||
|
||||
## 2023-10-12 - 1.0.220 to 1.0.221 - Core Fixes
|
||||
Minor core updates.
|
||||
|
||||
## 2023-10-11 - 1.0.219 to 1.0.220 - Core Fixes
|
||||
Bugfixes and updates for core.
|
||||
|
||||
## 2023-10-07 - 1.0.217 to 1.0.219 - Core Fixes
|
||||
Series of core updates and patches.
|
||||
|
||||
## 2023-10-05 - 1.0.216 to 1.0.217 - Core Fixes
|
||||
Minor updates for core functionalities.
|
||||
|
||||
## 2023-09-22 - 1.0.212 to 1.0.215 - Core Fixes
|
||||
Various updates and fixes for core.
|
||||
|
||||
## 2023-09-20 - 1.0.210 to 1.0.211 - Core Fixes
|
||||
Series of core patches.
|
||||
|
||||
## 2023-09-18 - 1.0.209 to 1.0.210 - Core Fixes
|
||||
Bugfixes and improvements to core.
|
||||
|
||||
## 2023-09-17 - 1.0.207 to 1.0.209 - Core Fixes
|
||||
Minor updates and fixes.
|
||||
|
||||
## 2023-09-15 - 1.0.203 to 1.0.206 - Core Fixes
|
||||
Series of core improvements and fixes.
|
||||
|
||||
## 2023-09-14 - 1.0.199 to 1.0.202 - Core Fixes
|
||||
Patches for core functionalities.
|
||||
|
||||
## 2023-09-13 - 1.0.194 to 1.0.198 - Core Fixes
|
||||
Various bugfixes and updates for core.
|
||||
|
||||
## 2023-09-12 - 1.0.190 to 1.0.193 - Core Fixes
|
||||
Minor core patches.
|
||||
|
||||
## 2023-09-09 - 1.0.188 to 1.0.189 - Core Fixes
|
||||
Updates and bugfixes for core.
|
||||
|
||||
## 2023-09-07 - 1.0.186 to 1.0.187 - Core Fixes
|
||||
Core patches and improvements.
|
||||
|
||||
## 2023-09-04 - 1.0.184 to 1.0.185 - Core Fixes
|
||||
Series of updates and bugfixes for core.
|
||||
|
||||
## 2023-09-01 - 1.0.180 to 1.0.183 - Core Fixes
|
||||
Various core updates and fixes.
|
||||
|
||||
## 2023-08-28 - 1.0.177 to 1.0.179 - Core Fixes
|
||||
Minor bugfixes and updates.
|
||||
|
||||
## 2023-08-26 - 1.0.176 to 1.0.177 - Core Fixes
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2023-08-20 - 1.0.173 to 1.0.175 - Core Fixes
|
||||
Series of updates and fixes for core.
|
||||
|
||||
## 2023-08-08 - 1.0.172 to 1.0.173 - Core Fixes
|
||||
Minor core updates.
|
||||
|
||||
## 2023-08-07 - 1.0.171 to 1.0.172 - Core Fixes
|
||||
Various updates to core.
|
||||
|
||||
## 2023-04-12 - 1.0.169 to 1.0.170 - Core Fixes
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2023-04-10 - 1.0.163 to 1.0.168 - Core Fixes
|
||||
Series of core updates and patches.
|
||||
|
||||
## 2023-04-07 - 1.0.160 to 1.0.162 - Core Fixes
|
||||
Various updates for core functionalities.
|
||||
|
||||
## 2023-04-06 - 1.0.155 to 1.0.159 - Core Fixes
|
||||
Bugfixes and improvements to core.
|
||||
|
||||
## 2023-03-27 - 1.0.150 to 1.0.154 - Core Fixes
|
||||
Minor updates and patches for core functionalities.
|
||||
|
||||
## 2023-03-09 - 1.0.149 to 1.0.150 - Core Fixes
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2023-01-17 - 1.0.146 to 1.0.148 - Core Fixes
|
||||
Updates and fixes for core.
|
||||
|
||||
## 2023-01-16 - 1.0.144 to 1.0.145 - Core Fixes
|
||||
Series of updates and bugfixes for core.
|
||||
|
||||
## 2023-01-13 - 1.0.134 to 1.0.143 - Core Fixes
|
||||
Various core patches and improvements.
|
||||
|
||||
## 2023-01-12 - 1.0.132 to 1.0.133 - Core Fixes
|
||||
Minor fixes for core functionalities.
|
||||
|
||||
## 2023-01-11 - 1.0.126 to 1.0.131 - Core Fixes
|
||||
Series of updates and fixes for core.
|
||||
|
||||
## 2023-01-09 - 1.0.122 to 1.0.123 - Core Fixes
|
||||
Minor core patches and updates.
|
||||
|
||||
## 2023-01-07 - 1.0.113 to 1.0.121 - Core Fixes
|
||||
Updates and bugfixes for core functionalities.
|
||||
|
||||
## 2023-01-03 - 1.0.107 to 1.0.110 - Core Fixes
|
||||
Series of core updates and fixes.
|
||||
|
||||
## 2022-12-12 - 1.0.105 to 1.0.106 - Core Fixes
|
||||
Various core patches.
|
||||
|
||||
## 2022-12-07 - 1.0.104 to 1.0.105 - Core Fixes
|
||||
Bugfixes and improvements to core functionalities.
|
||||
|
||||
## 2022-12-06 - 1.0.103 to 1.0.104 - Core Fixes
|
||||
Series of updates and patches for core.
|
||||
|
||||
## 2022-08-18 - 1.0.98 to 1.0.102 - Core Fixes
|
||||
Updates and bugfixes for core functionalities.
|
||||
|
||||
## 2022-07-14 - 1.0.93 to 1.0.94 - Core Fixes
|
||||
Minor core updates.
|
||||
|
||||
## 2022-06-26 - 1.0.92 to 1.0.93 - Core Fixes
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2022-06-10 - 1.0.91 to 1.0.92 - Core Fixes
|
||||
Updates and patches for core.
|
||||
|
||||
## 2022-05-30 - 1.0.90 to 1.0.91 - Core Fixes
|
||||
Minor updates and bugfixes.
|
||||
|
||||
## 2022-05-26 - 1.0.87 to 1.0.89 - Core Fixes
|
||||
Series of core updates and patches.
|
||||
|
||||
## 2022-05-24 to 2022-05-20 - 1.0.83 to 1.0.86 - Core Fixes
|
||||
Various updates and bugfixes for core functionalities.
|
||||
|
||||
## 2022-05-03 - 1.0.79 to 1.0.80 - Core Fixes
|
||||
Updates and patches for core functionalities.
|
||||
|
||||
## 2022-03-18 - 1.0.74 to 1.0.77 - Core Fixes
|
||||
Bugfixes and updates for core functionalities.
|
||||
|
||||
## 2022-01-24 - 1.0.73 to 1.0.74 - Core Fixes
|
||||
Minor core patches.
|
||||
|
||||
## 2021-12-10 - 1.0.71 to 1.0.72 - Core Fixes
|
||||
Series of updates and fixes for core functionalities.
|
||||
|
||||
## 2021-11-27 - 1.0.70 to 1.0.71 - Core Fixes
|
||||
Bugfixes and improvements to core.
|
||||
|
||||
## 2021-10-08 - 1.0.68 to 1.0.69 - Core Fixes
|
||||
Various core updates and patches.
|
||||
|
||||
## 2021-10-07 - 1.0.67 to 1.0.68 - Core Fixes
|
||||
Minor updates and improvements for core.
|
||||
|
||||
## 2021-09-15 - 1.0.64 to 1.0.66 - Core Fixes
|
||||
Bugfixes and improvements to core functionalities.
|
||||
|
||||
## 2021-09-14 - 1.0.61 to 1.0.62 - Core Fixes
|
||||
Minor patches and updates for core.
|
||||
|
||||
## 2021-09-10 - 1.0.57 to 1.0.60 - Core Fixes
|
||||
Series of updates and fixes for core functionalities.
|
||||
|
||||
## 2021-09-09 - 1.0.54 to 1.0.56 - Core Fixes
|
||||
Various core updates and patches.
|
||||
|
||||
## 2021-09-01 - 1.0.51 to 1.0.53 - Core Fixes
|
||||
Minor updates for core functionalities.
|
||||
|
||||
## 2021-08-29 - 1.0.49 to 1.0.50 - Core Fixes
|
||||
Bugfixes and patches for core.
|
||||
|
||||
## 2021-08-27 - 1.0.44 to 1.0.48 - Core Fixes
|
||||
Series of updates and improvements for core functionalities.
|
||||
|
||||
## 2021-08-26 - 1.0.42 to 1.0.43 - Core Fixes
|
||||
Minor updates and bugfixes for core.
|
||||
|
||||
## 2021-08-25 - 1.0.38 to 1.0.41 - Core Fixes
|
||||
Extracted series of core updates and patches.
|
||||
|
||||
## 2021-08-24 - 1.0.37 - Core Fix
|
||||
Bugfixes for core functionalities.
|
||||
|
||||
## 2021-08-20 - 1.0.34 to 1.0.36 - Core Fixes
|
||||
Core functionalities updates and patches.
|
||||
|
||||
## 2021-05-05 - 1.0.32 to 1.0.33 - Core Fixes
|
||||
Series of core bugfixes.
|
||||
|
||||
## 2021-03-06 - 1.0.29 to 1.0.31 - Core Fixes
|
||||
Updates and improvements for core functionalities.
|
||||
|
||||
## 2021-02-13 - 1.0.27 to 1.0.28 - Core Fixes
|
||||
Bugfixes and patches for core functionalities.
|
||||
|
||||
## 2020-12-10 - 1.0.25 to 1.0.26 - Core Fixes
|
||||
Various core updates and improvements.
|
||||
|
||||
## 2020-12-02 - 1.0.23 to 1.0.24 - Core Fixes
|
||||
Minor updates for core functionalities.
|
||||
|
||||
## 2020-12-01 - 1.0.21 to 1.0.22 - Core Fixes
|
||||
Bugfixes and patches for core.
|
||||
|
||||
## 2020-09-13 - 1.0.19 to 1.0.20 - Core Fixes
|
||||
Updates and bugfixes for core functionalities.
|
||||
|
||||
## 2020-05-11 - 1.0.17 to 1.0.18 - Core Fixes
|
||||
Core functionalities updates.
|
||||
114
html/index.html
114
html/index.html
@@ -1,7 +1,6 @@
|
||||
<!--gitzone default-->
|
||||
<!-- made by Lossless GmbH -->
|
||||
<!--gitzone element-->
|
||||
<!-- made by Task Venture Capital GmbH -->
|
||||
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--Lets set some basic meta tags-->
|
||||
@@ -10,119 +9,20 @@
|
||||
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png">
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@100;200;300;400;500;600;700;800&family=Roboto+Slab:wght@100;200;300;400;500;600;700;900&family=Roboto:wght@100;300;400;500;700;900&family=Roboto+Mono:wght@100;300;400;500;700;900&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Just+Me+Again+Down+Here&display=swap" rel="stylesheet">
|
||||
|
||||
|
||||
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||
<style>
|
||||
body {
|
||||
background: #000;
|
||||
margin: 0px;
|
||||
background: #222222;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
projectVersion = '';
|
||||
</script>
|
||||
|
||||
<script type="module" src="/bundle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<style>
|
||||
body {
|
||||
background: #303f9f;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-top: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background: #4357d9;
|
||||
}
|
||||
.contentHeader {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 25px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<div class="logo">
|
||||
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg">
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="contentHeader">
|
||||
We need JavaScript to run properly!
|
||||
</div>
|
||||
<div class="content">
|
||||
This site is being built using lit-element (made by Google). This technology works with
|
||||
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
|
||||
JavaScript.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="https://lossless.gmbh">Legal Info</a> |
|
||||
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="text/javascript" async defer>
|
||||
window.revenueEnabled = true;
|
||||
const runRevenueCheck = async () => {
|
||||
var e = document.createElement('div');
|
||||
e.id = '476kjuhzgtr764';
|
||||
e.style.display = 'none';
|
||||
document.body.appendChild(e);
|
||||
if (document.getElementById('476kjuhzgtr764')) {
|
||||
window.revenueEnabled = true;
|
||||
} else {
|
||||
window.revenueEnabled = false;
|
||||
}
|
||||
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||
};
|
||||
|
||||
runRevenueCheck();
|
||||
</script>
|
||||
</body>
|
||||
<script defer type="module" src="/bundle.js"></script>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// dees tools
|
||||
import * as deesWccTools from '@designestate/dees-wcctools';
|
||||
import * as deesDomTools from '@designestate/dees-domtools';
|
||||
import * as deesWccTools from '@design.estate/dees-wcctools';
|
||||
import * as deesDomTools from '@design.estate/dees-domtools';
|
||||
|
||||
// elements and pages
|
||||
import * as elements from '../ts_web/elements/index.js';
|
||||
|
||||
@@ -2,17 +2,46 @@
|
||||
"gitzone": {
|
||||
"projectType": "wcc",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "designestate",
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "design.estate",
|
||||
"gitrepo": "dees-catalog",
|
||||
"description": "a webcomponents catalog for handling daily stuff on the web",
|
||||
"npmPackagename": "@designestate/dees-catalog",
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"npmPackagename": "@design.estate/dees-catalog",
|
||||
"license": "MIT",
|
||||
"projectDomain": "design.estate"
|
||||
"projectDomain": "design.estate",
|
||||
"keywords": [
|
||||
"Web Components",
|
||||
"User Interface",
|
||||
"UI Library",
|
||||
"Component Library",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Dynamic Components",
|
||||
"Modular Architecture",
|
||||
"Reusable Components",
|
||||
"Web Development",
|
||||
"Application UI",
|
||||
"Custom Elements",
|
||||
"Shadow DOM",
|
||||
"UI Elements",
|
||||
"Dashboard Interfaces",
|
||||
"Form Handling",
|
||||
"Data Display",
|
||||
"Visualization",
|
||||
"Charting",
|
||||
"Interactive Components",
|
||||
"Responsive Design",
|
||||
"Web Applications",
|
||||
"Modern Web",
|
||||
"Frontend Development"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
}
|
||||
}
|
||||
10373
package-lock.json
generated
10373
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
90
package.json
90
package.json
@@ -1,34 +1,57 @@
|
||||
{
|
||||
"name": "@designestate/dees-catalog",
|
||||
"version": "1.0.84",
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.12.5",
|
||||
"private": false,
|
||||
"description": "website for lossless.com",
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
"typings": "dist_ts_web/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/ --web",
|
||||
"build": "tsbuild element --allowimplicitany && tsbundle element --production",
|
||||
"watch": "tswatch element"
|
||||
"test": "tstest test/ --web --verbose --timeout 30 --logfile",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
|
||||
"watch": "tswatch element",
|
||||
"buildDocs": "tsdoc",
|
||||
"postinstall": "node scripts/update-monaco-version.cjs"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@designestate/dees-domtools": "^2.0.22",
|
||||
"@designestate/dees-element": "^2.0.15",
|
||||
"@designestate/dees-wcctools": "^1.0.74",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1"
|
||||
"@design.estate/dees-domtools": "^2.3.3",
|
||||
"@design.estate/dees-element": "^2.1.2",
|
||||
"@design.estate/dees-wcctools": "^1.2.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.0.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.0.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.0.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
||||
"@push.rocks/smarti18n": "^1.0.4",
|
||||
"@push.rocks/smartpromise": "^4.2.0",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@tiptap/core": "^2.23.0",
|
||||
"@tiptap/extension-link": "^2.23.0",
|
||||
"@tiptap/extension-text-align": "^2.23.0",
|
||||
"@tiptap/extension-typography": "^2.23.0",
|
||||
"@tiptap/extension-underline": "^2.23.0",
|
||||
"@tiptap/starter-kit": "^2.23.0",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"@webcontainer/api": "1.2.0",
|
||||
"apexcharts": "^5.3.5",
|
||||
"highlight.js": "11.11.1",
|
||||
"ibantools": "^4.5.1",
|
||||
"lit": "^3.3.1",
|
||||
"lucide": "^0.544.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitzone/tsbuild": "^2.1.61",
|
||||
"@gitzone/tsbundle": "^2.0.3",
|
||||
"@gitzone/tstest": "^1.0.71",
|
||||
"@gitzone/tswatch": "^2.0.1",
|
||||
"@pushrocks/projectinfo": "^5.0.1",
|
||||
"@pushrocks/tapbundle": "^5.0.3"
|
||||
"@git.zone/tsbuild": "^2.6.8",
|
||||
"@git.zone/tsbundle": "^2.5.1",
|
||||
"@git.zone/tstest": "^2.3.8",
|
||||
"@git.zone/tswatch": "^2.2.1",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
@@ -44,5 +67,32 @@
|
||||
],
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
]
|
||||
],
|
||||
"keywords": [
|
||||
"Web Components",
|
||||
"User Interface",
|
||||
"UI Library",
|
||||
"Component Library",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Dynamic Components",
|
||||
"Modular Architecture",
|
||||
"Reusable Components",
|
||||
"Web Development",
|
||||
"Application UI",
|
||||
"Custom Elements",
|
||||
"Shadow DOM",
|
||||
"UI Elements",
|
||||
"Dashboard Interfaces",
|
||||
"Form Handling",
|
||||
"Data Display",
|
||||
"Visualization",
|
||||
"Charting",
|
||||
"Interactive Components",
|
||||
"Responsive Design",
|
||||
"Web Applications",
|
||||
"Modern Web",
|
||||
"Frontend Development"
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
}
|
||||
|
||||
12789
pnpm-lock.yaml
generated
Normal file
12789
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- mongodb-memory-server
|
||||
- puppeteer
|
||||
607
readme.hints.md
Normal file
607
readme.hints.md
Normal file
@@ -0,0 +1,607 @@
|
||||
!!! Please pay attention to the following points when writing the readme: !!!
|
||||
* Give a short rundown of components and a few points abput specific features on each.
|
||||
* Try to list all components in a summary.
|
||||
* Then list all components with a short description.
|
||||
|
||||
## Chart Components
|
||||
|
||||
### dees-chart-area
|
||||
- Fully functional area chart component using ApexCharts
|
||||
- Displays time-series data with gradient fills
|
||||
- Responsive with ResizeObserver (debounced to prevent flicker)
|
||||
- Fixed: Chart now properly respects container boundaries on initial render
|
||||
- Overflow prevention with proper CSS containment
|
||||
- Enhanced demo features:
|
||||
- Multiple dataset examples (System Usage, Network Traffic, Sales Analytics)
|
||||
- Real-time data simulation with automatic updates
|
||||
- Dynamic dataset switching
|
||||
- Customizable Y-axis formatters (percentages, currency, units)
|
||||
- Data randomization for testing
|
||||
- Manual data point addition
|
||||
- Properties:
|
||||
- `label`: Chart title
|
||||
- `series`: ApexAxisChartSeries data
|
||||
- `yAxisFormatter`: Custom Y-axis label formatter function
|
||||
- Methods:
|
||||
- `updateSeries()`: Update chart data
|
||||
- `appendData()`: Add new data points to existing series
|
||||
- Demo uses global reference to access chart element (window.__demoChartElement)
|
||||
|
||||
### dees-chart-log
|
||||
- Server log viewer component (not a chart despite the name)
|
||||
- Terminal-style interface with monospace font
|
||||
- Supports log levels: debug, info, warn, error, success
|
||||
- Features:
|
||||
- Auto-scroll toggle
|
||||
- Clear logs button
|
||||
- Colored log levels
|
||||
- Timestamp with milliseconds
|
||||
- Source labels for log entries
|
||||
- Maximum 1000 entries (configurable)
|
||||
- Light/dark theme support
|
||||
- Demo includes realistic server log simulation
|
||||
- Note: In demos, buttons use `@clicked` event (not `@click`)
|
||||
- Demo uses global reference to access log element (window.__demoLogElement)
|
||||
|
||||
## UI Components
|
||||
|
||||
### dees-button-group
|
||||
- Groups multiple buttons together with a unified background
|
||||
- Properties:
|
||||
- `label`: Optional label text displayed before the buttons
|
||||
- `direction`: 'horizontal' | 'vertical' layout
|
||||
- Features:
|
||||
- Light/dark theme support
|
||||
- Flexible layout with proper spacing
|
||||
- Works with all button types (normal, highlighted, success, danger)
|
||||
- Use cases:
|
||||
- View mode selectors
|
||||
- Action grouping
|
||||
- Navigation options
|
||||
- Filter controls
|
||||
|
||||
## Form Components
|
||||
|
||||
### dees-input-radio
|
||||
- Radio button component with proper group behavior
|
||||
- Properties:
|
||||
- `name`: Group name for mutually exclusive selection
|
||||
- `key`: Unique identifier for the radio option
|
||||
- `value`: Boolean indicating selection state
|
||||
- `label`: Display label
|
||||
- Features:
|
||||
- Automatic group management (radios with same name are mutually exclusive)
|
||||
- Cannot be deselected by clicking (proper radio behavior)
|
||||
- Form integration: Radio groups are collected by name, value is the selected radio's key
|
||||
- Works both inside and outside forms
|
||||
- Supports disabled state
|
||||
- Fixed: Radio buttons now properly deselect others in the group on first click
|
||||
- Note: When using in forms, set both `name` (for grouping) and `key` (for the value)
|
||||
|
||||
## WYSIWYG Editor Architecture
|
||||
|
||||
### Recent Refactoring (2025-06-24)
|
||||
|
||||
The WYSIWYG editor has been refactored to improve maintainability and separation of concerns:
|
||||
|
||||
#### New Handler Classes
|
||||
|
||||
1. **WysiwygBlockOperations** (`wysiwyg.blockoperations.ts`)
|
||||
- Manages all block-related operations
|
||||
- Methods: createBlock, insertBlockAfter, removeBlock, findBlock, focusBlock, etc.
|
||||
- Centralized block manipulation logic
|
||||
|
||||
2. **WysiwygInputHandler** (`wysiwyg.inputhandler.ts`)
|
||||
- Handles all input events for blocks
|
||||
- Manages block content updates based on type
|
||||
- Detects block type transformations
|
||||
- Handles slash commands
|
||||
- Manages auto-save with debouncing
|
||||
|
||||
3. **WysiwygKeyboardHandler** (`wysiwyg.keyboardhandler.ts`)
|
||||
- Handles all keyboard events
|
||||
- Manages formatting shortcuts (Cmd/Ctrl + B/I/U/K)
|
||||
- Handles special keys: Tab, Enter, Backspace
|
||||
- Manages slash menu navigation
|
||||
|
||||
4. **WysiwygDragDropHandler** (`wysiwyg.dragdrophandler.ts`)
|
||||
- Manages drag and drop operations
|
||||
- Tracks drag state
|
||||
- Handles visual feedback during drag
|
||||
- Manages block reordering
|
||||
|
||||
5. **WysiwygModalManager** (`wysiwyg.modalmanager.ts`)
|
||||
- Static methods for showing modals
|
||||
- Language selection for code blocks
|
||||
- Block settings modal
|
||||
- Reusable modal patterns
|
||||
|
||||
#### Main Component Updates
|
||||
|
||||
The main `DeesInputWysiwyg` component now:
|
||||
- Instantiates handler classes in `connectedCallback`
|
||||
- Delegates complex operations to appropriate handlers
|
||||
- Maintains cleaner, more focused code
|
||||
- Better separation of concerns
|
||||
|
||||
#### Benefits
|
||||
- Reduced main component size from 1100+ lines
|
||||
- Each handler class is focused on a single responsibility
|
||||
- Easier to test individual components
|
||||
- Better code organization
|
||||
- Improved maintainability
|
||||
|
||||
#### Fixed Issues
|
||||
- Enter key no longer duplicates content in new blocks
|
||||
- Removed problematic `setBlockContents()` method
|
||||
- Content is now managed directly through DOM properties
|
||||
- Better timing for block creation and focus
|
||||
- Slash menu no longer disappears immediately on first "/" press
|
||||
- Focus is properly maintained when slash menu opens
|
||||
- Removed duplicate event handling methods from main component
|
||||
- Simplified focus management throughout the editor
|
||||
|
||||
#### Additional Refactoring (2025-06-24 - Part 2)
|
||||
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
|
||||
- **Simplified focus management**: Removed complex lifecycle methods and timers
|
||||
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
|
||||
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
|
||||
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
|
||||
|
||||
#### Notes
|
||||
- All input handling now goes through WysiwygInputHandler
|
||||
- All keyboard handling goes through WysiwygKeyboardHandler
|
||||
- The slash menu uses click events instead of mousedown for better UX
|
||||
- Focus is maintained using requestAnimationFrame for better timing
|
||||
- The refactoring maintains all existing functionality with improved reliability
|
||||
|
||||
### Global Menu Architecture (2025-06-24 - Part 3)
|
||||
|
||||
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
|
||||
|
||||
#### Key Components:
|
||||
|
||||
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
|
||||
- Singleton component that renders globally in the document body
|
||||
- Accessed via `DeesSlashMenu.getInstance()`
|
||||
- Manages its own visibility, position, and filtering
|
||||
- Emits callbacks when items are selected
|
||||
|
||||
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
|
||||
- Singleton component that renders globally in the document body
|
||||
- Accessed via `DeesFormattingMenu.getInstance()`
|
||||
- Shows when text is selected
|
||||
- Applies formatting commands via callback
|
||||
|
||||
3. **Integration in DeesInputWysiwyg**
|
||||
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
|
||||
- Shows menus with absolute positioning
|
||||
- Menus handle their own rendering and state management
|
||||
|
||||
#### Benefits:
|
||||
- No focus loss when menus appear/disappear
|
||||
- Better performance (menus don't re-render with component)
|
||||
- Cleaner separation of concerns
|
||||
- Menus persist across component updates
|
||||
|
||||
#### Usage:
|
||||
```typescript
|
||||
// Show slash menu
|
||||
this.slashMenu.show(
|
||||
{ x: cursorX, y: cursorY },
|
||||
(type: string) => this.insertBlock(type)
|
||||
);
|
||||
|
||||
// Show formatting menu
|
||||
this.formattingMenu.show(
|
||||
{ x: selectionX, y: selectionY },
|
||||
(command: string) => this.applyFormat(command)
|
||||
);
|
||||
```
|
||||
|
||||
#### Previous Issues Fixed:
|
||||
- Slash menu was disappearing immediately on first "/" press
|
||||
- Focus was lost when menus appeared
|
||||
- Text selection was not working properly
|
||||
- Cursor position was lost after menu interactions
|
||||
|
||||
### Arrow Key Navigation (2025-06-24 - Part 4)
|
||||
|
||||
Enhanced arrow key handling for seamless navigation between blocks:
|
||||
|
||||
#### Features:
|
||||
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
|
||||
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
|
||||
3. **Smart detection**: Checks actual cursor position within the block content
|
||||
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
|
||||
5. **No focus loss**: Navigation maintains focus throughout
|
||||
|
||||
#### Implementation:
|
||||
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
|
||||
- Smart cursor position detection for different block types (text, lists, etc.)
|
||||
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
|
||||
- Prevents default behavior only when navigating between blocks
|
||||
- Skips divider blocks during navigation
|
||||
|
||||
### Focus Management Improvements (2025-06-24 - Part 5)
|
||||
|
||||
Enhanced focus management to prevent focus loss during various operations:
|
||||
|
||||
#### Key Improvements:
|
||||
|
||||
1. **Formatting Without execCommand**:
|
||||
- Replaced deprecated `document.execCommand` with modern DOM manipulation
|
||||
- Proper selection restoration after formatting
|
||||
- Async formatting operations to maintain focus
|
||||
|
||||
2. **Link Dialog**:
|
||||
- Replaced `prompt()` with custom modal dialog
|
||||
- Maintains focus context during async operations
|
||||
- Auto-focuses input field in modal
|
||||
|
||||
3. **Robust Focus Methods**:
|
||||
- Double `requestAnimationFrame` for DOM update timing
|
||||
- Fallback focus attempts with microtasks
|
||||
- Contenteditable attribute verification
|
||||
|
||||
4. **Cursor Positioning**:
|
||||
- Enhanced `setCursorToStart/End` with edge case handling
|
||||
- Zero-width space insertion for empty elements
|
||||
- Recursive node traversal for complex HTML structures
|
||||
|
||||
5. **Async Keyboard Shortcuts**:
|
||||
- Formatting shortcuts use Promise resolution
|
||||
- Prevents focus loss during rapid keyboard input
|
||||
|
||||
#### Implementation Details:
|
||||
- `focusWithCursor()` method now handles empty blocks and complex HTML
|
||||
- `applyFormat()` is async and properly restores selection
|
||||
- Link creation no longer uses blocking `prompt()` dialog
|
||||
- All focus operations use proper timing with RAF and microtasks
|
||||
|
||||
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
|
||||
|
||||
Fixed focus loss issues when slash menu and formatting menu appear:
|
||||
|
||||
#### Key Fixes:
|
||||
|
||||
1. **Timeout Reduction**:
|
||||
- Replaced 50ms setTimeout with requestAnimationFrame
|
||||
- Immediate focus attempt before falling back to RAF
|
||||
- Reduced delay when inserting blocks
|
||||
|
||||
2. **Menu Focus Prevention**:
|
||||
- Added `tabindex="-1"` to prevent menus from taking focus
|
||||
- Added focus event prevention on menus
|
||||
- Menus now use mousedown prevention consistently
|
||||
|
||||
3. **Blur Event Handling**:
|
||||
- Skip value updates when slash menu is visible
|
||||
- Prevent auto-save during slash menu interaction
|
||||
- Maintain focus after menu appears with RAF
|
||||
|
||||
4. **Block Focus Optimization**:
|
||||
- Try immediate focus if block element exists
|
||||
- Fall back to RAF only when necessary
|
||||
- Consistent focus handling across all block types
|
||||
|
||||
#### Implementation:
|
||||
- `handleBlockBlur()` checks if slash menu is visible before updating
|
||||
- `scheduleAutoSave()` skips saving when slash menu is open
|
||||
- Slash menu show adds RAF to restore focus if lost
|
||||
- Reduced timing delays throughout the focus chain
|
||||
|
||||
### Slash Command Cleanup (2025-06-24 - Part 7)
|
||||
|
||||
Fixed the issue where "/" remained in the editor after selecting a block type:
|
||||
|
||||
#### The Fix:
|
||||
|
||||
1. **In `insertBlock()`**:
|
||||
- Clear slash command before transforming block type
|
||||
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
|
||||
- Trim the result to ensure clean content
|
||||
- Set content to empty for transformed blocks
|
||||
|
||||
2. **Improved Content Handling**:
|
||||
- Wait for `updateComplete` before focusing
|
||||
- Ensure lists start with empty content
|
||||
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
|
||||
|
||||
3. **Edge Cases**:
|
||||
- Handle filtered commands (e.g., "/hea" for heading)
|
||||
- Clear content even with partial matches
|
||||
- Proper content reset for all block types
|
||||
|
||||
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
|
||||
|
||||
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
|
||||
|
||||
Added two major improvements to the wysiwyg editor:
|
||||
|
||||
#### 1. Smart Enter Key Behavior:
|
||||
|
||||
When pressing Enter, content after the cursor is now moved to the next block:
|
||||
|
||||
- **Content Splitting**: Uses Range API to extract content after cursor
|
||||
- **HTML Preservation**: Maintains formatting when splitting blocks
|
||||
- **Clean Split**: Current block keeps content before cursor, new block gets content after
|
||||
- **Empty Block**: If cursor is at end, creates empty new block
|
||||
|
||||
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
|
||||
```typescript
|
||||
// Clone the range to extract content after cursor
|
||||
const afterRange = range.cloneRange();
|
||||
afterRange.selectNodeContents(target);
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
|
||||
// Extract content after cursor
|
||||
const afterContent = afterRange.extractContents();
|
||||
```
|
||||
|
||||
#### 2. Block Type Changing via Settings Menu:
|
||||
|
||||
The block settings menu (three dots) now includes block type selection:
|
||||
|
||||
- **Type Selector Grid**: Shows all available block types with icons
|
||||
- **Smart Metadata Handling**:
|
||||
- Clears code language when changing from code block
|
||||
- Clears list type when changing from list
|
||||
- Prompts for language when changing to code block
|
||||
- **Visual Feedback**: Currently selected type is highlighted
|
||||
- **Instant Update**: Block transforms immediately on selection
|
||||
|
||||
Features:
|
||||
- Works for all block types (not just code blocks)
|
||||
- Preserves content during type transformation
|
||||
- Handles special cases like code block language selection
|
||||
- Modal closes automatically after selection
|
||||
|
||||
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
|
||||
|
||||
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
|
||||
|
||||
#### 1. Async Operation Architecture:
|
||||
- All focus operations are now async with proper Promise handling
|
||||
- `insertBlockAfter()` waits for component updates before focusing
|
||||
- `focusBlock()` ensures DOM is ready with `updateComplete`
|
||||
- Eliminated arbitrary timeouts in favor of proper async/await
|
||||
|
||||
#### 2. Enter Key Split Content Fix:
|
||||
- Added `getSplitContent()` method to block component
|
||||
- Properly extracts content before/after cursor using Range API
|
||||
- Updates current block and creates new block atomically
|
||||
- Content after cursor correctly moves to new block
|
||||
|
||||
```typescript
|
||||
// In block component
|
||||
public getSplitContent(): { before: string; after: string } | null {
|
||||
const beforeRange = range.cloneRange();
|
||||
beforeRange.selectNodeContents(this.blockElement);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
// ... extract and return split content
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Arrow Key Navigation:
|
||||
- Added ArrowLeft/ArrowRight handlers for block boundaries
|
||||
- Prevents focus loss when navigating between blocks
|
||||
- Only intercepts at block boundaries, normal navigation otherwise
|
||||
- All arrow key operations are async for proper timing
|
||||
|
||||
#### 4. Interface Architecture:
|
||||
Created `wysiwyg.interfaces.ts` with proper typing:
|
||||
- `IWysiwygComponent` - Main component contract
|
||||
- `IBlockOperations` - Block operation methods
|
||||
- `IWysiwygBlockComponent` - Block component interface
|
||||
- `IBlockEventHandlers` - Event handler signatures
|
||||
|
||||
#### 5. Focus Management Improvements:
|
||||
- Eliminated double RAF in favor of single async flow
|
||||
- Focus operations wait for DOM updates via `updateComplete`
|
||||
- Proper cursor positioning after all operations
|
||||
- No more focus loss during navigation
|
||||
|
||||
#### Key Changes:
|
||||
1. Keyboard handler methods are now async
|
||||
2. Block operations return Promises
|
||||
3. Enter key properly splits content at cursor
|
||||
4. Arrow keys handle block navigation without focus loss
|
||||
5. All timing is handled via proper async/await patterns
|
||||
|
||||
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.
|
||||
|
||||
### Programmatic Rendering Solution (2025-06-24 - Part 10)
|
||||
|
||||
Fixed persistent focus loss issue by implementing fully programmatic rendering:
|
||||
|
||||
#### The Problem:
|
||||
- User would click in a block, type text, then press arrow keys and lose focus
|
||||
- Root cause: Lit was re-rendering components when block content was mutated
|
||||
- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss
|
||||
|
||||
#### The Solution:
|
||||
|
||||
1. **Static Parent Rendering**:
|
||||
- Parent component renders only once with empty editor content div
|
||||
- All blocks are created and managed programmatically via DOM manipulation
|
||||
- No Lit re-renders triggered by state changes
|
||||
|
||||
2. **Manual Block Management**:
|
||||
- `renderBlocksProgrammatically()` creates all block elements manually
|
||||
- `createBlockElement()` builds block wrapper with all event handlers
|
||||
- `updateBlockElement()` replaces individual blocks when needed
|
||||
- No reactive properties trigger parent re-renders
|
||||
|
||||
3. **Content Update Strategy**:
|
||||
- During typing, content is NOT immediately synced to data model
|
||||
- Auto-save delayed to 2 seconds to avoid interference
|
||||
- Content synced from DOM only on blur or before save
|
||||
- `syncAllBlockContent()` reads from DOM when needed
|
||||
|
||||
4. **Focus Preservation**:
|
||||
- Block components prevent re-renders with `shouldUpdate()`
|
||||
- Parent never re-renders after initial load
|
||||
- Focus remains stable during all editing operations
|
||||
- Arrow key navigation works without focus loss
|
||||
|
||||
5. **Implementation Details**:
|
||||
```typescript
|
||||
// Parent render method - static after first render
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="editor-content" id="editor-content">
|
||||
<!-- Blocks rendered programmatically -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// All block operations use DOM manipulation
|
||||
private renderBlocksProgrammatically() {
|
||||
this.editorContentRef.innerHTML = '';
|
||||
this.blocks.forEach(block => {
|
||||
const blockWrapper = this.createBlockElement(block);
|
||||
this.editorContentRef.appendChild(blockWrapper);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing.
|
||||
|
||||
### Code Refactoring and Cleanup (2025-06-24 - Part 11)
|
||||
|
||||
Completed comprehensive refactoring to ensure clean, maintainable code with separated concerns:
|
||||
|
||||
#### Refactoring Changes:
|
||||
|
||||
1. **Drag and Drop Handler Cleanup**:
|
||||
- Removed all `requestUpdate()` calls from drag handler
|
||||
- Handler now only updates internal state
|
||||
- Parent component handles DOM updates programmatically
|
||||
- Simplified drag state management
|
||||
|
||||
2. **Unused Code Removal**:
|
||||
- Removed duplicate `showBlockSettingsModal` method (using WysiwygModalManager)
|
||||
- Removed duplicate `showLanguageSelectionModal` method
|
||||
- Removed unused `renderBlock` method
|
||||
- Cleaned up unused imports (WysiwygBlocks, ISlashMenuItem)
|
||||
|
||||
3. **Import Cleanup**:
|
||||
- Removed unused type imports
|
||||
- Organized imports logically
|
||||
- Kept only necessary dependencies
|
||||
|
||||
4. **Separated Concerns**:
|
||||
- Modal management in WysiwygModalManager
|
||||
- Block operations in WysiwygBlockOperations
|
||||
- Input handling in WysiwygInputHandler
|
||||
- Keyboard handling in WysiwygKeyboardHandler
|
||||
- Drag/drop in WysiwygDragDropHandler
|
||||
- Each class has a single responsibility
|
||||
|
||||
5. **Programmatic DOM Management**:
|
||||
- All DOM updates happen through explicit methods
|
||||
- No reactive re-renders during user interaction
|
||||
- Manual class management for drag states
|
||||
- Direct DOM manipulation for performance
|
||||
|
||||
6. **Test Files Created**:
|
||||
- `test-focus-fix.html` - Verifies focus management
|
||||
- `test-drag-drop.html` - Tests drag and drop functionality
|
||||
- `test-comprehensive.html` - Tests all features together
|
||||
|
||||
The refactoring follows the principles in instructions.md:
|
||||
- Uses static templates with manual DOM operations
|
||||
- Maintains separated concerns in different classes
|
||||
- Results in clean, concise, and manageable code
|
||||
|
||||
## Z-Index Management System (2025-12-24)
|
||||
|
||||
A comprehensive z-index management system has been implemented to fix overlay stacking conflicts:
|
||||
|
||||
### The Problem:
|
||||
- Modals were hiding dropdown overlays
|
||||
- Context menus appeared behind modals
|
||||
- Inconsistent z-index values across components
|
||||
- No clear hierarchy for overlay stacking
|
||||
|
||||
### The Solution:
|
||||
|
||||
#### 1. Central Z-Index Constants (`00zindex.ts`):
|
||||
Created a centralized file defining all z-index layers:
|
||||
|
||||
```typescript
|
||||
export const zIndexLayers = {
|
||||
// Base layer: Regular content
|
||||
base: {
|
||||
content: 'auto',
|
||||
inputElements: 1,
|
||||
},
|
||||
// Fixed UI elements
|
||||
fixed: {
|
||||
appBar: 10,
|
||||
sideMenu: 10,
|
||||
mobileNav: 250,
|
||||
},
|
||||
// Overlay backdrops
|
||||
backdrop: {
|
||||
dropdown: 1999,
|
||||
modal: 2999,
|
||||
contextMenu: 3999,
|
||||
},
|
||||
// Interactive overlays
|
||||
overlay: {
|
||||
dropdown: 2000, // Dropdowns and select menus
|
||||
modal: 3000, // Modal dialogs
|
||||
contextMenu: 4000, // Context menus and tooltips
|
||||
toast: 5000, // Toast notifications
|
||||
},
|
||||
// Special cases
|
||||
modalDropdown: 3500, // Dropdowns inside modals
|
||||
wysiwygMenus: 4500, // Editor formatting menus
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Updated Components:
|
||||
- **dees-modal**: Changed from 2000 to 3000
|
||||
- **dees-windowlayer**: Changed from 200-201 to 1999-2000 (used by dropdowns)
|
||||
- **dees-contextmenu**: Changed from 10000 to 4000
|
||||
- **dees-toast**: Changed from 10000 to 5000
|
||||
- **wysiwyg menus**: Changed from 10000 to 4500
|
||||
- **dees-appui-profiledropdown**: Uses new dropdown z-index (2000)
|
||||
|
||||
#### 3. Stacking Order (bottom to top):
|
||||
1. Regular page content (auto)
|
||||
2. Fixed navigation elements (10-250)
|
||||
3. Dropdown backdrop (1999)
|
||||
4. Dropdown content (2000)
|
||||
5. Modal backdrop (2999)
|
||||
6. Modal content (3000)
|
||||
7. Context menu (4000)
|
||||
8. WYSIWYG menus (4500)
|
||||
9. Toast notifications (5000)
|
||||
|
||||
#### 4. Key Benefits:
|
||||
- Dropdowns now appear above modals
|
||||
- Context menus appear above dropdowns and modals
|
||||
- Toast notifications always appear on top
|
||||
- Consistent and predictable stacking behavior
|
||||
- Easy to adjust hierarchy by modifying central constants
|
||||
|
||||
#### 5. Testing:
|
||||
Created `test-zindex.demo.ts` to verify stacking behavior with:
|
||||
- Modal containing dropdown
|
||||
- Context menu on modal
|
||||
- Toast notifications
|
||||
- Complex overlay combinations
|
||||
|
||||
### Usage:
|
||||
Import and use the z-index constants in any component:
|
||||
```typescript
|
||||
import { zIndexLayers } from './00zindex.js';
|
||||
|
||||
// In styles
|
||||
z-index: ${zIndexLayers.overlay.modal};
|
||||
```
|
||||
|
||||
This system ensures proper stacking order for all overlay components and prevents z-index conflicts.
|
||||
1906
readme.icons.md
Normal file
1906
readme.icons.md
Normal file
File diff suppressed because it is too large
Load Diff
80
readme.info.md
Normal file
80
readme.info.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Contributor Information
|
||||
|
||||
This reference consolidates the helper notes previously split across `codex.md` and `CLAUDE.md`. Use it to get oriented quickly when working on `@design.estate/dees-catalog`, a TypeScript/Lit web-components library that ships themed UI building blocks for modern web applications.
|
||||
|
||||
## Project Snapshot
|
||||
- Package: `@design.estate/dees-catalog`
|
||||
- Description: Comprehensive catalog of reusable web components with cohesive design, advanced form inputs, data displays, and layout scaffolding.
|
||||
- Entry points: builds ship to `dist_ts_web/` (ES modules) and `dist_bundle/` (browser bundle); demos live in `html/`.
|
||||
- Type system: strict TypeScript targeting modern browsers (see `tsconfig.json`).
|
||||
|
||||
## Repository Layout
|
||||
- `ts_web/` – TypeScript source
|
||||
- `elements/` – component implementations (`00*.ts` shared utilities, `dees-*.ts` components, `*.demo.ts` demos)
|
||||
- `pages/` – showcase pages aggregating demos
|
||||
- `index.ts` – main export surface
|
||||
- `html/` – demo entry point bootstrapping bundles
|
||||
- `dist_bundle/`, `dist_ts_web/`, `dist_watch/` – build outputs (production, module, and watch bundles)
|
||||
- `test/` – browser/node tests powered by `@push.rocks/tapbundle`
|
||||
- `scripts/` – maintenance utilities (e.g., Monaco version sync postinstall)
|
||||
|
||||
## Build & Development Commands
|
||||
All workflows use pnpm (see `package.json`).
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm run build # tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild
|
||||
pnpm run watch # tswatch element (development watch/dev server)
|
||||
pnpm test # tstest test/ --web --verbose --timeout 30 --logfile
|
||||
pnpm run buildDocs # tsdoc (generates docs)
|
||||
tsx test/test.file.ts # run a specific test file (file must be named test.*)
|
||||
```
|
||||
|
||||
`postinstall` runs `node scripts/update-monaco-version.cjs` to sync the Monaco editor version, so keep the script intact when updating dependencies.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: `@push.rocks/tapbundle` with smartexpect assertions. Always review https://code.foss.global/push.rocks/smartexpect/raw/branch/master/readme.md when adding tests.
|
||||
- Import pattern:
|
||||
```typescript
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
```
|
||||
- Test naming: `test.*.both.ts` for dual runtime, `.node.ts` for Node-only, `.browser.ts` for browser-only suites.
|
||||
- Prefer `pnpm test` for full runs; use `tsx` for focused debugging. Type-check failing tests with `tsc --noEmit`.
|
||||
- Logs live under `.nogit/testlogs/`; put ad-hoc debug artefacts in `.nogit/debug/`.
|
||||
|
||||
## Component Architecture
|
||||
- **Base pattern**: Components extend `DeesElement` from `@design.estate/dees-element`, use Lit decorators (`@customElement`, `@property`), and combine `cssManager.defaultStyles` with component styles. Rendering happens via Lit `html` templates; demos sit on a static `demo` property referencing a `.demo.ts` module.
|
||||
- **Theming**: `cssManager.bdTheme(light, dark)` selects theme-aware values. Shared palettes live in `ts_web/elements/00colors.ts`.
|
||||
- **Z-index management**: Overlays consult the registry in `ts_web/elements/00zindex.ts` (`ZIndexRegistry`) to coordinate stacking.
|
||||
- **Component families**:
|
||||
- Core UI (`dees-button`, `dees-badge`, `dees-icon`, …) focus on consistent theming and interactions.
|
||||
- Form inputs (`dees-form`, `dees-input-*`) build on `DeesInputBase` and communicate through subjects/events for validation.
|
||||
- Layout shells (`dees-appui-*`) orchestrate responsive app frames with centralized event rebroadcasts.
|
||||
- Data views (`dees-table`, `dees-dataview-*`, `dees-statsgrid`) handle large datasets with virtualisation and chart integrations.
|
||||
- Overlays (`dees-modal`, `dees-contextmenu`, `dees-toast`) respect the z-index registry and use shared window-layer utilities.
|
||||
- **WYSIWYG editor**: `dees-input-wysiwyg` coordinates specialized handler classes (`WysiwygInputHandler`, `WysiwygKeyboardHandler`, drag/drop & modal managers) and global menus (`DeesSlashMenu`, `DeesFormattingMenu`). Rendering is imperative to preserve caret focus.
|
||||
|
||||
## Implementation Guidelines
|
||||
- Import external modules through `ts_web/elements/00plugins.ts`: `import * as plugins from './plugins.ts';` then reference `plugins.moduleName`.
|
||||
- When creating new components:
|
||||
1. Extend `DeesElement` and decorate with `@customElement('dees-component')`.
|
||||
2. Support theming, slots, and accessibility; provide meaningful default styles.
|
||||
3. Expose a `.demo.ts` for the component and re-export via `elements/index.ts`.
|
||||
- Form components must implement `getValue()` / `setValue()` and emit through `changeSubject` while honoring `disabled` and `required` states.
|
||||
- Overlay components retrieve z-indices from the registry, register/unregister on show/hide, and use `DeesWindowLayer` for backdrops when appropriate.
|
||||
- Avoid simplifying away functionality; prefer small, targeted changes and keep compatibility with existing APIs.
|
||||
|
||||
## Common Patterns & Pitfalls
|
||||
- Focus management: schedule DOM updates with `requestAnimationFrame` inside interactive editors to avoid focus loss.
|
||||
- Event handling: stop propagation where nested interactive elements coexist; mix pointer and keyboard handling for accessibility.
|
||||
- Performance: heavy blocks/components may load lazily; charts use debounced observers, tables rely on virtual scrolling. Watch bundle size when adding dependencies.
|
||||
|
||||
## Documentation & Demos
|
||||
- `readme.md` surfaces component overviews; demos in `.demo.ts` illustrate real usage.
|
||||
- Update this `readme.info.md` when architectural patterns or workflows change so contributors stay in sync.
|
||||
|
||||
## Recent Highlights
|
||||
- Z-index registry overhaul enables dynamic stacking control across overlays.
|
||||
- WYSIWYG refactor separated block handlers for maintainability.
|
||||
- Dashboard grid enhancements added live drag-and-drop previews and overlap fixes.
|
||||
- Monaco editor integration now reads the installed version at build time.
|
||||
0
readme.plan.md
Normal file
0
readme.plan.md
Normal file
784
readme.playbook.md
Normal file
784
readme.playbook.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# UI Components Playbook
|
||||
|
||||
This playbook provides comprehensive guidance for creating and maintaining UI components in the @design.estate/dees-catalog library. Follow these patterns and best practices to ensure consistency, maintainability, and quality.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Component Creation Checklist](#component-creation-checklist)
|
||||
2. [Architectural Patterns](#architectural-patterns)
|
||||
3. [Component Types and Base Classes](#component-types-and-base-classes)
|
||||
4. [Theming System](#theming-system)
|
||||
5. [Event Handling](#event-handling)
|
||||
6. [State Management](#state-management)
|
||||
7. [Form Components](#form-components)
|
||||
8. [Overlay Components](#overlay-components)
|
||||
9. [Complex Components](#complex-components)
|
||||
10. [Performance Optimization](#performance-optimization)
|
||||
11. [Focus Management](#focus-management)
|
||||
12. [Demo System](#demo-system)
|
||||
13. [Common Pitfalls and Anti-patterns](#common-pitfalls-and-anti-patterns)
|
||||
14. [Code Examples](#code-examples)
|
||||
|
||||
## Component Creation Checklist
|
||||
|
||||
When creating a new component, follow this checklist:
|
||||
|
||||
- [ ] Choose the appropriate base class (`DeesElement` or `DeesInputBase`)
|
||||
- [ ] Use `@customElement('dees-componentname')` decorator
|
||||
- [ ] Implement consistent theming with `cssManager.bdTheme()`
|
||||
- [ ] Create demo function in separate `.demo.ts` file
|
||||
- [ ] Export component from `ts_web/elements/index.ts`
|
||||
- [ ] Use proper TypeScript types and interfaces (prefix with `I` for interfaces, `T` for types)
|
||||
- [ ] Implement proper event handling with bubbling and composition
|
||||
- [ ] Consider mobile responsiveness
|
||||
- [ ] Add focus states for accessibility
|
||||
- [ ] Clean up resources in `destroy()` method
|
||||
- [ ] Follow lowercase naming convention for files
|
||||
- [ ] Add z-index registry support if it's an overlay component
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Base Component Structure
|
||||
|
||||
```typescript
|
||||
import { customElement, property, state, css, TemplateResult, html } from '@design.estate/dees-element';
|
||||
import { DeesElement } from '@design.estate/dees-element';
|
||||
import * as cssManager from './00colors.js';
|
||||
import * as demoFunc from './dees-componentname.demo.js';
|
||||
|
||||
@customElement('dees-componentname')
|
||||
export class DeesComponentName extends DeesElement {
|
||||
// Static demo reference
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
// Public properties (reactive, can be set via attributes)
|
||||
@property({ type: String })
|
||||
public label: string = '';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled: boolean = false;
|
||||
|
||||
// Internal state (reactive, but not exposed as attributes)
|
||||
@state()
|
||||
private internalState: string = '';
|
||||
|
||||
// Static styles with theme support
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
// Render method
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="main-container">
|
||||
<!-- Component content -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Lifecycle methods
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Setup that needs DOM access
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// One-time initialization after first render
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
public destroy() {
|
||||
// Clean up listeners, observers, registrations
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
#### 1. Separation of Concerns (Complex Components)
|
||||
|
||||
For complex components like WYSIWYG editors, separate concerns into handler classes:
|
||||
|
||||
```typescript
|
||||
export class DeesComplexComponent extends DeesElement {
|
||||
// Orchestrator pattern - main component coordinates handlers
|
||||
private inputHandler: InputHandler;
|
||||
private stateHandler: StateHandler;
|
||||
private renderHandler: RenderHandler;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.inputHandler = new InputHandler(this);
|
||||
this.stateHandler = new StateHandler(this);
|
||||
this.renderHandler = new RenderHandler(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Singleton Pattern (Global Components)
|
||||
|
||||
For global UI elements like menus:
|
||||
|
||||
```typescript
|
||||
export class DeesGlobalMenu extends DeesElement {
|
||||
private static instance: DeesGlobalMenu;
|
||||
|
||||
public static getInstance(): DeesGlobalMenu {
|
||||
if (!DeesGlobalMenu.instance) {
|
||||
DeesGlobalMenu.instance = new DeesGlobalMenu();
|
||||
document.body.appendChild(DeesGlobalMenu.instance);
|
||||
}
|
||||
return DeesGlobalMenu.instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Registry Pattern (Z-Index Management)
|
||||
|
||||
Use centralized registries for global state:
|
||||
|
||||
```typescript
|
||||
class ComponentRegistry {
|
||||
private static instance: ComponentRegistry;
|
||||
private registry = new WeakMap<HTMLElement, number>();
|
||||
|
||||
public register(element: HTMLElement, value: number) {
|
||||
this.registry.set(element, value);
|
||||
}
|
||||
|
||||
public unregister(element: HTMLElement) {
|
||||
this.registry.delete(element);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Types and Base Classes
|
||||
|
||||
### Standard Component (extends DeesElement)
|
||||
|
||||
Use for most UI components:
|
||||
- Buttons, badges, icons
|
||||
- Layout components
|
||||
- Data display components
|
||||
- Overlay components
|
||||
|
||||
### Form Input Component (extends DeesInputBase)
|
||||
|
||||
Use for all form inputs:
|
||||
- Text inputs, dropdowns, checkboxes
|
||||
- Date pickers, file uploads
|
||||
- Rich text editors
|
||||
|
||||
**Required implementations:**
|
||||
```typescript
|
||||
export class DeesInputCustom extends DeesInputBase<ValueType> {
|
||||
// Required: Get current value
|
||||
public getValue(): ValueType {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
// Required: Set value programmatically
|
||||
public setValue(value: ValueType): void {
|
||||
this.value = value;
|
||||
this.changeSubject.next(this); // Notify form
|
||||
}
|
||||
|
||||
// Optional: Custom validation
|
||||
public async validate(): Promise<boolean> {
|
||||
// Custom validation logic
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Theming System
|
||||
|
||||
### DO: Use Theme Functions
|
||||
|
||||
Always use `cssManager.bdTheme()` for colors that change between themes:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#333333')};
|
||||
|
||||
// ❌ INCORRECT
|
||||
background: #ffffff; // Hard-coded color
|
||||
color: var(--custom-color); // Custom CSS variable
|
||||
```
|
||||
|
||||
### DO: Use Consistent Color Values
|
||||
|
||||
Reference shared color constants when possible:
|
||||
|
||||
```typescript
|
||||
// From 00colors.ts
|
||||
background: ${cssManager.bdTheme(colors.bright.background, colors.dark.background)};
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
### DO: Dispatch Custom Events Properly
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Events bubble and cross shadow DOM
|
||||
this.dispatchEvent(new CustomEvent('dees-componentname-change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// ❌ INCORRECT - Event won't propagate properly
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value: this.value }
|
||||
// Missing bubbles and composed
|
||||
}));
|
||||
```
|
||||
|
||||
### DO: Use Event Delegation
|
||||
|
||||
For dynamic content, use event delegation:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Single listener for all items
|
||||
this.addEventListener('click', (e: MouseEvent) => {
|
||||
const item = (e.target as HTMLElement).closest('.item');
|
||||
if (item) {
|
||||
this.handleItemClick(item);
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ INCORRECT - Multiple listeners
|
||||
this.items.forEach(item => {
|
||||
item.addEventListener('click', () => this.handleItemClick(item));
|
||||
});
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### DO: Use Appropriate Property Decorators
|
||||
|
||||
```typescript
|
||||
// Public API - use @property
|
||||
@property({ type: String })
|
||||
public label: string;
|
||||
|
||||
// Internal state - use @state
|
||||
@state()
|
||||
private isLoading: boolean = false;
|
||||
|
||||
// Reflect to attribute when needed
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public disabled: boolean = false;
|
||||
```
|
||||
|
||||
### DON'T: Manipulate State in Render
|
||||
|
||||
```typescript
|
||||
// ❌ INCORRECT - Side effects in render
|
||||
public render() {
|
||||
this.counter++; // Don't modify state
|
||||
return html`<div>${this.counter}</div>`;
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Pure render function
|
||||
public render() {
|
||||
return html`<div>${this.counter}</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### DO: Extend DeesInputBase
|
||||
|
||||
All form inputs must extend the base class:
|
||||
|
||||
```typescript
|
||||
export class DeesInputNew extends DeesInputBase<string> {
|
||||
// Inherits: key, label, value, required, disabled, validationState
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Emit Changes Consistently
|
||||
|
||||
```typescript
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.changeSubject.next(this); // Notify form system
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Support Standard Form Properties
|
||||
|
||||
```typescript
|
||||
// All form inputs should support:
|
||||
@property() public key: string;
|
||||
@property() public label: string;
|
||||
@property() public required: boolean = false;
|
||||
@property() public disabled: boolean = false;
|
||||
@property() public validationState: 'valid' | 'warn' | 'invalid';
|
||||
```
|
||||
|
||||
## Overlay Components
|
||||
|
||||
### DO: Use Z-Index Registry
|
||||
|
||||
Never hardcode z-index values:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
import { zIndexRegistry } from './00zindex.js';
|
||||
|
||||
public async show() {
|
||||
this.modalZIndex = zIndexRegistry.getNextZIndex();
|
||||
zIndexRegistry.register(this, this.modalZIndex);
|
||||
this.style.zIndex = `${this.modalZIndex}`;
|
||||
}
|
||||
|
||||
public async hide() {
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
// ❌ INCORRECT
|
||||
public async show() {
|
||||
this.style.zIndex = '9999'; // Hardcoded z-index
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Use Window Layers
|
||||
|
||||
For modal backdrops:
|
||||
|
||||
```typescript
|
||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||
|
||||
private windowLayer: DeesWindowLayer;
|
||||
|
||||
public async show() {
|
||||
this.windowLayer = new DeesWindowLayer();
|
||||
this.windowLayer.zIndex = zIndexRegistry.getNextZIndex();
|
||||
document.body.append(this.windowLayer);
|
||||
}
|
||||
```
|
||||
|
||||
## Complex Components
|
||||
|
||||
### DO: Use Handler Classes
|
||||
|
||||
For complex logic, separate into specialized handlers:
|
||||
|
||||
```typescript
|
||||
// wysiwyg/handlers/input.handler.ts
|
||||
export class InputHandler {
|
||||
constructor(private component: DeesInputWysiwyg) {}
|
||||
|
||||
public handleInput(event: InputEvent) {
|
||||
// Specialized input handling
|
||||
}
|
||||
}
|
||||
|
||||
// Main component orchestrates
|
||||
export class DeesInputWysiwyg extends DeesInputBase {
|
||||
private inputHandler = new InputHandler(this);
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Use Programmatic Rendering
|
||||
|
||||
For performance-critical updates that shouldn't trigger re-renders:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Direct DOM manipulation when needed
|
||||
private updateBlockContent(blockId: string, content: string) {
|
||||
const blockElement = this.shadowRoot.querySelector(`#${blockId}`);
|
||||
if (blockElement) {
|
||||
blockElement.textContent = content; // Direct update
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Triggering full re-render
|
||||
private updateBlockContent(blockId: string, content: string) {
|
||||
this.blocks.find(b => b.id === blockId).content = content;
|
||||
this.requestUpdate(); // Unnecessary re-render
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### DO: Debounce Expensive Operations
|
||||
|
||||
```typescript
|
||||
private resizeTimeout: number;
|
||||
|
||||
private handleResize = () => {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
this.resizeTimeout = window.setTimeout(() => {
|
||||
this.updateLayout();
|
||||
}, 250);
|
||||
};
|
||||
```
|
||||
|
||||
### DO: Use Observers Efficiently
|
||||
|
||||
```typescript
|
||||
// Clean up observers
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.resizeObserver?.disconnect();
|
||||
this.mutationObserver?.disconnect();
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Implement Virtual Scrolling
|
||||
|
||||
For large lists:
|
||||
|
||||
```typescript
|
||||
// Only render visible items
|
||||
private getVisibleItems() {
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const containerHeight = this.scrollContainer.clientHeight;
|
||||
const itemHeight = 50;
|
||||
|
||||
const startIndex = Math.floor(scrollTop / itemHeight);
|
||||
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
|
||||
|
||||
return this.items.slice(startIndex, endIndex);
|
||||
}
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### DO: Handle Focus Timing
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Wait for render
|
||||
async focusInput() {
|
||||
await this.updateComplete;
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
this.inputElement?.focus();
|
||||
}
|
||||
|
||||
// ❌ INCORRECT - Focus too early
|
||||
focusInput() {
|
||||
this.inputElement?.focus(); // Element might not exist
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Prevent Focus Loss
|
||||
|
||||
```typescript
|
||||
// For global menus
|
||||
constructor() {
|
||||
super();
|
||||
// Prevent focus loss when clicking menu
|
||||
this.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Implement Blur Debouncing
|
||||
|
||||
```typescript
|
||||
private blurTimeout: number;
|
||||
|
||||
private handleBlur = () => {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = window.setTimeout(() => {
|
||||
// Check if truly blurred
|
||||
if (!this.contains(document.activeElement)) {
|
||||
this.handleTrueBlur();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
```
|
||||
|
||||
## Demo System
|
||||
|
||||
### DO: Create Comprehensive Demos
|
||||
|
||||
Every component needs a demo:
|
||||
|
||||
```typescript
|
||||
// dees-button.demo.ts
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-button>Default Button</dees-button>
|
||||
<dees-button type="primary">Primary Button</dees-button>
|
||||
<dees-button type="danger" disabled>Disabled Danger</dees-button>
|
||||
`;
|
||||
|
||||
// In component file
|
||||
import * as demoFunc from './dees-button.demo.js';
|
||||
|
||||
export class DeesButton extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
}
|
||||
```
|
||||
|
||||
### DO: Include All Variants
|
||||
|
||||
Show all component states and variations in demos:
|
||||
- Default state
|
||||
- Different types/variants
|
||||
- Disabled state
|
||||
- Loading state
|
||||
- Error states
|
||||
- Edge cases (long text, empty content)
|
||||
|
||||
## Common Pitfalls and Anti-patterns
|
||||
|
||||
### ❌ DON'T: Hardcode Z-Index Values
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
this.style.zIndex = '9999';
|
||||
|
||||
// ✅ CORRECT
|
||||
this.style.zIndex = `${zIndexRegistry.getNextZIndex()}`;
|
||||
```
|
||||
|
||||
### ❌ DON'T: Skip Base Classes
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Form input without base class
|
||||
export class DeesInputCustom extends DeesElement {
|
||||
// Missing standard form functionality
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export class DeesInputCustom extends DeesInputBase<string> {
|
||||
// Inherits all form functionality
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Forget Theme Support
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
|
||||
// ✅ CORRECT
|
||||
background-color: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
```
|
||||
|
||||
### ❌ DON'T: Create Components Without Demos
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
export class DeesComponent extends DeesElement {
|
||||
// No demo property
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export class DeesComponent extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Emit Non-Bubbling Events
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: this.value
|
||||
}));
|
||||
|
||||
// ✅ CORRECT
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: this.value,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
```
|
||||
|
||||
### ❌ DON'T: Skip Cleanup
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
public connectedCallback() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Use Inline Styles for Theming
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
<div style="background-color: ${this.darkMode ? '#000' : '#fff'}">
|
||||
|
||||
// ✅ CORRECT
|
||||
<div class="themed-container">
|
||||
// In styles:
|
||||
.themed-container {
|
||||
background-color: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T: Forget Mobile Responsiveness
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
:host {
|
||||
width: 800px; // Fixed width
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
:host {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:host {
|
||||
/* Mobile adjustments */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Example: Creating a New Button Variant
|
||||
|
||||
```typescript
|
||||
// dees-special-button.ts
|
||||
import { customElement, property, css, html } from '@design.estate/dees-element';
|
||||
import { DeesElement } from '@design.estate/dees-element';
|
||||
import * as cssManager from './00colors.js';
|
||||
import * as demoFunc from './dees-special-button.demo.js';
|
||||
|
||||
@customElement('dees-special-button')
|
||||
export class DeesSpecialButton extends DeesElement {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public text: string = 'Click me';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loading: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 8px 16px;
|
||||
background: ${cssManager.bdTheme('#0066ff', '#0044cc')};
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
|
||||
}
|
||||
|
||||
:host([loading]) .button {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<button class="button" ?disabled=${this.loading} @click=${this.handleClick}>
|
||||
${this.loading ? html`<dees-spinner size="small"></dees-spinner>` : this.text}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleClick() {
|
||||
this.dispatchEvent(new CustomEvent('special-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Creating a Form Input
|
||||
|
||||
```typescript
|
||||
// dees-input-special.ts
|
||||
export class DeesInputSpecial extends DeesInputBase<string> {
|
||||
public static demo = demoFunc.demoFunc;
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-label .label=${this.label} .required=${this.required}>
|
||||
<input
|
||||
type="text"
|
||||
.value=${this.value || ''}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this.handleInput}
|
||||
@blur=${this.handleBlur}
|
||||
/>
|
||||
</dees-label>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
this.dispatchEvent(new CustomEvent('blur', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
This playbook represents the collective wisdom and patterns found in the @design.estate/dees-catalog component library. Following these guidelines will help you create components that are:
|
||||
|
||||
- **Consistent**: Following established patterns
|
||||
- **Maintainable**: Easy to understand and modify
|
||||
- **Performant**: Optimized for real-world use
|
||||
- **Accessible**: Usable by everyone
|
||||
- **Theme-aware**: Supporting light and dark modes
|
||||
- **Well-integrated**: Working seamlessly with the component ecosystem
|
||||
|
||||
Remember: When in doubt, look at existing components for examples. The codebase itself is the best documentation of these patterns in action.
|
||||
44
scripts/update-monaco-version.cjs
Executable file
44
scripts/update-monaco-version.cjs
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function resolveMonacoPackageJson() {
|
||||
try {
|
||||
const resolvedPath = require.resolve('monaco-editor/package.json', {
|
||||
paths: [projectRoot],
|
||||
});
|
||||
return resolvedPath;
|
||||
} catch (error) {
|
||||
console.error('[dees-editor] Unable to resolve monaco-editor/package.json');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getMonacoVersion() {
|
||||
const monacoPackagePath = resolveMonacoPackageJson();
|
||||
const monacoPackage = require(monacoPackagePath);
|
||||
if (!monacoPackage.version) {
|
||||
throw new Error('[dees-editor] monaco-editor/package.json does not expose a version field');
|
||||
}
|
||||
return monacoPackage.version;
|
||||
}
|
||||
|
||||
function writeVersionModule(version) {
|
||||
const targetDir = path.join(projectRoot, 'ts_web', 'elements', 'dees-editor');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const targetFile = path.join(targetDir, 'version.ts');
|
||||
const fileContent = `// Auto-generated by scripts/update-monaco-version.cjs\nexport const MONACO_VERSION = '${version}';\n`;
|
||||
fs.writeFileSync(targetFile, fileContent, 'utf8');
|
||||
console.log(`[dees-editor] Wrote ${path.relative(projectRoot, targetFile)} with monaco-editor@${version}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const version = getMonacoVersion();
|
||||
writeVersionModule(version);
|
||||
} catch (error) {
|
||||
console.error('[dees-editor] Failed to update Monaco version module.');
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tap, expect, webhelpers } from '@pushrocks/tapbundle';
|
||||
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as deesCatalog from '../ts_web';
|
||||
import * as deesCatalog from '../ts_web/index.js';
|
||||
|
||||
tap.test('should create a working button', async () => {
|
||||
const button: deesCatalog.DeesButton = await webhelpers.fixture(
|
||||
@@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
|
||||
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
export default tap.start();
|
||||
|
||||
35
test/test.contextmenu-demo.browser.ts
Normal file
35
test/test.contextmenu-demo.browser.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
import { demoFunc } from '../ts_web/elements/dees-contextmenu.demo.js';
|
||||
|
||||
tap.test('should render context menu demo', async () => {
|
||||
// Create demo container
|
||||
const demoContainer = document.createElement('div');
|
||||
document.body.appendChild(demoContainer);
|
||||
|
||||
// Render the demo
|
||||
const demoContent = demoFunc();
|
||||
|
||||
// Create a temporary element to hold the rendered template
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = demoContent.strings.join('');
|
||||
|
||||
// Check that panels are rendered
|
||||
const panels = tempDiv.querySelectorAll('dees-panel');
|
||||
expect(panels.length).toEqual(4);
|
||||
|
||||
// Check panel headings
|
||||
expect(panels[0].getAttribute('heading')).toEqual('Basic Context Menu with Nested Submenus');
|
||||
expect(panels[1].getAttribute('heading')).toEqual('Component-Specific Context Menu');
|
||||
expect(panels[2].getAttribute('heading')).toEqual('Advanced Context Menu Example');
|
||||
expect(panels[3].getAttribute('heading')).toEqual('Static Context Menu (Always Visible)');
|
||||
|
||||
// Check that static context menu exists
|
||||
const staticMenu = tempDiv.querySelector('dees-contextmenu');
|
||||
expect(staticMenu).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
demoContainer.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
93
test/test.contextmenu-nested-close.browser.ts
Normal file
93
test/test.contextmenu-nested-close.browser.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should close all parent menus when clicking action in nested submenu', async () => {
|
||||
let actionCalled = false;
|
||||
|
||||
// Create a test element
|
||||
const testDiv = document.createElement('div');
|
||||
testDiv.style.width = '300px';
|
||||
testDiv.style.height = '300px';
|
||||
testDiv.style.background = '#f0f0f0';
|
||||
testDiv.innerHTML = 'Right-click for nested menu test';
|
||||
document.body.appendChild(testDiv);
|
||||
|
||||
// Simulate right-click to open context menu
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 150,
|
||||
clientY: 150,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Open context menu with nested structure
|
||||
DeesContextmenu.openContextMenuWithOptions(contextMenuEvent, [
|
||||
{
|
||||
name: 'Parent Item',
|
||||
iconName: 'folder',
|
||||
action: async () => {}, // Parent items with submenus need an action
|
||||
submenu: [
|
||||
{
|
||||
name: 'Child Item',
|
||||
iconName: 'file',
|
||||
action: async () => {
|
||||
actionCalled = true;
|
||||
console.log('Child action called');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Another Child',
|
||||
iconName: 'fileText',
|
||||
action: async () => console.log('Another child')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Regular Item',
|
||||
iconName: 'box',
|
||||
action: async () => console.log('Regular item')
|
||||
}
|
||||
]);
|
||||
|
||||
// Wait for main menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Check main menu exists
|
||||
const mainMenu = document.querySelector('dees-contextmenu');
|
||||
expect(mainMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Hover over "Parent Item" to trigger submenu
|
||||
const parentItem = mainMenu!.shadowRoot!.querySelector('.menuitem');
|
||||
expect(parentItem).toBeTruthy();
|
||||
parentItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
// Wait for submenu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check submenu exists
|
||||
const allMenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(allMenus.length).toEqual(2); // Main menu and submenu
|
||||
|
||||
const submenu = allMenus[1];
|
||||
expect(submenu).toBeTruthy();
|
||||
|
||||
// Click on "Child Item" in submenu
|
||||
const childItem = submenu.shadowRoot!.querySelector('.menuitem');
|
||||
expect(childItem).toBeTruthy();
|
||||
childItem!.click();
|
||||
|
||||
// Wait for menus to close
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Verify action was called
|
||||
expect(actionCalled).toEqual(true);
|
||||
|
||||
// Verify all menus are closed
|
||||
const remainingMenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(remainingMenus.length).toEqual(0);
|
||||
|
||||
// Clean up
|
||||
testDiv.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
71
test/test.contextmenu-shadowdom.browser.ts
Normal file
71
test/test.contextmenu-shadowdom.browser.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
import { DeesElement, customElement, html } from '@design.estate/dees-element';
|
||||
|
||||
// Create a test element with shadow DOM
|
||||
@customElement('test-shadow-element')
|
||||
class TestShadowElement extends DeesElement {
|
||||
public getContextMenuItems() {
|
||||
return [
|
||||
{ name: 'Shadow Item 1', iconName: 'box', action: async () => console.log('Shadow 1') },
|
||||
{ name: 'Shadow Item 2', iconName: 'package', action: async () => console.log('Shadow 2') }
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div style="padding: 40px; background: #eee; border-radius: 8px;">
|
||||
<h3>Shadow DOM Content</h3>
|
||||
<p>Right-click anywhere inside this shadow DOM</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
tap.test('should show context menu when right-clicking inside shadow DOM', async () => {
|
||||
// Create the shadow DOM element
|
||||
const shadowElement = document.createElement('test-shadow-element');
|
||||
document.body.appendChild(shadowElement);
|
||||
|
||||
// Wait for element to be ready
|
||||
await shadowElement.updateComplete;
|
||||
|
||||
// Get the content inside shadow DOM
|
||||
const shadowContent = shadowElement.shadowRoot!.querySelector('div');
|
||||
expect(shadowContent).toBeTruthy();
|
||||
|
||||
// Simulate right-click on content inside shadow DOM
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true // Important for shadow DOM
|
||||
});
|
||||
|
||||
shadowContent!.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Check if menu items from shadow element are rendered
|
||||
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
|
||||
expect(menuItems.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Check menu item text
|
||||
const menuTexts = Array.from(menuItems).map(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent
|
||||
);
|
||||
expect(menuTexts).toContain('Shadow Item 1');
|
||||
expect(menuTexts).toContain('Shadow Item 2');
|
||||
|
||||
// Clean up
|
||||
contextMenu!.remove();
|
||||
shadowElement.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
77
test/test.contextmenu.browser.ts
Normal file
77
test/test.contextmenu.browser.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should show context menu with nested submenu', async () => {
|
||||
// Create a test element with context menu items
|
||||
const testDiv = document.createElement('div');
|
||||
testDiv.style.width = '200px';
|
||||
testDiv.style.height = '200px';
|
||||
testDiv.style.background = '#eee';
|
||||
testDiv.innerHTML = 'Right-click me';
|
||||
|
||||
// Add getContextMenuItems method
|
||||
(testDiv as any).getContextMenuItems = () => {
|
||||
return [
|
||||
{
|
||||
name: 'Change Type',
|
||||
iconName: 'type',
|
||||
submenu: [
|
||||
{ name: 'Paragraph', iconName: 'text', action: () => console.log('Paragraph') },
|
||||
{ name: 'Heading 1', iconName: 'heading1', action: () => console.log('Heading 1') },
|
||||
{ name: 'Heading 2', iconName: 'heading2', action: () => console.log('Heading 2') },
|
||||
{ divider: true },
|
||||
{ name: 'Code Block', iconName: 'fileCode', action: () => console.log('Code') },
|
||||
{ name: 'Quote', iconName: 'quote', action: () => console.log('Quote') }
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'trash2',
|
||||
action: () => console.log('Delete')
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
document.body.appendChild(testDiv);
|
||||
|
||||
// Simulate right-click
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
testDiv.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Check if menu items are rendered
|
||||
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
|
||||
expect(menuItems.length).toEqual(2); // "Change Type" and "Delete"
|
||||
|
||||
// Hover over "Change Type" to trigger submenu
|
||||
const changeTypeItem = menuItems[0] as HTMLElement;
|
||||
changeTypeItem.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
// Wait for submenu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check if submenu is created
|
||||
const submenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(submenus.length).toEqual(2); // Main menu and submenu
|
||||
|
||||
// Clean up
|
||||
contextMenu!.remove();
|
||||
const submenu = submenus[1];
|
||||
if (submenu) submenu.remove();
|
||||
testDiv.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
28
test/test.dashboardgrid-layout.node.ts
Normal file
28
test/test.dashboardgrid-layout.node.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
|
||||
import {
|
||||
resolveWidgetPlacement,
|
||||
collectCollisions,
|
||||
} from '../ts_web/elements/dees-dashboardgrid/layout.ts';
|
||||
import type { DashboardWidget } from '../ts_web/elements/dees-dashboardgrid/types.ts';
|
||||
|
||||
tap.test('dashboardgrid does not overlap widgets after swap attempt', async () => {
|
||||
const widgets: DashboardWidget[] = [
|
||||
{ id: 'w0', x: 6, y: 5, w: 1, h: 3 },
|
||||
{ id: 'w1', x: 6, y: 1, w: 1, h: 3 },
|
||||
{ id: 'w2', x: 3, y: 0, w: 2, h: 2 },
|
||||
{ id: 'w3', x: 9, y: 0, w: 1, h: 2 },
|
||||
{ id: 'w4', x: 4, y: 3, w: 1, h: 2 },
|
||||
];
|
||||
|
||||
const placement = resolveWidgetPlacement(widgets, 'w0', { x: 6, y: 3 }, 12);
|
||||
expect(placement).toBeTruthy();
|
||||
|
||||
const layout = placement!.widgets;
|
||||
for (const widget of layout) {
|
||||
const collisions = collectCollisions(layout, widget, widget.x, widget.y, widget.w, widget.h);
|
||||
expect(collisions).toBeEmptyArray();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
175
test/test.shadow-dom-containment.browser.ts
Normal file
175
test/test.shadow-dom-containment.browser.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { expect, tap, webhelpers } from '@push.rocks/tapbundle';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
import { WysiwygSelection } from '../ts_web/elements/wysiwyg/wysiwyg.selection.js';
|
||||
|
||||
tap.test('Shadow DOM containment should work correctly', async () => {
|
||||
console.log('=== Testing Shadow DOM Containment ===');
|
||||
|
||||
// Create a WYSIWYG block component
|
||||
const block = await webhelpers.fixture<DeesWysiwygBlock>(
|
||||
'<dees-wysiwyg-block></dees-wysiwyg-block>'
|
||||
);
|
||||
|
||||
// Set the block data
|
||||
block.block = {
|
||||
id: 'test-1',
|
||||
type: 'paragraph',
|
||||
content: 'Hello world test content'
|
||||
};
|
||||
|
||||
block.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {}
|
||||
};
|
||||
|
||||
await block.updateComplete;
|
||||
|
||||
// Get the paragraph element inside Shadow DOM
|
||||
const container = block.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
expect(paragraphBlock).toBeTruthy();
|
||||
console.log('Found paragraph block:', paragraphBlock);
|
||||
console.log('Paragraph text content:', paragraphBlock.textContent);
|
||||
|
||||
// Focus the paragraph
|
||||
paragraphBlock.focus();
|
||||
|
||||
// Manually set cursor position
|
||||
const textNode = paragraphBlock.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
|
||||
// Set cursor at position 11 (after "Hello world")
|
||||
range.setStart(textNode, 11);
|
||||
range.setEnd(textNode, 11);
|
||||
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
console.log('Set cursor at position 11');
|
||||
|
||||
// Test the containment check
|
||||
console.log('\n--- Testing containment ---');
|
||||
const currentSelection = window.getSelection();
|
||||
if (currentSelection && currentSelection.rangeCount > 0) {
|
||||
const selRange = currentSelection.getRangeAt(0);
|
||||
console.log('Selection range:', {
|
||||
startContainer: selRange.startContainer,
|
||||
startOffset: selRange.startOffset,
|
||||
containerText: selRange.startContainer.textContent
|
||||
});
|
||||
|
||||
// Test regular contains (should fail across Shadow DOM)
|
||||
const regularContains = paragraphBlock.contains(selRange.startContainer);
|
||||
console.log('Regular contains:', regularContains);
|
||||
|
||||
// Test Shadow DOM-aware contains
|
||||
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selRange.startContainer);
|
||||
console.log('Shadow DOM contains:', shadowDOMContains);
|
||||
|
||||
// Since we're setting selection within the same shadow DOM, both should be true
|
||||
expect(regularContains).toBeTrue();
|
||||
expect(shadowDOMContains).toBeTrue();
|
||||
}
|
||||
|
||||
// Test getSplitContent
|
||||
console.log('\n--- Testing getSplitContent ---');
|
||||
const splitResult = block.getSplitContent();
|
||||
console.log('Split result:', splitResult);
|
||||
|
||||
expect(splitResult).toBeTruthy();
|
||||
if (splitResult) {
|
||||
console.log('Before:', JSON.stringify(splitResult.before));
|
||||
console.log('After:', JSON.stringify(splitResult.after));
|
||||
|
||||
// Expected split at position 11
|
||||
expect(splitResult.before).toEqual('Hello world');
|
||||
expect(splitResult.after).toEqual(' test content');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Shadow DOM containment across different shadow roots', async () => {
|
||||
console.log('=== Testing Cross Shadow Root Containment ===');
|
||||
|
||||
// Create parent component with WYSIWYG editor
|
||||
const parentDiv = document.createElement('div');
|
||||
parentDiv.innerHTML = `
|
||||
<dees-input-wysiwyg>
|
||||
<dees-wysiwyg-block></dees-wysiwyg-block>
|
||||
</dees-input-wysiwyg>
|
||||
`;
|
||||
document.body.appendChild(parentDiv);
|
||||
|
||||
// Wait for components to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
|
||||
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
|
||||
if (blockElement) {
|
||||
// Set block data
|
||||
blockElement.block = {
|
||||
id: 'test-2',
|
||||
type: 'paragraph',
|
||||
content: 'Cross shadow DOM test'
|
||||
};
|
||||
|
||||
blockElement.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {}
|
||||
};
|
||||
|
||||
await blockElement.updateComplete;
|
||||
|
||||
// Get the paragraph inside the nested shadow DOM
|
||||
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
if (paragraphBlock) {
|
||||
console.log('Found nested paragraph block');
|
||||
|
||||
// Focus and set selection
|
||||
paragraphBlock.focus();
|
||||
const textNode = paragraphBlock.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, 5);
|
||||
range.setEnd(textNode, 5);
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// Test containment from parent's perspective
|
||||
const selRange = selection?.getRangeAt(0);
|
||||
if (selRange) {
|
||||
// This should fail because it crosses shadow DOM boundary
|
||||
const regularContains = wysiwygInput.contains(selRange.startContainer);
|
||||
console.log('Parent regular contains:', regularContains);
|
||||
expect(regularContains).toBeFalse();
|
||||
|
||||
// This should work with our Shadow DOM-aware method
|
||||
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
|
||||
console.log('Parent shadow DOM contains:', shadowDOMContains);
|
||||
expect(shadowDOMContains).toBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(parentDiv);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
146
test/test.tabs-indicator.browser.ts
Normal file
146
test/test.tabs-indicator.browser.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as deesCatalog from '../ts_web/index.js';
|
||||
|
||||
tap.test('tabs indicator positioning - detailed measurements', async () => {
|
||||
// Create tabs element with different length labels
|
||||
const tabsElement = new deesCatalog.DeesAppuiTabs();
|
||||
tabsElement.tabs = [
|
||||
{ key: 'Home', iconName: 'lucide:home', action: () => {} },
|
||||
{ key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => {} },
|
||||
{ key: 'User Settings', iconName: 'lucide:settings', action: () => {} },
|
||||
];
|
||||
|
||||
document.body.appendChild(tabsElement);
|
||||
await tabsElement.updateComplete;
|
||||
|
||||
// Wait for fonts and indicator initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Get all elements
|
||||
const shadowRoot = tabsElement.shadowRoot;
|
||||
const wrapper = shadowRoot.querySelector('.tabs-wrapper') as HTMLElement;
|
||||
const container = shadowRoot.querySelector('.tabsContainer') as HTMLElement;
|
||||
const tabs = shadowRoot.querySelectorAll('.tab');
|
||||
const firstTab = tabs[0] as HTMLElement;
|
||||
const firstContent = firstTab.querySelector('.tab-content') as HTMLElement;
|
||||
const indicator = shadowRoot.querySelector('.tabIndicator') as HTMLElement;
|
||||
|
||||
// Verify all elements exist
|
||||
expect(wrapper).toBeInstanceOf(HTMLElement);
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
expect(firstTab).toBeInstanceOf(HTMLElement);
|
||||
expect(firstContent).toBeInstanceOf(HTMLElement);
|
||||
expect(indicator).toBeInstanceOf(HTMLElement);
|
||||
|
||||
// Get all measurements
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = firstTab.getBoundingClientRect();
|
||||
const contentRect = firstContent.getBoundingClientRect();
|
||||
const indicatorRect = indicator.getBoundingClientRect();
|
||||
|
||||
console.log('\n=== DETAILED MEASUREMENTS ===');
|
||||
console.log('Document body left:', document.body.getBoundingClientRect().left);
|
||||
console.log('Wrapper left:', wrapperRect.left);
|
||||
console.log('Container left:', containerRect.left);
|
||||
console.log('Tab left:', tabRect.left);
|
||||
console.log('Content left:', contentRect.left);
|
||||
console.log('Indicator left (actual):', indicatorRect.left);
|
||||
|
||||
console.log('\n=== RELATIVE POSITIONS ===');
|
||||
console.log('Container padding (container - wrapper):', containerRect.left - wrapperRect.left);
|
||||
console.log('Tab position in container:', tabRect.left - containerRect.left);
|
||||
console.log('Content position in tab:', contentRect.left - tabRect.left);
|
||||
console.log('Content relative to wrapper:', contentRect.left - wrapperRect.left);
|
||||
console.log('Indicator relative to wrapper (actual):', indicatorRect.left - wrapperRect.left);
|
||||
|
||||
console.log('\n=== WIDTHS ===');
|
||||
console.log('Tab width:', tabRect.width);
|
||||
console.log('Content width:', contentRect.width);
|
||||
console.log('Indicator width:', indicatorRect.width);
|
||||
|
||||
console.log('\n=== STYLES (what we set) ===');
|
||||
console.log('Indicator style.left:', indicator.style.left);
|
||||
console.log('Indicator style.width:', indicator.style.width);
|
||||
|
||||
console.log('\n=== CALCULATIONS ===');
|
||||
const expectedIndicatorLeft = contentRect.left - wrapperRect.left - 4; // We subtract 4 to center
|
||||
const expectedIndicatorWidth = contentRect.width + 8; // We add 8 in the code
|
||||
console.log('Expected indicator left:', expectedIndicatorLeft);
|
||||
console.log('Expected indicator width:', expectedIndicatorWidth);
|
||||
console.log('Actual indicator left (from style):', parseFloat(indicator.style.left));
|
||||
console.log('Actual indicator width (from style):', parseFloat(indicator.style.width));
|
||||
|
||||
console.log('\n=== VISUAL ALIGNMENT CHECK ===');
|
||||
const tabCenter = tabRect.left + (tabRect.width / 2);
|
||||
const contentCenter = contentRect.left + (contentRect.width / 2);
|
||||
const indicatorCenter = indicatorRect.left + (indicatorRect.width / 2);
|
||||
|
||||
console.log('Tab center:', tabCenter);
|
||||
console.log('Content center:', contentCenter);
|
||||
console.log('Indicator center:', indicatorCenter);
|
||||
console.log('Content offset from tab center:', contentCenter - tabCenter);
|
||||
console.log('Indicator offset from content center:', indicatorCenter - contentCenter);
|
||||
console.log('Indicator offset from tab center:', indicatorCenter - tabCenter);
|
||||
console.log('---');
|
||||
console.log('Indicator extends left of content by:', contentRect.left - indicatorRect.left);
|
||||
console.log('Indicator extends right of content by:', (indicatorRect.left + indicatorRect.width) - (contentRect.left + contentRect.width));
|
||||
|
||||
// Check if icons are rendering
|
||||
const icon = firstContent.querySelector('dees-icon');
|
||||
console.log('\n=== ICON CHECK ===');
|
||||
console.log('Icon element found:', icon ? 'YES' : 'NO');
|
||||
if (icon) {
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
console.log('Icon width:', iconRect.width);
|
||||
console.log('Icon height:', iconRect.height);
|
||||
console.log('Icon visible:', iconRect.width > 0 && iconRect.height > 0 ? 'YES' : 'NO');
|
||||
}
|
||||
|
||||
// Verify indicator is visible
|
||||
expect(indicator.style.opacity).toEqual('1');
|
||||
|
||||
// Verify positioning calculations
|
||||
expect(parseFloat(indicator.style.left)).toBeCloseTo(expectedIndicatorLeft, 1);
|
||||
expect(parseFloat(indicator.style.width)).toBeCloseTo(expectedIndicatorWidth, 1);
|
||||
|
||||
// Verify visual centering on content (should be perfectly centered)
|
||||
expect(Math.abs(indicatorCenter - contentCenter)).toBeLessThan(1);
|
||||
|
||||
document.body.removeChild(tabsElement);
|
||||
});
|
||||
|
||||
tap.test('tabs indicator should move when tab is clicked', async () => {
|
||||
// Create tabs element
|
||||
const tabsElement = new deesCatalog.DeesAppuiTabs();
|
||||
tabsElement.tabs = [
|
||||
{ key: 'Home', iconName: 'lucide:home', action: () => {} },
|
||||
{ key: 'Analytics', iconName: 'lucide:barChart', action: () => {} },
|
||||
{ key: 'Settings', iconName: 'lucide:settings', action: () => {} },
|
||||
];
|
||||
|
||||
document.body.appendChild(tabsElement);
|
||||
await tabsElement.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const shadowRoot = tabsElement.shadowRoot;
|
||||
const tabs = shadowRoot.querySelectorAll('.tab');
|
||||
const indicator = shadowRoot.querySelector('.tabIndicator') as HTMLElement;
|
||||
|
||||
// Get initial position
|
||||
const initialLeft = parseFloat(indicator.style.left);
|
||||
|
||||
// Click second tab
|
||||
(tabs[1] as HTMLElement).click();
|
||||
await tabsElement.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Position should have changed
|
||||
const newLeft = parseFloat(indicator.style.left);
|
||||
expect(newLeft).not.toEqual(initialLeft);
|
||||
expect(newLeft).toBeGreaterThan(initialLeft);
|
||||
|
||||
document.body.removeChild(tabsElement);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
9
test/test.wysiwyg-basic.browser.ts
Normal file
9
test/test.wysiwyg-basic.browser.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
tap.test('should create wysiwyg editor', async () => {
|
||||
const editor = new DeesInputWysiwyg();
|
||||
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
85
test/test.wysiwyg-blockmovement.browser.ts
Normal file
85
test/test.wysiwyg-blockmovement.browser.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg block movement during drag', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
|
||||
// Start dragging block 1
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: () => {},
|
||||
setDragImage: () => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Verify drag state
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Check that drag height was calculated
|
||||
console.log('Checking drag height...');
|
||||
const dragHandler = element.dragDropHandler as any;
|
||||
console.log('draggedBlockHeight:', dragHandler.draggedBlockHeight);
|
||||
console.log('draggedBlockContentHeight:', dragHandler.draggedBlockContentHeight);
|
||||
|
||||
// Manually call updateBlockPositions to simulate drag movement
|
||||
console.log('Simulating drag movement...');
|
||||
const updateBlockPositions = dragHandler.updateBlockPositions.bind(dragHandler);
|
||||
|
||||
// Simulate dragging down past block 2
|
||||
const block2 = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
|
||||
const block2Rect = block2.getBoundingClientRect();
|
||||
const dragToY = block2Rect.bottom + 10;
|
||||
|
||||
console.log('Dragging to Y position:', dragToY);
|
||||
updateBlockPositions(dragToY);
|
||||
|
||||
// Check if blocks have moved
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
console.log('Block states after drag:');
|
||||
blocks.forEach((block, i) => {
|
||||
const classes = block.className;
|
||||
const offset = (block as HTMLElement).style.getPropertyValue('--drag-offset');
|
||||
console.log(`Block ${i}: classes="${classes}", offset="${offset}"`);
|
||||
});
|
||||
|
||||
// Check that at least one block has move class
|
||||
const movedUpBlocks = editorContent.querySelectorAll('.block-wrapper.move-up');
|
||||
const movedDownBlocks = editorContent.querySelectorAll('.block-wrapper.move-down');
|
||||
console.log('Moved up blocks:', movedUpBlocks.length);
|
||||
console.log('Moved down blocks:', movedDownBlocks.length);
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
69
test/test.wysiwyg-blocks-debug.browser.ts
Normal file
69
test/test.wysiwyg-blocks-debug.browser.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
||||
|
||||
import * as deesCatalog from '../ts_web/index.js';
|
||||
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
// Import block registration to ensure handlers are registered
|
||||
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||
|
||||
tap.test('Debug: should create empty wysiwyg block component', async () => {
|
||||
try {
|
||||
console.log('Creating DeesWysiwygBlock...');
|
||||
const block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||
);
|
||||
console.log('Block created:', block);
|
||||
expect(block).toBeDefined();
|
||||
expect(block).toBeInstanceOf(DeesWysiwygBlock);
|
||||
console.log('Initial block property:', block.block);
|
||||
console.log('Initial handlers property:', block.handlers);
|
||||
} catch (error) {
|
||||
console.error('Error creating block:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Debug: should set properties step by step', async () => {
|
||||
try {
|
||||
console.log('Step 1: Creating component...');
|
||||
const block: DeesWysiwygBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
expect(block).toBeDefined();
|
||||
|
||||
console.log('Step 2: Setting handlers...');
|
||||
block.handlers = {
|
||||
onInput: () => console.log('onInput'),
|
||||
onKeyDown: () => console.log('onKeyDown'),
|
||||
onFocus: () => console.log('onFocus'),
|
||||
onBlur: () => console.log('onBlur'),
|
||||
onCompositionStart: () => console.log('onCompositionStart'),
|
||||
onCompositionEnd: () => console.log('onCompositionEnd')
|
||||
};
|
||||
console.log('Handlers set:', block.handlers);
|
||||
|
||||
console.log('Step 3: Setting block data...');
|
||||
block.block = {
|
||||
id: 'test-block',
|
||||
type: 'divider',
|
||||
content: ' '
|
||||
};
|
||||
console.log('Block set:', block.block);
|
||||
|
||||
console.log('Step 4: Appending to body...');
|
||||
document.body.appendChild(block);
|
||||
|
||||
console.log('Step 5: Waiting for update...');
|
||||
await block.updateComplete;
|
||||
console.log('Update complete');
|
||||
|
||||
console.log('Step 6: Checking shadowRoot...');
|
||||
expect(block.shadowRoot).toBeDefined();
|
||||
console.log('ShadowRoot exists');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in step-by-step test:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
205
test/test.wysiwyg-blocks.browser.ts
Normal file
205
test/test.wysiwyg-blocks.browser.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
||||
|
||||
import * as deesCatalog from '../ts_web/index.js';
|
||||
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
// Import block registration to ensure handlers are registered
|
||||
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||
|
||||
tap.test('BlockRegistry should have registered handlers', async () => {
|
||||
// Test divider handler
|
||||
const dividerHandler = BlockRegistry.getHandler('divider');
|
||||
expect(dividerHandler).toBeDefined();
|
||||
expect(dividerHandler?.type).toEqual('divider');
|
||||
|
||||
// Test paragraph handler
|
||||
const paragraphHandler = BlockRegistry.getHandler('paragraph');
|
||||
expect(paragraphHandler).toBeDefined();
|
||||
expect(paragraphHandler?.type).toEqual('paragraph');
|
||||
|
||||
// Test heading handlers
|
||||
const heading1Handler = BlockRegistry.getHandler('heading-1');
|
||||
expect(heading1Handler).toBeDefined();
|
||||
expect(heading1Handler?.type).toEqual('heading-1');
|
||||
|
||||
const heading2Handler = BlockRegistry.getHandler('heading-2');
|
||||
expect(heading2Handler).toBeDefined();
|
||||
expect(heading2Handler?.type).toEqual('heading-2');
|
||||
|
||||
const heading3Handler = BlockRegistry.getHandler('heading-3');
|
||||
expect(heading3Handler).toBeDefined();
|
||||
expect(heading3Handler?.type).toEqual('heading-3');
|
||||
|
||||
// Test that getAllTypes returns all registered types
|
||||
const allTypes = BlockRegistry.getAllTypes();
|
||||
expect(allTypes).toContain('divider');
|
||||
expect(allTypes).toContain('paragraph');
|
||||
expect(allTypes).toContain('heading-1');
|
||||
expect(allTypes).toContain('heading-2');
|
||||
expect(allTypes).toContain('heading-3');
|
||||
});
|
||||
|
||||
tap.test('should render divider block using handler', async () => {
|
||||
const dividerBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||
);
|
||||
|
||||
// Set required handlers
|
||||
dividerBlock.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {}
|
||||
};
|
||||
|
||||
// Set a divider block
|
||||
dividerBlock.block = {
|
||||
id: 'test-divider',
|
||||
type: 'divider',
|
||||
content: ' '
|
||||
};
|
||||
|
||||
await dividerBlock.updateComplete;
|
||||
|
||||
// Check that the divider is rendered
|
||||
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
|
||||
expect(dividerElement).toBeDefined();
|
||||
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
|
||||
|
||||
// Check for the divider icon
|
||||
const icon = dividerBlock.shadowRoot?.querySelector('.divider-icon');
|
||||
expect(icon).toBeDefined();
|
||||
});
|
||||
|
||||
tap.test('should render paragraph block using handler', async () => {
|
||||
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||
);
|
||||
|
||||
// Set required handlers
|
||||
paragraphBlock.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {},
|
||||
onMouseUp: () => {}
|
||||
};
|
||||
|
||||
// Set a paragraph block
|
||||
paragraphBlock.block = {
|
||||
id: 'test-paragraph',
|
||||
type: 'paragraph',
|
||||
content: 'Test paragraph content'
|
||||
};
|
||||
|
||||
await paragraphBlock.updateComplete;
|
||||
|
||||
// Check that the paragraph is rendered
|
||||
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
|
||||
expect(paragraphElement).toBeDefined();
|
||||
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
|
||||
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
|
||||
});
|
||||
|
||||
tap.test('should render heading blocks using handler', async () => {
|
||||
// Test heading-1
|
||||
const heading1Block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||
);
|
||||
|
||||
// Set required handlers
|
||||
heading1Block.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {},
|
||||
onMouseUp: () => {}
|
||||
};
|
||||
|
||||
heading1Block.block = {
|
||||
id: 'test-h1',
|
||||
type: 'heading-1',
|
||||
content: 'Heading 1 Test'
|
||||
};
|
||||
|
||||
await heading1Block.updateComplete;
|
||||
|
||||
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
|
||||
expect(h1Element).toBeDefined();
|
||||
expect(h1Element?.textContent).toEqual('Heading 1 Test');
|
||||
|
||||
// Test heading-2
|
||||
const heading2Block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||
);
|
||||
|
||||
// Set required handlers
|
||||
heading2Block.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {},
|
||||
onMouseUp: () => {}
|
||||
};
|
||||
|
||||
heading2Block.block = {
|
||||
id: 'test-h2',
|
||||
type: 'heading-2',
|
||||
content: 'Heading 2 Test'
|
||||
};
|
||||
|
||||
await heading2Block.updateComplete;
|
||||
|
||||
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
|
||||
expect(h2Element).toBeDefined();
|
||||
expect(h2Element?.textContent).toEqual('Heading 2 Test');
|
||||
});
|
||||
|
||||
tap.test('paragraph block handler methods should work', async () => {
|
||||
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||
);
|
||||
|
||||
// Set required handlers
|
||||
paragraphBlock.handlers = {
|
||||
onInput: () => {},
|
||||
onKeyDown: () => {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onCompositionStart: () => {},
|
||||
onCompositionEnd: () => {},
|
||||
onMouseUp: () => {}
|
||||
};
|
||||
|
||||
paragraphBlock.block = {
|
||||
id: 'test-methods',
|
||||
type: 'paragraph',
|
||||
content: 'Initial content'
|
||||
};
|
||||
|
||||
await paragraphBlock.updateComplete;
|
||||
|
||||
// Test getContent
|
||||
const content = paragraphBlock.getContent();
|
||||
expect(content).toEqual('Initial content');
|
||||
|
||||
// Test setContent
|
||||
paragraphBlock.setContent('Updated content');
|
||||
await paragraphBlock.updateComplete;
|
||||
expect(paragraphBlock.getContent()).toEqual('Updated content');
|
||||
|
||||
// Test that the DOM is updated
|
||||
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
|
||||
expect(paragraphElement?.textContent).toEqual('Updated content');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
98
test/test.wysiwyg-blocktype-change.browser.ts
Normal file
98
test/test.wysiwyg-blocktype-change.browser.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should change block type via context menu', async () => {
|
||||
// Create WYSIWYG editor with a paragraph
|
||||
const wysiwygEditor = new DeesInputWysiwyg();
|
||||
wysiwygEditor.value = '<p>This is a test paragraph</p>';
|
||||
document.body.appendChild(wysiwygEditor);
|
||||
|
||||
// Wait for editor to be ready
|
||||
await wysiwygEditor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get the first block
|
||||
const firstBlock = wysiwygEditor.blocks[0];
|
||||
expect(firstBlock.type).toEqual('paragraph');
|
||||
|
||||
// Get the block element
|
||||
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
|
||||
expect(firstBlockWrapper).toBeTruthy();
|
||||
|
||||
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
|
||||
expect(blockComponent).toBeTruthy();
|
||||
await blockComponent.updateComplete;
|
||||
|
||||
// Get the editable content inside the block's shadow DOM
|
||||
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
|
||||
expect(editableBlock).toBeTruthy();
|
||||
|
||||
// Simulate right-click on the editable block
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
editableBlock!.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Find "Change Type" menu item
|
||||
const menuItems = Array.from(contextMenu!.shadowRoot!.querySelectorAll('.menuitem'));
|
||||
const changeTypeItem = menuItems.find(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
|
||||
);
|
||||
expect(changeTypeItem).toBeTruthy();
|
||||
|
||||
// Hover over "Change Type" to trigger submenu
|
||||
changeTypeItem!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
// Wait for submenu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Check if submenu is created
|
||||
const allMenus = document.querySelectorAll('dees-contextmenu');
|
||||
expect(allMenus.length).toEqual(2);
|
||||
|
||||
const submenu = allMenus[1];
|
||||
const submenuItems = Array.from(submenu.shadowRoot!.querySelectorAll('.menuitem'));
|
||||
|
||||
// Find "Heading 1" option
|
||||
const heading1Item = submenuItems.find(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Heading 1'
|
||||
);
|
||||
expect(heading1Item).toBeTruthy();
|
||||
|
||||
// Click on "Heading 1"
|
||||
(heading1Item as HTMLElement).click();
|
||||
|
||||
// Wait for menu to close and block to update
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Verify block type has changed
|
||||
expect(wysiwygEditor.blocks[0].type).toEqual('heading-1');
|
||||
|
||||
// Verify DOM has been updated
|
||||
const updatedBlockComponent = wysiwygEditor.shadowRoot!
|
||||
.querySelector('.block-wrapper')!
|
||||
.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
await updatedBlockComponent.updateComplete;
|
||||
|
||||
const updatedBlock = updatedBlockComponent.shadowRoot!.querySelector('.block');
|
||||
expect(updatedBlock?.classList.contains('heading-1')).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
wysiwygEditor.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
68
test/test.wysiwyg-contextmenu.browser.ts
Normal file
68
test/test.wysiwyg-contextmenu.browser.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesContextmenu } from '../ts_web/elements/dees-contextmenu.js';
|
||||
|
||||
tap.test('should show context menu on WYSIWYG blocks', async () => {
|
||||
// Create WYSIWYG editor
|
||||
const wysiwygEditor = new DeesInputWysiwyg();
|
||||
wysiwygEditor.value = '<p>Test paragraph</p><h1>Test heading</h1>';
|
||||
document.body.appendChild(wysiwygEditor);
|
||||
|
||||
// Wait for editor to be ready
|
||||
await wysiwygEditor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get the first block element
|
||||
const firstBlockWrapper = wysiwygEditor.shadowRoot!.querySelector('.block-wrapper');
|
||||
expect(firstBlockWrapper).toBeTruthy();
|
||||
|
||||
const blockComponent = firstBlockWrapper!.querySelector('dees-wysiwyg-block') as any;
|
||||
expect(blockComponent).toBeTruthy();
|
||||
|
||||
// Wait for block to be ready
|
||||
await blockComponent.updateComplete;
|
||||
|
||||
// Get the editable content inside the block's shadow DOM
|
||||
const editableBlock = blockComponent.shadowRoot!.querySelector('.block');
|
||||
expect(editableBlock).toBeTruthy();
|
||||
|
||||
// Simulate right-click on the editable block
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true // Important for shadow DOM
|
||||
});
|
||||
|
||||
editableBlock!.dispatchEvent(contextMenuEvent);
|
||||
|
||||
// Wait for context menu to appear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if context menu is created
|
||||
const contextMenu = document.querySelector('dees-contextmenu');
|
||||
expect(contextMenu).toBeInstanceOf(DeesContextmenu);
|
||||
|
||||
// Check if menu items from WYSIWYG block are rendered
|
||||
const menuItems = contextMenu!.shadowRoot!.querySelectorAll('.menuitem');
|
||||
const menuTexts = Array.from(menuItems).map(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim()
|
||||
);
|
||||
|
||||
// Should have "Change Type" and "Delete Block" items
|
||||
expect(menuTexts).toContain('Change Type');
|
||||
expect(menuTexts).toContain('Delete Block');
|
||||
|
||||
// Check if "Change Type" has submenu indicator
|
||||
const changeTypeItem = Array.from(menuItems).find(item =>
|
||||
item.querySelector('.menuitem-text')?.textContent?.trim() === 'Change Type'
|
||||
);
|
||||
expect(changeTypeItem?.classList.contains('has-submenu')).toEqual(true);
|
||||
|
||||
// Clean up
|
||||
contextMenu!.remove();
|
||||
wysiwygEditor.remove();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
95
test/test.wysiwyg-dragdrop-simple.browser.ts
Normal file
95
test/test.wysiwyg-dragdrop-simple.browser.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag handler initialization', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for element to be ready
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that drag handler is initialized
|
||||
expect(element.dragDropHandler).toBeTruthy();
|
||||
|
||||
// Set initial content with multiple blocks
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Second paragraph' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that editor content ref exists
|
||||
console.log('editorContentRef:', element.editorContentRef);
|
||||
expect(element.editorContentRef).toBeTruthy();
|
||||
|
||||
// Check that blocks are rendered
|
||||
const blockWrappers = element.shadowRoot!.querySelectorAll('.block-wrapper');
|
||||
console.log('Number of block wrappers:', blockWrappers.length);
|
||||
expect(blockWrappers.length).toEqual(2);
|
||||
|
||||
// Check drag handles
|
||||
const dragHandles = element.shadowRoot!.querySelectorAll('.drag-handle');
|
||||
console.log('Number of drag handles:', dragHandles.length);
|
||||
expect(dragHandles.length).toEqual(2);
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag start behavior', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const dragHandle = element.shadowRoot!.querySelector('.drag-handle') as HTMLElement;
|
||||
expect(dragHandle).toBeTruthy();
|
||||
|
||||
// Check that drag handle has draggable attribute
|
||||
console.log('Drag handle draggable:', dragHandle.draggable);
|
||||
expect(dragHandle.draggable).toBeTrue();
|
||||
|
||||
// Test drag handler state before drag
|
||||
console.log('Initial drag state:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
|
||||
|
||||
// Try to manually call handleDragStart
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData called with:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage called');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Check drag state after drag start
|
||||
console.log('Drag state after start:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
133
test/test.wysiwyg-dragdrop-visual.browser.ts
Normal file
133
test/test.wysiwyg-dragdrop-visual.browser.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag visual feedback - block movement', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
|
||||
// Manually start drag
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {},
|
||||
setDragImage: (img: any, x: number, y: number) => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Check dragging state
|
||||
console.log('Block 1 classes:', block1.className);
|
||||
console.log('Editor content classes:', editorContent.className);
|
||||
expect(block1.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Check drop indicator exists
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
console.log('Drop indicator:', dropIndicator);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Test block movement calculation
|
||||
console.log('Testing updateBlockPositions...');
|
||||
|
||||
// Access private method for testing
|
||||
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
|
||||
|
||||
// Simulate dragging to different position
|
||||
updateBlockPositions(150); // Move down
|
||||
|
||||
// Check if blocks have move classes
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
console.log('Block classes after move:');
|
||||
blocks.forEach((block, i) => {
|
||||
console.log(`Block ${i}:`, block.className, 'transform:', (block as HTMLElement).style.getPropertyValue('--drag-offset'));
|
||||
});
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drop indicator positioning', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Paragraph 1' },
|
||||
{ id: 'block2', type: 'heading-2', content: 'Heading 2' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Start dragging first block
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {},
|
||||
setDragImage: (img: any, x: number, y: number) => {}
|
||||
},
|
||||
clientY: 50,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Get drop indicator
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Check initial display state
|
||||
console.log('Drop indicator initial display:', dropIndicator.style.display);
|
||||
|
||||
// Trigger updateBlockPositions to see drop indicator
|
||||
const updateBlockPositions = element.dragDropHandler['updateBlockPositions'].bind(element.dragDropHandler);
|
||||
updateBlockPositions(100);
|
||||
|
||||
// Check drop indicator position
|
||||
console.log('Drop indicator after update:');
|
||||
console.log('- display:', dropIndicator.style.display);
|
||||
console.log('- top:', dropIndicator.style.top);
|
||||
console.log('- height:', dropIndicator.style.height);
|
||||
|
||||
expect(dropIndicator.style.display).toEqual('block');
|
||||
expect(dropIndicator.style.top).toBeTruthy();
|
||||
expect(dropIndicator.style.height).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
172
test/test.wysiwyg-dragdrop.browser.ts
Normal file
172
test/test.wysiwyg-dragdrop.browser.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag and drop should work correctly', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Wait for element to be ready
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content with multiple blocks
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block2', type: 'heading-2', content: 'Test Heading' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Second paragraph' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check that blocks are rendered
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
expect(editorContent).toBeTruthy();
|
||||
|
||||
const blockWrappers = editorContent.querySelectorAll('.block-wrapper');
|
||||
expect(blockWrappers.length).toEqual(3);
|
||||
|
||||
// Test drag handles exist for non-divider blocks
|
||||
const dragHandles = editorContent.querySelectorAll('.drag-handle');
|
||||
expect(dragHandles.length).toEqual(3);
|
||||
|
||||
// Get references to specific blocks
|
||||
const firstBlock = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const secondBlock = editorContent.querySelector('[data-block-id="block2"]') as HTMLElement;
|
||||
const firstDragHandle = firstBlock.querySelector('.drag-handle') as HTMLElement;
|
||||
|
||||
expect(firstBlock).toBeTruthy();
|
||||
expect(secondBlock).toBeTruthy();
|
||||
expect(firstDragHandle).toBeTruthy();
|
||||
|
||||
// Test drag initialization
|
||||
console.log('Testing drag initialization...');
|
||||
|
||||
// Create drag event
|
||||
const dragStartEvent = new DragEvent('dragstart', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 100,
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
// Simulate drag start
|
||||
firstDragHandle.dispatchEvent(dragStartEvent);
|
||||
|
||||
// Check that drag state is initialized
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Check that dragging class is applied
|
||||
await new Promise(resolve => setTimeout(resolve, 20)); // Wait for setTimeout in drag start
|
||||
expect(firstBlock.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Test drop indicator creation
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator');
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Simulate drag over
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 200,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(dragOverEvent);
|
||||
|
||||
// Check that blocks move out of the way
|
||||
console.log('Checking block movements...');
|
||||
const blocks = Array.from(editorContent.querySelectorAll('.block-wrapper'));
|
||||
const hasMovedBlocks = blocks.some(block =>
|
||||
block.classList.contains('move-up') || block.classList.contains('move-down')
|
||||
);
|
||||
|
||||
console.log('Blocks with move classes:', blocks.filter(block =>
|
||||
block.classList.contains('move-up') || block.classList.contains('move-down')
|
||||
).length);
|
||||
|
||||
// Test drag end
|
||||
const dragEndEvent = new DragEvent('dragend', {
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(dragEndEvent);
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Check that drag state is cleaned up
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toBeNull();
|
||||
expect(firstBlock.classList.contains('dragging')).toBeFalse();
|
||||
expect(editorContent.classList.contains('dragging')).toBeFalse();
|
||||
|
||||
// Check that drop indicator is removed
|
||||
const dropIndicatorAfter = editorContent.querySelector('.drop-indicator');
|
||||
expect(dropIndicatorAfter).toBeFalsy();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag and drop visual feedback', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Block 1' },
|
||||
{ id: 'block2', type: 'paragraph', content: 'Block 2' },
|
||||
{ id: 'block3', type: 'paragraph', content: 'Block 3' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
const block1 = editorContent.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const dragHandle1 = block1.querySelector('.drag-handle') as HTMLElement;
|
||||
|
||||
// Start dragging block 1
|
||||
const dragStartEvent = new DragEvent('dragstart', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 50,
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
dragHandle1.dispatchEvent(dragStartEvent);
|
||||
|
||||
// Wait for dragging class
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Simulate dragging down
|
||||
const dragOverEvent = new DragEvent('dragover', {
|
||||
dataTransfer: new DataTransfer(),
|
||||
clientY: 150, // Move down past block 2
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
// Trigger the global drag over handler
|
||||
element.dragDropHandler['handleGlobalDragOver'](dragOverEvent);
|
||||
|
||||
// Check that transform is applied to dragged block
|
||||
const transform = block1.style.transform;
|
||||
console.log('Dragged block transform:', transform);
|
||||
expect(transform).toContain('translateY');
|
||||
|
||||
// Check drop indicator position
|
||||
const dropIndicator = editorContent.querySelector('.drop-indicator') as HTMLElement;
|
||||
if (dropIndicator) {
|
||||
const indicatorStyle = dropIndicator.style;
|
||||
console.log('Drop indicator position:', indicatorStyle.top, 'display:', indicatorStyle.display);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
124
test/test.wysiwyg-dragissue.browser.ts
Normal file
124
test/test.wysiwyg-dragissue.browser.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drag full flow without await', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Mock drag event
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Starting drag...');
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
console.log('Drag started');
|
||||
|
||||
// Check immediate state
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Instead of await with setTimeout, use a done callback
|
||||
return new Promise<void>((resolve) => {
|
||||
console.log('Setting up delayed check...');
|
||||
|
||||
// Use regular setTimeout
|
||||
setTimeout(() => {
|
||||
console.log('In setTimeout callback');
|
||||
|
||||
try {
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
console.log('Block has dragging class:', block1?.classList.contains('dragging'));
|
||||
console.log('Editor has dragging class:', editorContent?.classList.contains('dragging'));
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('Error in setTimeout:', error);
|
||||
throw error;
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('identify the crash point', async () => {
|
||||
console.log('Test started');
|
||||
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
console.log('Element created');
|
||||
await element.updateComplete;
|
||||
|
||||
console.log('Setting blocks');
|
||||
element.blocks = [{ id: 'block1', type: 'paragraph', content: 'Test' }];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
console.log('Waiting for update');
|
||||
await element.updateComplete;
|
||||
|
||||
console.log('Creating mock event');
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: () => {},
|
||||
setDragImage: () => {}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Calling handleDragStart');
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
|
||||
console.log('handleDragStart completed');
|
||||
|
||||
// Try different wait methods
|
||||
console.log('About to wait...');
|
||||
|
||||
// Method 1: Direct promise
|
||||
await Promise.resolve();
|
||||
console.log('Promise.resolve completed');
|
||||
|
||||
// Method 2: setTimeout 0
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log('setTimeout 0 completed');
|
||||
|
||||
// Method 3: requestAnimationFrame
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)));
|
||||
console.log('requestAnimationFrame completed');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
console.log('Cleanup completed');
|
||||
});
|
||||
|
||||
tap.start();
|
||||
108
test/test.wysiwyg-dropindicator.browser.ts
Normal file
108
test/test.wysiwyg-dropindicator.browser.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg drop indicator creation', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Check editorContentRef
|
||||
console.log('editorContentRef exists:', !!element.editorContentRef);
|
||||
console.log('editorContentRef tagName:', element.editorContentRef?.tagName);
|
||||
expect(element.editorContentRef).toBeTruthy();
|
||||
|
||||
// Check initial state - no drop indicator
|
||||
let dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator before drag:', dropIndicator);
|
||||
expect(dropIndicator).toBeFalsy();
|
||||
|
||||
// Manually call createDropIndicator
|
||||
try {
|
||||
console.log('Calling createDropIndicator...');
|
||||
element.dragDropHandler['createDropIndicator']();
|
||||
console.log('createDropIndicator succeeded');
|
||||
} catch (error) {
|
||||
console.error('Error creating drop indicator:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check drop indicator was created
|
||||
dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator after creation:', dropIndicator);
|
||||
console.log('Drop indicator parent:', dropIndicator?.parentElement?.className);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
expect(dropIndicator!.style.display).toEqual('none');
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg drag initialization with drop indicator', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Mock drag event
|
||||
const mockDragEvent = {
|
||||
dataTransfer: {
|
||||
effectAllowed: '',
|
||||
setData: (type: string, data: string) => {
|
||||
console.log('setData:', type, data);
|
||||
},
|
||||
setDragImage: (img: any, x: number, y: number) => {
|
||||
console.log('setDragImage');
|
||||
}
|
||||
},
|
||||
clientY: 100,
|
||||
preventDefault: () => {},
|
||||
} as any;
|
||||
|
||||
console.log('Starting drag...');
|
||||
|
||||
try {
|
||||
element.dragDropHandler.handleDragStart(mockDragEvent, element.blocks[0]);
|
||||
console.log('Drag start succeeded');
|
||||
} catch (error) {
|
||||
console.error('Error during drag start:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait for async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
|
||||
// Check drop indicator exists
|
||||
const dropIndicator = element.shadowRoot!.querySelector('.drop-indicator');
|
||||
console.log('Drop indicator after drag start:', dropIndicator);
|
||||
expect(dropIndicator).toBeTruthy();
|
||||
|
||||
// Check drag state
|
||||
console.log('Drag state:', element.dragDropHandler.dragState);
|
||||
expect(element.dragDropHandler.dragState.draggedBlockId).toEqual('block1');
|
||||
|
||||
// Clean up
|
||||
element.dragDropHandler.handleDragEnd();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
114
test/test.wysiwyg-eventlisteners.browser.ts
Normal file
114
test/test.wysiwyg-eventlisteners.browser.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
// Initialize the element
|
||||
DeesInputWysiwyg;
|
||||
|
||||
tap.test('wysiwyg global event listeners', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
console.log('Block 1 found:', !!block1);
|
||||
|
||||
// Set up drag state manually without using handleDragStart
|
||||
element.dragDropHandler['draggedBlockId'] = 'block1';
|
||||
element.dragDropHandler['draggedBlockElement'] = block1;
|
||||
element.dragDropHandler['initialMouseY'] = 100;
|
||||
|
||||
// Create drop indicator manually
|
||||
element.dragDropHandler['createDropIndicator']();
|
||||
|
||||
// Test adding global event listeners
|
||||
console.log('Adding event listeners...');
|
||||
const handleGlobalDragOver = element.dragDropHandler['handleGlobalDragOver'];
|
||||
const handleGlobalDragEnd = element.dragDropHandler['handleGlobalDragEnd'];
|
||||
|
||||
try {
|
||||
document.addEventListener('dragover', handleGlobalDragOver);
|
||||
console.log('dragover listener added');
|
||||
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
console.log('dragend listener added');
|
||||
} catch (error) {
|
||||
console.error('Error adding event listeners:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Test firing a dragover event
|
||||
console.log('Creating dragover event...');
|
||||
const dragOverEvent = new Event('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
Object.defineProperty(dragOverEvent, 'clientY', { value: 150 });
|
||||
|
||||
console.log('Dispatching dragover event...');
|
||||
document.dispatchEvent(dragOverEvent);
|
||||
console.log('dragover event dispatched');
|
||||
|
||||
// Clean up
|
||||
document.removeEventListener('dragover', handleGlobalDragOver);
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.test('wysiwyg setTimeout in drag start', async () => {
|
||||
const element = document.createElement('dees-input-wysiwyg');
|
||||
document.body.appendChild(element);
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
// Set initial content
|
||||
element.blocks = [
|
||||
{ id: 'block1', type: 'paragraph', content: 'Test block' },
|
||||
];
|
||||
element.renderBlocksProgrammatically();
|
||||
|
||||
await element.updateComplete;
|
||||
|
||||
const block1 = element.shadowRoot!.querySelector('[data-block-id="block1"]') as HTMLElement;
|
||||
const editorContent = element.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Set drag state
|
||||
element.dragDropHandler['draggedBlockId'] = 'block1';
|
||||
element.dragDropHandler['draggedBlockElement'] = block1;
|
||||
|
||||
console.log('Testing setTimeout callback...');
|
||||
|
||||
// Test the setTimeout callback directly
|
||||
try {
|
||||
if (block1) {
|
||||
console.log('Adding dragging class to block...');
|
||||
block1.classList.add('dragging');
|
||||
console.log('Block classes:', block1.className);
|
||||
}
|
||||
if (editorContent) {
|
||||
console.log('Adding dragging class to editor...');
|
||||
editorContent.classList.add('dragging');
|
||||
console.log('Editor classes:', editorContent.className);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in setTimeout callback:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
expect(block1.classList.contains('dragging')).toBeTrue();
|
||||
expect(editorContent.classList.contains('dragging')).toBeTrue();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
341
test/test.wysiwyg-keyboard.browser.ts
Normal file
341
test/test.wysiwyg-keyboard.browser.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('Keyboard: Arrow navigation between blocks', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import multiple blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'block-1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'block-2', type: 'paragraph', content: 'Second paragraph' },
|
||||
{ id: 'block-3', type: 'paragraph', content: 'Third paragraph' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Focus first block at end
|
||||
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Focus and set cursor at end of first block
|
||||
firstParagraph.focus();
|
||||
const textNode = firstParagraph.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press ArrowRight to move to second block
|
||||
const arrowRightEvent = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowRight',
|
||||
code: 'ArrowRight',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
firstParagraph.dispatchEvent(arrowRightEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if second block is focused
|
||||
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Check if the second paragraph has focus
|
||||
const activeElement = secondBlockComponent.shadowRoot?.activeElement;
|
||||
expect(activeElement).toEqual(secondParagraph);
|
||||
|
||||
console.log('Arrow navigation test complete');
|
||||
});
|
||||
|
||||
tap.test('Keyboard: Backspace merges blocks', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import two blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'merge-1', type: 'paragraph', content: 'First' },
|
||||
{ id: 'merge-2', type: 'paragraph', content: 'Second' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Focus second block at beginning
|
||||
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="merge-2"]');
|
||||
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Focus and set cursor at beginning
|
||||
secondParagraph.focus();
|
||||
const textNode = secondParagraph.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 0);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press Backspace to merge with previous block
|
||||
const backspaceEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Backspace',
|
||||
code: 'Backspace',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
secondParagraph.dispatchEvent(backspaceEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if blocks were merged
|
||||
expect(editor.blocks.length).toEqual(1);
|
||||
expect(editor.blocks[0].content).toContain('First');
|
||||
expect(editor.blocks[0].content).toContain('Second');
|
||||
|
||||
console.log('Backspace merge test complete');
|
||||
});
|
||||
|
||||
tap.test('Keyboard: Delete key on non-editable blocks', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import blocks including a divider
|
||||
editor.importBlocks([
|
||||
{ id: 'para-1', type: 'paragraph', content: 'Before divider' },
|
||||
{ id: 'div-1', type: 'divider', content: '' },
|
||||
{ id: 'para-2', type: 'paragraph', content: 'After divider' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Focus the divider block
|
||||
const dividerBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="div-1"]');
|
||||
const dividerBlockComponent = dividerBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const dividerBlockContainer = dividerBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const dividerElement = dividerBlockContainer?.querySelector('.block.divider') as HTMLElement;
|
||||
|
||||
// Non-editable blocks need to be focused differently
|
||||
dividerElement?.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press Delete to remove the divider
|
||||
const deleteEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
code: 'Delete',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
dividerElement.dispatchEvent(deleteEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if divider was removed
|
||||
expect(editor.blocks.length).toEqual(2);
|
||||
expect(editor.blocks.find(b => b.type === 'divider')).toBeUndefined();
|
||||
|
||||
console.log('Delete key on non-editable block test complete');
|
||||
});
|
||||
|
||||
tap.test('Keyboard: Tab key in code block', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a code block
|
||||
editor.importBlocks([
|
||||
{ id: 'code-1', type: 'code', content: 'function test() {', metadata: { language: 'javascript' } }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Focus code block
|
||||
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const codeBlockContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const codeElement = codeBlockContainer?.querySelector('.block.code') as HTMLElement;
|
||||
|
||||
// Focus and set cursor at end
|
||||
codeElement.focus();
|
||||
const textNode = codeElement.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press Tab to insert spaces
|
||||
const tabEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
codeElement.dispatchEvent(tabEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if spaces were inserted
|
||||
const updatedContent = codeElement.textContent || '';
|
||||
expect(updatedContent).toContain(' '); // Tab should insert 2 spaces
|
||||
|
||||
console.log('Tab in code block test complete');
|
||||
});
|
||||
|
||||
tap.test('Keyboard: ArrowUp/Down navigation', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import multiple blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'nav-1', type: 'paragraph', content: 'First line' },
|
||||
{ id: 'nav-2', type: 'paragraph', content: 'Second line' },
|
||||
{ id: 'nav-3', type: 'paragraph', content: 'Third line' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Focus second block
|
||||
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-2"]');
|
||||
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
secondParagraph.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press ArrowUp to move to first block
|
||||
const arrowUpEvent = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowUp',
|
||||
code: 'ArrowUp',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
secondParagraph.dispatchEvent(arrowUpEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if first block is focused
|
||||
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
|
||||
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const firstParagraph = firstBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
expect(firstBlockComponent.shadowRoot?.activeElement).toEqual(firstParagraph);
|
||||
|
||||
// Now press ArrowDown twice to get to third block
|
||||
const arrowDownEvent = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
code: 'ArrowDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
firstParagraph.dispatchEvent(arrowDownEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Second block should be focused, dispatch again
|
||||
const secondActiveElement = secondBlockComponent.shadowRoot?.activeElement;
|
||||
if (secondActiveElement) {
|
||||
secondActiveElement.dispatchEvent(arrowDownEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
// Check if third block is focused
|
||||
const thirdBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-3"]');
|
||||
const thirdBlockComponent = thirdBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const thirdParagraph = thirdBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
expect(thirdBlockComponent.shadowRoot?.activeElement).toEqual(thirdParagraph);
|
||||
|
||||
console.log('ArrowUp/Down navigation test complete');
|
||||
});
|
||||
|
||||
tap.test('Keyboard: Formatting shortcuts', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a paragraph
|
||||
editor.importBlocks([
|
||||
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Focus and select text
|
||||
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
paragraph.focus();
|
||||
|
||||
// Select "formatting"
|
||||
const textNode = paragraph.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.setStart(textNode, 5); // After "Test "
|
||||
range.setEnd(textNode, 15); // After "formatting"
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press Cmd/Ctrl+B for bold
|
||||
const boldEvent = new KeyboardEvent('keydown', {
|
||||
key: 'b',
|
||||
code: 'KeyB',
|
||||
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
paragraph.dispatchEvent(boldEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if bold was applied
|
||||
const content = paragraph.innerHTML;
|
||||
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
|
||||
|
||||
console.log('Formatting shortcuts test complete');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
150
test/test.wysiwyg-phase3.browser.ts
Normal file
150
test/test.wysiwyg-phase3.browser.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('Phase 3: Quote block should render and work correctly', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a quote block
|
||||
editor.importBlocks([
|
||||
{ id: 'quote-1', type: 'quote', content: 'This is a famous quote' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if quote block was rendered
|
||||
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
expect(quoteBlockComponent).toBeTruthy();
|
||||
|
||||
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||
expect(quoteElement).toBeTruthy();
|
||||
expect(quoteElement?.textContent).toEqual('This is a famous quote');
|
||||
|
||||
// Check if styles are applied (border-left for quote)
|
||||
const computedStyle = window.getComputedStyle(quoteElement);
|
||||
expect(computedStyle.borderLeftStyle).toEqual('solid');
|
||||
expect(computedStyle.fontStyle).toEqual('italic');
|
||||
});
|
||||
|
||||
tap.test('Phase 3: Code block should render and handle tab correctly', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a code block
|
||||
editor.importBlocks([
|
||||
{ id: 'code-1', type: 'code', content: 'const x = 42;', metadata: { language: 'javascript' } }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if code block was rendered
|
||||
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const codeContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||
|
||||
expect(codeElement).toBeTruthy();
|
||||
expect(codeElement?.textContent).toEqual('const x = 42;');
|
||||
|
||||
// Check if language label is shown
|
||||
const languageLabel = codeContainer?.querySelector('.code-language');
|
||||
expect(languageLabel?.textContent).toEqual('javascript');
|
||||
|
||||
// Check if monospace font is applied
|
||||
const computedStyle = window.getComputedStyle(codeElement);
|
||||
expect(computedStyle.fontFamily).toContain('monospace');
|
||||
});
|
||||
|
||||
tap.test('Phase 3: List block should render correctly', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a list block
|
||||
editor.importBlocks([
|
||||
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if list block was rendered
|
||||
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
|
||||
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
|
||||
|
||||
expect(listElement).toBeTruthy();
|
||||
|
||||
// Check if list items were created
|
||||
const listItems = listElement?.querySelectorAll('li');
|
||||
expect(listItems?.length).toEqual(3);
|
||||
expect(listItems?.[0].textContent).toEqual('First item');
|
||||
expect(listItems?.[1].textContent).toEqual('Second item');
|
||||
expect(listItems?.[2].textContent).toEqual('Third item');
|
||||
|
||||
// Check if it's an unordered list by default
|
||||
const ulElement = listElement?.querySelector('ul');
|
||||
expect(ulElement).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('Phase 3: Quote block split should work', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a quote block
|
||||
editor.importBlocks([
|
||||
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get the quote block
|
||||
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
|
||||
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||
|
||||
// Focus and set cursor after "To be"
|
||||
quoteElement.focus();
|
||||
const textNode = quoteElement.firstChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.setStart(textNode, 5); // After "To be"
|
||||
range.setEnd(textNode, 5);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Press Enter to split
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
quoteElement.dispatchEvent(enterEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if split happened correctly
|
||||
expect(editor.blocks.length).toEqual(2);
|
||||
expect(editor.blocks[0].content).toEqual('To be');
|
||||
expect(editor.blocks[1].content).toEqual(' or not to be');
|
||||
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
105
test/test.wysiwyg-registry.both.ts
Normal file
105
test/test.wysiwyg-registry.both.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||
import { DividerBlockHandler } from '../ts_web/elements/wysiwyg/blocks/content/divider.block.js';
|
||||
import { ParagraphBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/paragraph.block.js';
|
||||
import { HeadingBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/heading.block.js';
|
||||
|
||||
// Import block registration to ensure handlers are registered
|
||||
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||
|
||||
tap.test('BlockRegistry should register and retrieve handlers', async () => {
|
||||
// Test divider handler
|
||||
const dividerHandler = BlockRegistry.getHandler('divider');
|
||||
expect(dividerHandler).toBeDefined();
|
||||
expect(dividerHandler).toBeInstanceOf(DividerBlockHandler);
|
||||
expect(dividerHandler?.type).toEqual('divider');
|
||||
|
||||
// Test paragraph handler
|
||||
const paragraphHandler = BlockRegistry.getHandler('paragraph');
|
||||
expect(paragraphHandler).toBeDefined();
|
||||
expect(paragraphHandler).toBeInstanceOf(ParagraphBlockHandler);
|
||||
expect(paragraphHandler?.type).toEqual('paragraph');
|
||||
|
||||
// Test heading handlers
|
||||
const heading1Handler = BlockRegistry.getHandler('heading-1');
|
||||
expect(heading1Handler).toBeDefined();
|
||||
expect(heading1Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||
expect(heading1Handler?.type).toEqual('heading-1');
|
||||
|
||||
const heading2Handler = BlockRegistry.getHandler('heading-2');
|
||||
expect(heading2Handler).toBeDefined();
|
||||
expect(heading2Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||
expect(heading2Handler?.type).toEqual('heading-2');
|
||||
|
||||
const heading3Handler = BlockRegistry.getHandler('heading-3');
|
||||
expect(heading3Handler).toBeDefined();
|
||||
expect(heading3Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||
expect(heading3Handler?.type).toEqual('heading-3');
|
||||
});
|
||||
|
||||
tap.test('Block handlers should render content correctly', async () => {
|
||||
const testBlock = {
|
||||
id: 'test-1',
|
||||
type: 'paragraph' as const,
|
||||
content: 'Test paragraph content'
|
||||
};
|
||||
|
||||
const handler = BlockRegistry.getHandler('paragraph');
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
if (handler) {
|
||||
const rendered = handler.render(testBlock, false);
|
||||
expect(rendered).toContain('contenteditable="true"');
|
||||
expect(rendered).toContain('data-block-type="paragraph"');
|
||||
expect(rendered).toContain('Test paragraph content');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Divider handler should render correctly', async () => {
|
||||
const dividerBlock = {
|
||||
id: 'test-divider',
|
||||
type: 'divider' as const,
|
||||
content: ' '
|
||||
};
|
||||
|
||||
const handler = BlockRegistry.getHandler('divider');
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
if (handler) {
|
||||
const rendered = handler.render(dividerBlock, false);
|
||||
expect(rendered).toContain('class="block divider"');
|
||||
expect(rendered).toContain('tabindex="0"');
|
||||
expect(rendered).toContain('divider-icon');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Heading handlers should render with correct levels', async () => {
|
||||
const headingBlock = {
|
||||
id: 'test-h1',
|
||||
type: 'heading-1' as const,
|
||||
content: 'Test Heading'
|
||||
};
|
||||
|
||||
const handler = BlockRegistry.getHandler('heading-1');
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
if (handler) {
|
||||
const rendered = handler.render(headingBlock, false);
|
||||
expect(rendered).toContain('class="block heading-1"');
|
||||
expect(rendered).toContain('contenteditable="true"');
|
||||
expect(rendered).toContain('Test Heading');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('getAllTypes should return all registered types', async () => {
|
||||
const allTypes = BlockRegistry.getAllTypes();
|
||||
expect(allTypes).toContain('divider');
|
||||
expect(allTypes).toContain('paragraph');
|
||||
expect(allTypes).toContain('heading-1');
|
||||
expect(allTypes).toContain('heading-2');
|
||||
expect(allTypes).toContain('heading-3');
|
||||
expect(allTypes.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('Selection highlighting should work consistently for all block types', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import various block types
|
||||
editor.importBlocks([
|
||||
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
|
||||
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
|
||||
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
|
||||
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
|
||||
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Test paragraph highlighting
|
||||
console.log('Testing paragraph highlighting...');
|
||||
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Focus paragraph to select it
|
||||
paraElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if paragraph has selected class
|
||||
const paraHasSelected = paraElement.classList.contains('selected');
|
||||
console.log('Paragraph has selected class:', paraHasSelected);
|
||||
|
||||
// Check computed styles
|
||||
const paraStyle = window.getComputedStyle(paraElement);
|
||||
console.log('Paragraph background:', paraStyle.background);
|
||||
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
|
||||
|
||||
// Test heading highlighting
|
||||
console.log('\nTesting heading highlighting...');
|
||||
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
|
||||
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
|
||||
|
||||
// Focus heading to select it
|
||||
headingElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if heading has selected class
|
||||
const headingHasSelected = headingElement.classList.contains('selected');
|
||||
console.log('Heading has selected class:', headingHasSelected);
|
||||
|
||||
// Check computed styles
|
||||
const headingStyle = window.getComputedStyle(headingElement);
|
||||
console.log('Heading background:', headingStyle.background);
|
||||
console.log('Heading box-shadow:', headingStyle.boxShadow);
|
||||
|
||||
// Test quote highlighting
|
||||
console.log('\nTesting quote highlighting...');
|
||||
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||
|
||||
// Focus quote to select it
|
||||
quoteElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if quote has selected class
|
||||
const quoteHasSelected = quoteElement.classList.contains('selected');
|
||||
console.log('Quote has selected class:', quoteHasSelected);
|
||||
|
||||
// Test code highlighting
|
||||
console.log('\nTesting code highlighting...');
|
||||
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||
|
||||
// Focus code to select it
|
||||
codeElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if code has selected class
|
||||
const codeHasSelected = codeElement.classList.contains('selected');
|
||||
console.log('Code has selected class:', codeHasSelected);
|
||||
|
||||
// Focus back on paragraph and check if others are deselected
|
||||
console.log('\nFocusing back on paragraph...');
|
||||
paraElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check that only paragraph is selected
|
||||
expect(paraElement.classList.contains('selected')).toBeTrue();
|
||||
expect(headingElement.classList.contains('selected')).toBeFalse();
|
||||
expect(quoteElement.classList.contains('selected')).toBeFalse();
|
||||
expect(codeElement.classList.contains('selected')).toBeFalse();
|
||||
|
||||
console.log('Selection highlighting test complete');
|
||||
});
|
||||
|
||||
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import two blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'block-1', type: 'paragraph', content: 'First block' },
|
||||
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get both blocks
|
||||
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Initially neither should be selected
|
||||
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||
|
||||
// Click on first block
|
||||
block1Element.click();
|
||||
block1Element.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// First block should be selected
|
||||
expect(block1Element.classList.contains('selected')).toBeTrue();
|
||||
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||
|
||||
// Click on second block
|
||||
block2Element.click();
|
||||
block2Element.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Second block should be selected, first should not
|
||||
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||
expect(block2Element.classList.contains('selected')).toBeTrue();
|
||||
|
||||
console.log('Toggle test complete');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('Selection highlighting basic test', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import two blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get paragraph element
|
||||
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Get heading element
|
||||
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
|
||||
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
|
||||
|
||||
console.log('Found elements:', {
|
||||
paraBlock: !!paraBlock,
|
||||
headBlock: !!headBlock
|
||||
});
|
||||
|
||||
// Focus paragraph
|
||||
paraBlock.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check classes
|
||||
console.log('Paragraph classes:', paraBlock.className);
|
||||
console.log('Heading classes:', headBlock.className);
|
||||
|
||||
// Check isSelected property
|
||||
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||
|
||||
// Focus heading
|
||||
headBlock.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check classes again
|
||||
console.log('\nAfter focusing heading:');
|
||||
console.log('Paragraph classes:', paraBlock.className);
|
||||
console.log('Heading classes:', headBlock.className);
|
||||
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||
|
||||
// Check that heading is selected
|
||||
expect(headBlock.classList.contains('selected')).toBeTrue();
|
||||
expect(paraBlock.classList.contains('selected')).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
98
test/test.wysiwyg-split.browser.ts
Normal file
98
test/test.wysiwyg-split.browser.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('should split paragraph content on Enter key', async () => {
|
||||
// Create the wysiwyg editor
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a test paragraph
|
||||
editor.importBlocks([{
|
||||
id: 'test-para-1',
|
||||
type: 'paragraph',
|
||||
content: 'Hello World'
|
||||
}]);
|
||||
|
||||
await editor.updateComplete;
|
||||
|
||||
// Wait for blocks to render
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get the block wrapper and component
|
||||
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-para-1"]');
|
||||
expect(blockWrapper).toBeDefined();
|
||||
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
expect(blockComponent).toBeDefined();
|
||||
expect(blockComponent.block.type).toEqual('paragraph');
|
||||
|
||||
// Wait for block to render
|
||||
await blockComponent.updateComplete;
|
||||
|
||||
// Test getSplitContent
|
||||
console.log('Testing getSplitContent...');
|
||||
const splitResult = blockComponent.getSplitContent();
|
||||
console.log('Split result:', splitResult);
|
||||
|
||||
// Since we haven't set cursor position, it might return null or split at start
|
||||
// This is just to test if the method is callable
|
||||
expect(typeof blockComponent.getSplitContent).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('should handle Enter key press in paragraph', async () => {
|
||||
// Create the wysiwyg editor
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import a test paragraph
|
||||
editor.importBlocks([{
|
||||
id: 'test-enter-1',
|
||||
type: 'paragraph',
|
||||
content: 'First part|Second part' // | marks where we'll simulate cursor
|
||||
}]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check initial state
|
||||
expect(editor.blocks.length).toEqual(1);
|
||||
expect(editor.blocks[0].content).toEqual('First part|Second part');
|
||||
|
||||
// Get the block element
|
||||
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-enter-1"]');
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const blockElement = blockComponent.shadowRoot?.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
|
||||
expect(blockElement).toBeDefined();
|
||||
|
||||
// Set content without the | marker
|
||||
blockElement.textContent = 'First partSecond part';
|
||||
|
||||
// Focus the block
|
||||
blockElement.focus();
|
||||
|
||||
// Create and dispatch Enter key event
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true
|
||||
});
|
||||
|
||||
// Dispatch the event
|
||||
blockElement.dispatchEvent(enterEvent);
|
||||
|
||||
// Wait for processing
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if block was split (this might not work perfectly in test environment)
|
||||
console.log('Blocks after Enter:', editor.blocks.length);
|
||||
console.log('Block contents:', editor.blocks.map(b => b.content));
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@designestate/dees-catalog',
|
||||
version: '1.0.84',
|
||||
description: 'website for lossless.com'
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '1.12.5',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
||||
13
ts_web/elements/00colors.ts
Normal file
13
ts_web/elements/00colors.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const dark = {
|
||||
blue: '#0050b9',
|
||||
blueActive: '#0069f2',
|
||||
blueMuted: '#012452',
|
||||
text: '#ffffff',
|
||||
}
|
||||
|
||||
export const bright = {
|
||||
blue: '#0050b9',
|
||||
blueActive: '#0069f2',
|
||||
blueMuted: '#0069f2',
|
||||
text: '#333333',
|
||||
}
|
||||
53
ts_web/elements/00fonts.ts
Normal file
53
ts_web/elements/00fonts.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { unsafeCSS } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* Geist Sans font family - Main font for the design system
|
||||
* Already available in the environment, no need to load
|
||||
*/
|
||||
export const geistSansFont = 'Geist Sans';
|
||||
|
||||
/**
|
||||
* Intel One Mono font family - Monospace font for code and technical content
|
||||
* Already available in the environment, no need to load
|
||||
*/
|
||||
export const intelOneMonoFont = 'Intel One Mono';
|
||||
|
||||
/**
|
||||
* Complete font family stacks with fallbacks
|
||||
*/
|
||||
export const geistFontFamily = `'${geistSansFont}', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif`;
|
||||
|
||||
export const monoFontFamily = `'${intelOneMonoFont}', 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace`;
|
||||
|
||||
/**
|
||||
* CSS-ready font family values using unsafeCSS
|
||||
* Use these in component styles
|
||||
*/
|
||||
export const cssGeistFontFamily = unsafeCSS(geistFontFamily);
|
||||
export const cssMonoFontFamily = unsafeCSS(monoFontFamily);
|
||||
|
||||
/**
|
||||
* Cal Sans font for headings - Display font
|
||||
* May need to be loaded separately
|
||||
*/
|
||||
export const calSansFont = 'Cal Sans';
|
||||
export const calSansFontFamily = `'${calSansFont}', ${geistFontFamily}`;
|
||||
export const cssCalSansFontFamily = unsafeCSS(calSansFontFamily);
|
||||
|
||||
/**
|
||||
* Roboto Slab font for special content - Serif font
|
||||
* May need to be loaded separately
|
||||
*/
|
||||
export const robotoSlabFont = 'Roboto Slab';
|
||||
export const robotoSlabFontFamily = `'${robotoSlabFont}', Georgia, serif`;
|
||||
export const cssRobotoSlabFontFamily = unsafeCSS(robotoSlabFontFamily);
|
||||
|
||||
/**
|
||||
* Base font styles that can be applied to components
|
||||
*/
|
||||
export const baseFontStyles = unsafeCSS(`
|
||||
font-family: ${geistFontFamily};
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: 'cv11', 'tnum', 'cv05' 1;
|
||||
`);
|
||||
13
ts_web/elements/00plugins.ts
Normal file
13
ts_web/elements/00plugins.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// @push.rocks scope
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
|
||||
export {
|
||||
smartpromise,
|
||||
}
|
||||
|
||||
// @tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export {
|
||||
tsclass
|
||||
}
|
||||
161
ts_web/elements/00zindex.ts
Normal file
161
ts_web/elements/00zindex.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Central z-index management for consistent stacking order
|
||||
* Higher numbers appear on top of lower numbers
|
||||
*/
|
||||
|
||||
export const zIndexLayers = {
|
||||
// Base layer: Regular content
|
||||
base: {
|
||||
content: 'auto',
|
||||
inputElements: 1,
|
||||
},
|
||||
|
||||
// Fixed UI elements
|
||||
fixed: {
|
||||
appBar: 10,
|
||||
sideMenu: 10,
|
||||
mobileNav: 250,
|
||||
},
|
||||
|
||||
// Overlay backdrops (semi-transparent backgrounds)
|
||||
backdrop: {
|
||||
dropdown: 1999, // Below modals but above fixed elements
|
||||
modal: 2999, // Below dropdowns on modals
|
||||
contextMenu: 3999, // Below critical overlays
|
||||
},
|
||||
|
||||
// Interactive overlays
|
||||
overlay: {
|
||||
dropdown: 2000, // Dropdowns and select menus
|
||||
modal: 3000, // Modal dialogs
|
||||
contextMenu: 4000, // Context menus and tooltips
|
||||
toast: 5000, // Toast notifications (highest priority)
|
||||
},
|
||||
|
||||
// Special cases for nested elements
|
||||
modalDropdown: 3500, // Dropdowns inside modals
|
||||
wysiwygMenus: 4500, // Editor formatting menus
|
||||
} as const;
|
||||
|
||||
// Helper function to get z-index value
|
||||
export function getZIndex(category: keyof typeof zIndexLayers, subcategory?: string): number | string {
|
||||
const categoryObj = zIndexLayers[category];
|
||||
if (typeof categoryObj === 'object' && subcategory) {
|
||||
return categoryObj[subcategory as keyof typeof categoryObj] || 'auto';
|
||||
}
|
||||
return typeof categoryObj === 'number' ? categoryObj : 'auto';
|
||||
}
|
||||
|
||||
// Z-index assignments for components
|
||||
export const componentZIndex = {
|
||||
'dees-modal': zIndexLayers.overlay.modal,
|
||||
'dees-windowlayer': zIndexLayers.overlay.dropdown,
|
||||
'dees-contextmenu': zIndexLayers.overlay.contextMenu,
|
||||
'dees-toast': zIndexLayers.overlay.toast,
|
||||
'dees-appui-mainmenu': zIndexLayers.fixed.appBar,
|
||||
'dees-mobilenavigation': zIndexLayers.fixed.mobileNav,
|
||||
'dees-slash-menu': zIndexLayers.wysiwygMenus,
|
||||
'dees-formatting-menu': zIndexLayers.wysiwygMenus,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Z-Index Registry for managing stacked elements
|
||||
* Simple incremental z-index assignment based on creation order
|
||||
*/
|
||||
export class ZIndexRegistry {
|
||||
private static instance: ZIndexRegistry;
|
||||
private activeElements = new Set<HTMLElement>();
|
||||
private elementZIndexMap = new WeakMap<HTMLElement, number>();
|
||||
private currentZIndex = 1000; // Starting z-index
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): ZIndexRegistry {
|
||||
if (!ZIndexRegistry.instance) {
|
||||
ZIndexRegistry.instance = new ZIndexRegistry();
|
||||
}
|
||||
return ZIndexRegistry.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available z-index
|
||||
* @returns The next available z-index
|
||||
*/
|
||||
public getNextZIndex(): number {
|
||||
this.currentZIndex += 10;
|
||||
return this.currentZIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an element with the z-index registry
|
||||
* @param element - The HTML element to register
|
||||
* @param zIndex - The z-index assigned to this element
|
||||
*/
|
||||
public register(element: HTMLElement, zIndex: number): void {
|
||||
this.activeElements.add(element);
|
||||
this.elementZIndexMap.set(element, zIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an element from the z-index registry
|
||||
* @param element - The HTML element to unregister
|
||||
*/
|
||||
public unregister(element: HTMLElement): void {
|
||||
this.activeElements.delete(element);
|
||||
this.elementZIndexMap.delete(element);
|
||||
|
||||
// If no more active elements, reset counter to base
|
||||
if (this.activeElements.size === 0) {
|
||||
this.currentZIndex = 1000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the z-index for a specific element
|
||||
* @param element - The HTML element
|
||||
* @returns The z-index or undefined if not registered
|
||||
*/
|
||||
public getElementZIndex(element: HTMLElement): number | undefined {
|
||||
return this.elementZIndexMap.get(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active elements
|
||||
* @returns Number of active elements
|
||||
*/
|
||||
public getActiveCount(): number {
|
||||
return this.activeElements.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current highest z-index
|
||||
* @returns The current z-index value
|
||||
*/
|
||||
public getCurrentZIndex(): number {
|
||||
return this.currentZIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registrations (useful for testing)
|
||||
*/
|
||||
public clear(): void {
|
||||
this.activeElements.clear();
|
||||
this.elementZIndexMap = new WeakMap();
|
||||
this.currentZIndex = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active elements in z-index order
|
||||
* @returns Array of elements sorted by z-index
|
||||
*/
|
||||
public getActiveElementsInOrder(): HTMLElement[] {
|
||||
return Array.from(this.activeElements).sort((a, b) => {
|
||||
const aZ = this.elementZIndexMap.get(a) || 0;
|
||||
const bZ = this.elementZIndexMap.get(b) || 0;
|
||||
return aZ - bZ;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for convenience
|
||||
export const zIndexRegistry = ZIndexRegistry.getInstance();
|
||||
512
ts_web/elements/dees-appui-activitylog.ts
Normal file
512
ts_web/elements/dees-appui-activitylog.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
import './dees-icon.js';
|
||||
|
||||
@customElement('dees-appui-activitylog')
|
||||
export class DeesAppuiActivitylog extends DeesElement {
|
||||
// STATIC
|
||||
public static demo = () => html`
|
||||
<style>
|
||||
.demo-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 600px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
padding: 32px;
|
||||
}
|
||||
</style>
|
||||
<div class="demo-container">
|
||||
<dees-appui-activitylog></dees-appui-activitylog>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// INSTANCE
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
height: 100%;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
font-family: 'Geist Mono', monospace;
|
||||
border-left: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
cursor: default;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'-4px 0 12px rgba(0, 0, 0, 0.02)',
|
||||
'-4px 0 12px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
}
|
||||
.maincontainer {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
padding: 0px 16px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topbar .heading {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.activityContainer {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
bottom: 48px;
|
||||
width: 100%;
|
||||
padding: 12px 0px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${cssManager.bdTheme('#e5e7eb', '#27272a')} transparent;
|
||||
}
|
||||
|
||||
.activityContainer::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.activityContainer::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.activityContainer::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.activityContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
}
|
||||
|
||||
.streamingIndicator {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.streamingIndicator::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.streamingIndicator.bottom {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.activityentry {
|
||||
min-height: 36px;
|
||||
font-size: 13px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.activityentry:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activityentry:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
min-width: 45px;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.activity-icon.login {
|
||||
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.1)', 'rgba(34, 197, 94, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#16a34a', '#22c55e')};
|
||||
}
|
||||
|
||||
.activity-icon.logout {
|
||||
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.1)', 'rgba(239, 68, 68, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
}
|
||||
|
||||
.activity-icon.view {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#2563eb', '#3b82f6')};
|
||||
}
|
||||
|
||||
.activity-icon.create {
|
||||
background: ${cssManager.bdTheme('rgba(168, 85, 247, 0.1)', 'rgba(168, 85, 247, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#9333ea', '#a855f7')};
|
||||
}
|
||||
|
||||
.activity-icon.update {
|
||||
background: ${cssManager.bdTheme('rgba(251, 146, 60, 0.1)', 'rgba(251, 146, 60, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
flex: 1;
|
||||
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
|
||||
}
|
||||
|
||||
.activity-user {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#09090b')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.searchbox {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.searchbox input {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 6px;
|
||||
padding: 0 12px 0 36px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.searchbox input::placeholder {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.searchbox input:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
}
|
||||
|
||||
.searchbox input:focus ~ .search-icon,
|
||||
.search-wrapper:has(input:focus) .search-icon {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.bottomShadow {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
bottom: 48px;
|
||||
background: ${cssManager.bdTheme(
|
||||
'linear-gradient(180deg, transparent 0%, #fafafa 100%)',
|
||||
'linear-gradient(180deg, transparent 0%, #0a0a0a 100%)'
|
||||
)};
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.topShadow {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
top: 40px;
|
||||
background: ${cssManager.bdTheme(
|
||||
'linear-gradient(0deg, transparent 0%, #fafafa 100%)',
|
||||
'linear-gradient(0deg, transparent 0%, #0a0a0a 100%)'
|
||||
)};
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${domtools.elementBasic.styles}
|
||||
<style></style>
|
||||
<div class="maincontainer">
|
||||
<div class="topbar">
|
||||
<div class="heading">Activity Log</div>
|
||||
</div>
|
||||
<div class="activityContainer">
|
||||
<div class="streamingIndicator">Live Updates</div>
|
||||
|
||||
<div class="date-separator">Today</div>
|
||||
|
||||
<div class="activityentry" @contextmenu=${async eventArg => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Copy activity',
|
||||
action: async () => {},
|
||||
},
|
||||
{
|
||||
name: 'View details',
|
||||
action: async () => {},
|
||||
},
|
||||
{
|
||||
name: 'Filter by user',
|
||||
action: async () => {},
|
||||
},
|
||||
]);
|
||||
}}>
|
||||
<span class="timestamp">22:20</span>
|
||||
<div class="activity-icon logout">
|
||||
<dees-icon .icon=${'lucide:logOut'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> logged out
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:19</span>
|
||||
<div class="activity-icon update">
|
||||
<dees-icon .icon=${'lucide:checkCircle'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> approved a payment
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:18</span>
|
||||
<div class="activity-icon view">
|
||||
<dees-icon .icon=${'lucide:archive'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> archived an invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:17</span>
|
||||
<div class="activity-icon login">
|
||||
<dees-icon .icon=${'lucide:logIn'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> logged in
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:16</span>
|
||||
<div class="activity-icon logout">
|
||||
<dees-icon .icon=${'lucide:logOut'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> logged out
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:15</span>
|
||||
<div class="activity-icon update">
|
||||
<dees-icon .icon=${'lucide:key'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> changed password
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:14</span>
|
||||
<div class="activity-icon create">
|
||||
<dees-icon .icon=${'lucide:userPlus'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> added a new user
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">22:13</span>
|
||||
<div class="activity-icon view">
|
||||
<dees-icon .icon=${'lucide:messageCircle'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> contacted support
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-separator">Yesterday</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">18:45</span>
|
||||
<div class="activity-icon update">
|
||||
<dees-icon .icon=${'lucide:trash2'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> deleted an invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">17:30</span>
|
||||
<div class="activity-icon login">
|
||||
<dees-icon .icon=${'lucide:logIn'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> logged in
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">16:15</span>
|
||||
<div class="activity-icon logout">
|
||||
<dees-icon .icon=${'lucide:logOut'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> logged out
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">14:20</span>
|
||||
<div class="activity-icon view">
|
||||
<dees-icon .icon=${'lucide:barChart'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> viewed reports
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">13:45</span>
|
||||
<div class="activity-icon create">
|
||||
<dees-icon .icon=${'lucide:send'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> sent an invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activityentry">
|
||||
<span class="timestamp">13:30</span>
|
||||
<div class="activity-icon create">
|
||||
<dees-icon .icon=${'lucide:filePlus'}></dees-icon>
|
||||
</div>
|
||||
<div class="activity-text">
|
||||
<span class="activity-user">Max Mustermann</span> created a new invoice
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="streamingIndicator bottom">Loading History</div>
|
||||
</div>
|
||||
<div class="searchbox">
|
||||
<div class="search-wrapper">
|
||||
<dees-icon class="search-icon" .icon=${'lucide:search'}></dees-icon>
|
||||
<input type="text" placeholder="Search activities, users..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="topShadow"></div>
|
||||
<div class="bottomShadow"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
460
ts_web/elements/dees-appui-appbar/component.ts
Normal file
460
ts_web/elements/dees-appui-appbar/component.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import * as interfaces from '../interfaces/index.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { appuiAppbarStyles } from './styles.js';
|
||||
import { renderAppuiAppbar } from './template.js';
|
||||
|
||||
// Import required components
|
||||
import '../dees-icon.js';
|
||||
import '../dees-windowcontrols.js';
|
||||
import '../dees-appui-profiledropdown.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-appui-appbar': DeesAppuiBar;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-appui-appbar')
|
||||
export class DeesAppuiBar extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE PROPERTIES
|
||||
@property({ type: Array })
|
||||
public menuItems: interfaces.IAppBarMenuItem[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
public breadcrumbs: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public breadcrumbSeparator: string = ' > ';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showWindowControls: boolean = true;
|
||||
|
||||
|
||||
@property({ type: Object })
|
||||
public user?: {
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
status?: 'online' | 'offline' | 'busy' | 'away';
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showSearch: boolean = false;
|
||||
|
||||
// STATE
|
||||
@state()
|
||||
private activeMenu: string | null = null;
|
||||
|
||||
@state()
|
||||
private openDropdowns: Set<string> = new Set();
|
||||
|
||||
@state()
|
||||
private focusedItem: string | null = null;
|
||||
|
||||
@state()
|
||||
private focusedDropdownItem: number = -1;
|
||||
|
||||
@state()
|
||||
private isProfileDropdownOpen: boolean = false;
|
||||
|
||||
public static styles = appuiAppbarStyles;
|
||||
|
||||
// INSTANCE
|
||||
public render(): TemplateResult {
|
||||
return renderAppuiAppbar(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public renderMenuItems(): TemplateResult {
|
||||
return html`
|
||||
${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMenuItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
|
||||
if ('divider' in item && item.divider) {
|
||||
return html`<div class="dropdown-divider"></div>`;
|
||||
}
|
||||
|
||||
const menuItem = item as interfaces.IAppBarMenuItemRegular;
|
||||
const isActive = this.activeMenu === itemId;
|
||||
const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="menuItem ${isActive ? 'active' : ''}"
|
||||
?disabled=${menuItem.disabled}
|
||||
tabindex="${menuItem.disabled ? -1 : 0}"
|
||||
data-item-id="${itemId}"
|
||||
@click=${() => this.handleMenuClick(menuItem, itemId)}
|
||||
@keydown=${(e: KeyboardEvent) => this.handleMenuKeydown(e, menuItem, itemId)}
|
||||
role="menuitem"
|
||||
aria-haspopup="${hasSubmenu}"
|
||||
aria-expanded="${isActive}"
|
||||
>
|
||||
${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
|
||||
${menuItem.name}
|
||||
${hasSubmenu ? this.renderDropdown(menuItem.submenu, itemId, isActive) : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDropdown(items: interfaces.IAppBarMenuItem[], parentId: string, isOpen: boolean): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="dropdown ${isOpen ? 'open' : ''}"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
@keydown=${(e: KeyboardEvent) => this.handleDropdownKeydown(e, items, parentId)}
|
||||
tabindex="${isOpen ? 0 : -1}"
|
||||
role="menu"
|
||||
>
|
||||
${items.map((item, index) => this.renderDropdownItem(item, `${parentId}-${index}`))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDropdownItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
|
||||
if ('divider' in item && item.divider) {
|
||||
return html`<div class="dropdown-divider"></div>`;
|
||||
}
|
||||
|
||||
const menuItem = item as interfaces.IAppBarMenuItemRegular;
|
||||
const itemIndex = parseInt(itemId.split('-').pop() || '0');
|
||||
const isFocused = this.focusedDropdownItem === itemIndex;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="dropdown-item ${isFocused ? 'focused' : ''}"
|
||||
?disabled=${menuItem.disabled}
|
||||
@click=${() => this.handleDropdownItemClick(menuItem)}
|
||||
@mouseenter=${() => this.focusedDropdownItem = itemIndex}
|
||||
role="menuitem"
|
||||
tabindex="${menuItem.disabled ? -1 : 0}"
|
||||
>
|
||||
${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
|
||||
<span>${menuItem.name}</span>
|
||||
${menuItem.shortcut ? html`<span class="shortcut">${menuItem.shortcut}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public renderBreadcrumbs(): TemplateResult {
|
||||
if (!this.breadcrumbs) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const parts = this.breadcrumbs.split(this.breadcrumbSeparator);
|
||||
return html`
|
||||
${parts.map((part, index) => html`
|
||||
${index > 0 ? html`<span class="breadcrumb-separator">${this.breadcrumbSeparator}</span>` : ''}
|
||||
<span
|
||||
class="breadcrumb-item"
|
||||
@click=${() => this.handleBreadcrumbClick(part, index)}
|
||||
>
|
||||
${part}
|
||||
</span>
|
||||
`)}
|
||||
`;
|
||||
}
|
||||
|
||||
public renderAccountSection(): TemplateResult {
|
||||
return html`
|
||||
${this.showSearch ? html`
|
||||
<dees-icon
|
||||
class="search-icon"
|
||||
.icon=${'lucide:search'}
|
||||
@click=${this.handleSearchClick}
|
||||
></dees-icon>
|
||||
` : ''}
|
||||
${this.user ? html`
|
||||
<div style="position: relative;">
|
||||
<div class="user-info" @click=${this.handleUserClick}>
|
||||
<div class="user-avatar">
|
||||
${this.user.avatar ?
|
||||
html`<img src="${this.user.avatar}" alt="${this.user.name}">` :
|
||||
html`${this.user.name.charAt(0).toUpperCase()}`
|
||||
}
|
||||
${this.user.status ? html`
|
||||
<div class="user-status ${this.user.status}"></div>
|
||||
` : ''}
|
||||
</div>
|
||||
<span>${this.user.name}</span>
|
||||
</div>
|
||||
<dees-appui-profiledropdown
|
||||
.user=${this.user}
|
||||
.menuItems=${this.profileMenuItems}
|
||||
.isOpen=${this.isProfileDropdownOpen}
|
||||
.position=${'top-right'}
|
||||
@menu-select=${(e: CustomEvent) => this.handleProfileMenuSelect(e)}
|
||||
></dees-appui-profiledropdown>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
private handleMenuClick(item: interfaces.IAppBarMenuItemRegular, itemId: string) {
|
||||
if (item.disabled) return;
|
||||
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
// Toggle dropdown
|
||||
if (this.activeMenu === itemId) {
|
||||
this.activeMenu = null;
|
||||
} else {
|
||||
this.activeMenu = itemId;
|
||||
}
|
||||
} else {
|
||||
// Execute action
|
||||
this.activeMenu = null;
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('menu-select', {
|
||||
detail: { item },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private handleDropdownItemClick(item: interfaces.IAppBarMenuItemRegular) {
|
||||
if (item.disabled) return;
|
||||
|
||||
this.activeMenu = null;
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('menu-select', {
|
||||
detail: { item },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleMenuKeydown(e: KeyboardEvent, item: interfaces.IAppBarMenuItemRegular, itemId: string) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
this.handleMenuClick(item, itemId);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (item.submenu && this.activeMenu === itemId) {
|
||||
e.preventDefault();
|
||||
// Focus first non-disabled item in dropdown
|
||||
this.focusedDropdownItem = 0;
|
||||
const firstValidItem = this.findNextValidItem(item.submenu, -1, 1);
|
||||
if (firstValidItem !== -1) {
|
||||
this.focusedDropdownItem = firstValidItem;
|
||||
// Focus the dropdown element
|
||||
setTimeout(() => {
|
||||
const dropdown = this.renderRoot.querySelector('.dropdown.open');
|
||||
if (dropdown) {
|
||||
(dropdown as HTMLElement).focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.activeMenu = null;
|
||||
this.focusedDropdownItem = -1;
|
||||
break;
|
||||
case 'Tab':
|
||||
// Let default tab navigation work but close dropdown
|
||||
if (this.activeMenu === itemId) {
|
||||
this.activeMenu = null;
|
||||
this.focusedDropdownItem = -1;
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
this.focusNextMenuItem(itemId, 1);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
this.focusNextMenuItem(itemId, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBreadcrumbClick(breadcrumb: string, index: number) {
|
||||
this.dispatchEvent(new CustomEvent('breadcrumb-navigate', {
|
||||
detail: { breadcrumb, index },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleSearchClick() {
|
||||
this.dispatchEvent(new CustomEvent('search-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleUserClick() {
|
||||
this.isProfileDropdownOpen = !this.isProfileDropdownOpen;
|
||||
|
||||
// Also emit the event for backward compatibility
|
||||
this.dispatchEvent(new CustomEvent('user-menu-open', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleProfileMenuSelect(e: CustomEvent) {
|
||||
this.isProfileDropdownOpen = false;
|
||||
|
||||
// Re-emit the event
|
||||
this.dispatchEvent(new CustomEvent('profile-menu-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Add global click listener to close dropdowns
|
||||
this.addEventListener('click', this.handleGlobalClick);
|
||||
document.addEventListener('click', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
}
|
||||
|
||||
private handleGlobalClick = (e: Event) => {
|
||||
// Prevent closing when clicking inside
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private handleDocumentClick = () => {
|
||||
// Close all dropdowns when clicking outside
|
||||
this.activeMenu = null;
|
||||
this.focusedDropdownItem = -1;
|
||||
// Note: Profile dropdown handles its own outside clicks
|
||||
}
|
||||
|
||||
private handleDropdownKeydown(e: KeyboardEvent, items: interfaces.IAppBarMenuItem[], _parentId: string) {
|
||||
const validItems = items.filter(item => !('divider' in item && item.divider));
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
const nextIndex = this.findNextValidItem(items, this.focusedDropdownItem, 1);
|
||||
if (nextIndex !== -1) {
|
||||
this.focusedDropdownItem = nextIndex;
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
const prevIndex = this.findNextValidItem(items, this.focusedDropdownItem, -1);
|
||||
if (prevIndex !== -1) {
|
||||
this.focusedDropdownItem = prevIndex;
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (this.focusedDropdownItem !== -1) {
|
||||
const focusedItem = validItems[this.focusedDropdownItem];
|
||||
if (focusedItem && 'action' in focusedItem && !focusedItem.disabled) {
|
||||
this.handleDropdownItemClick(focusedItem as interfaces.IAppBarMenuItemRegular);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
const firstIndex = this.findNextValidItem(items, -1, 1);
|
||||
if (firstIndex !== -1) {
|
||||
this.focusedDropdownItem = firstIndex;
|
||||
}
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
const lastIndex = this.findNextValidItem(items, items.length, -1);
|
||||
if (lastIndex !== -1) {
|
||||
this.focusedDropdownItem = lastIndex;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.activeMenu = null;
|
||||
this.focusedDropdownItem = -1;
|
||||
// Return focus to menu item
|
||||
const menuItem = this.renderRoot.querySelector(`.menuItem.active`);
|
||||
if (menuItem) {
|
||||
(menuItem as HTMLElement).focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private findNextValidItem(items: interfaces.IAppBarMenuItem[], currentIndex: number, direction: number): number {
|
||||
let index = currentIndex + direction;
|
||||
|
||||
while (index >= 0 && index < items.length) {
|
||||
const item = items[index];
|
||||
// Skip dividers and disabled items
|
||||
if (!('divider' in item && item.divider) && !('disabled' in item && item.disabled)) {
|
||||
return index;
|
||||
}
|
||||
index += direction;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private focusNextMenuItem(currentItemId: string, direction: number) {
|
||||
const menuItems = Array.from(this.renderRoot.querySelectorAll('.menuItem'));
|
||||
const currentIndex = menuItems.findIndex(item => item.getAttribute('data-item-id') === currentItemId);
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let nextIndex = currentIndex + direction;
|
||||
|
||||
// Wrap around
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = menuItems.length - 1;
|
||||
} else if (nextIndex >= menuItems.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
|
||||
// Find next non-disabled item
|
||||
let attempts = 0;
|
||||
while (attempts < menuItems.length) {
|
||||
const nextItem = menuItems[nextIndex] as HTMLElement;
|
||||
if (!nextItem.hasAttribute('disabled')) {
|
||||
nextItem.focus();
|
||||
// Close current dropdown if open
|
||||
if (this.activeMenu) {
|
||||
this.activeMenu = null;
|
||||
this.focusedDropdownItem = -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
nextIndex = (nextIndex + direction + menuItems.length) % menuItems.length;
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
212
ts_web/elements/dees-appui-appbar/demo.ts
Normal file
212
ts_web/elements/dees-appui-appbar/demo.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import type { DeesAppuiBar } from './component.js';
|
||||
import type { IAppBarMenuItem } from '../interfaces/appbarmenuitem.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './component.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
// Sample menu items with various configurations
|
||||
// Note: Following standard desktop UI patterns, top-level menu items don't have icons
|
||||
// Icons are only used in dropdown menu items for better visual hierarchy
|
||||
const menuItems: IAppBarMenuItem[] = [
|
||||
{
|
||||
name: 'File',
|
||||
action: async () => {}, // No-op action for menu with submenu
|
||||
submenu: [
|
||||
{ name: 'New File', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => console.log('New file') },
|
||||
{ name: 'Open...', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => console.log('Open') },
|
||||
{ name: 'Open Recent', action: async () => {}, submenu: [
|
||||
{ name: 'project-alpha.ts', action: async () => console.log('Open recent 1') },
|
||||
{ name: 'config.json', action: async () => console.log('Open recent 2') },
|
||||
{ name: 'readme.md', action: async () => console.log('Open recent 3') },
|
||||
]},
|
||||
{ divider: true },
|
||||
{ name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => console.log('Save') },
|
||||
{ name: 'Save As...', shortcut: 'Cmd+Shift+S', action: async () => console.log('Save as'), disabled: true },
|
||||
{ divider: true },
|
||||
{ name: 'Exit', shortcut: 'Cmd+Q', action: async () => console.log('Exit') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
action: async () => {}, // No-op action for menu with submenu
|
||||
submenu: [
|
||||
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
|
||||
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
|
||||
{ divider: true },
|
||||
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
|
||||
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
|
||||
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
|
||||
{ divider: true },
|
||||
{ name: 'Find', shortcut: 'Cmd+F', iconName: 'search', action: async () => console.log('Find') },
|
||||
{ name: 'Replace', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'View',
|
||||
action: async () => {}, // No-op action for menu with submenu
|
||||
submenu: [
|
||||
{ name: 'Toggle Fullscreen', shortcut: 'F11', iconName: 'expand', action: async () => console.log('Fullscreen') },
|
||||
{ name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoom-in', action: async () => console.log('Zoom in') },
|
||||
{ name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoom-out', action: async () => console.log('Zoom out') },
|
||||
{ name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
|
||||
{ divider: true },
|
||||
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
|
||||
{ name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Help',
|
||||
action: async () => {}, // No-op action for menu with submenu
|
||||
submenu: [
|
||||
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
|
||||
{ name: 'Release Notes', iconName: 'file-text', action: async () => console.log('Release notes') },
|
||||
{ divider: true },
|
||||
{ name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') },
|
||||
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const appbar = elementArg.querySelector('#appbar') as DeesAppuiBar;
|
||||
|
||||
// Set up status toggle
|
||||
const statusButtons = elementArg.querySelectorAll('.status-toggle dees-button');
|
||||
statusButtons[0].addEventListener('click', () => {
|
||||
appbar.user = { ...appbar.user, status: 'online' };
|
||||
});
|
||||
statusButtons[1].addEventListener('click', () => {
|
||||
appbar.user = { ...appbar.user, status: 'busy' };
|
||||
});
|
||||
statusButtons[2].addEventListener('click', () => {
|
||||
appbar.user = { ...appbar.user, status: 'away' };
|
||||
});
|
||||
statusButtons[3].addEventListener('click', () => {
|
||||
appbar.user = { ...appbar.user, status: 'offline' };
|
||||
});
|
||||
|
||||
// Set up window controls toggle
|
||||
const windowControlsButton = elementArg.querySelector('.window-controls-toggle dees-button');
|
||||
windowControlsButton.addEventListener('click', () => {
|
||||
appbar.showWindowControls = !appbar.showWindowControls;
|
||||
});
|
||||
|
||||
// Set up breadcrumb buttons
|
||||
const breadcrumbButtons = elementArg.querySelectorAll('.breadcrumb-toggle dees-button');
|
||||
breadcrumbButtons[0].addEventListener('click', () => {
|
||||
appbar.breadcrumbs = 'Home > Documents > Projects > MyApp > src > index.ts';
|
||||
});
|
||||
breadcrumbButtons[1].addEventListener('click', () => {
|
||||
appbar.breadcrumbs = 'Dashboard';
|
||||
});
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
height: 600px;
|
||||
width: 100%;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-appui-appbar
|
||||
id="appbar"
|
||||
.menuItems=${menuItems}
|
||||
.breadcrumbs=${'Project > src > components > AppBar.ts'}
|
||||
.breadcrumbSeparator=${' > '}
|
||||
.showWindowControls=${true}
|
||||
.showSearch=${true}
|
||||
.theme=${'dark'}
|
||||
.user=${{
|
||||
name: 'John Doe',
|
||||
status: 'online' as 'online' | 'offline' | 'busy' | 'away'
|
||||
}}
|
||||
@menu-select=${(e: CustomEvent) => console.log('Menu selected:', e.detail.item)}
|
||||
@breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb clicked:', e.detail)}
|
||||
@search-click=${() => console.log('Search clicked')}
|
||||
@user-menu-open=${() => console.log('User menu clicked')}
|
||||
></dees-appui-appbar>
|
||||
|
||||
<div class="content">
|
||||
<h2>App Bar Demo</h2>
|
||||
<p>This demo shows various features of the app bar component:</p>
|
||||
<ul>
|
||||
<li>Dynamic menu items with icons, shortcuts, and submenus</li>
|
||||
<li>Breadcrumb navigation</li>
|
||||
<li>User account section with status indicator</li>
|
||||
<li>Search icon</li>
|
||||
<li>Window controls (platform-specific)</li>
|
||||
<li>Dark/light theme support</li>
|
||||
<li>Keyboard navigation (Tab, Enter, Escape)</li>
|
||||
<li>Custom events for all interactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>Theme</label>
|
||||
<dees-button-group class="theme-toggle">
|
||||
<dees-button>Dark</dees-button>
|
||||
<dees-button>Light</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>User Status</label>
|
||||
<dees-button-group class="status-toggle">
|
||||
<dees-button>Online</dees-button>
|
||||
<dees-button>Busy</dees-button>
|
||||
<dees-button>Away</dees-button>
|
||||
<dees-button>Offline</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Window Controls</label>
|
||||
<dees-button-group class="window-controls-toggle">
|
||||
<dees-button>Toggle</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Breadcrumbs</label>
|
||||
<dees-button-group class="breadcrumb-toggle">
|
||||
<dees-button>Long Path</dees-button>
|
||||
<dees-button>Short Path</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
3
ts_web/elements/dees-appui-appbar/index.ts
Normal file
3
ts_web/elements/dees-appui-appbar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './component.js';
|
||||
export { appuiAppbarStyles } from './styles.js';
|
||||
export { renderAppuiAppbar } from './template.js';
|
||||
238
ts_web/elements/dees-appui-appbar/styles.ts
Normal file
238
ts_web/elements/dees-appui-appbar/styles.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const appuiAppbarStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
/* CSS Variables for theming */
|
||||
--appbar-height: 40px;
|
||||
--appbar-font-size: 12px;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--appbar-height);
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
font-size: var(--appbar-font-size);
|
||||
display: grid;
|
||||
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
padding: 0px 12px;
|
||||
margin: 8px 0px;
|
||||
border-radius: 4px;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Optional: Style for menu items with icons (not typically used for top-level items) */
|
||||
.menuItem dees-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.menuItem.active {
|
||||
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.menuItem[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.menuItem:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown styles */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
border-radius: 4px;
|
||||
box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')};
|
||||
margin-top: 4px;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item.focused {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.dropdown-item[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-item .shortcut {
|
||||
margin-left: auto;
|
||||
opacity: 0.6;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
|
||||
cursor: default;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Account section */
|
||||
.account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.search-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.user-status.offline {
|
||||
background: #757575;
|
||||
}
|
||||
|
||||
.user-status.busy {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.user-status.away {
|
||||
background: #ff9800;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
18
ts_web/elements/dees-appui-appbar/template.ts
Normal file
18
ts_web/elements/dees-appui-appbar/template.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesAppuiBar } from './component.js';
|
||||
|
||||
export const renderAppuiAppbar = (component: DeesAppuiBar): TemplateResult => {
|
||||
return html`
|
||||
<div class="menus">
|
||||
${component.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
|
||||
${component.renderMenuItems()}
|
||||
</div>
|
||||
<div class="breadcrumbs">
|
||||
${component.renderBreadcrumbs()}
|
||||
</div>
|
||||
<div class="account">
|
||||
${component.renderAccountSection()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
157
ts_web/elements/dees-appui-base.demo.ts
Normal file
157
ts_web/elements/dees-appui-base.demo.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import type { DeesAppuiBase } from './dees-appui-base.js';
|
||||
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
|
||||
import type { ITab } from './interfaces/tab.js';
|
||||
import type { ISelectionOption } from './interfaces/selectionoption.js';
|
||||
import * as plugins from './00plugins.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
// Menu items for the appbar
|
||||
const menuItems: IAppBarMenuItem[] = [
|
||||
{
|
||||
name: 'File',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'New Project', shortcut: 'Cmd+N', iconName: 'filePlus', action: async () => console.log('New project') },
|
||||
{ name: 'Open Project...', shortcut: 'Cmd+O', iconName: 'folderOpen', action: async () => console.log('Open project') },
|
||||
{ name: 'Recent Projects', action: async () => {}, submenu: [
|
||||
{ name: 'my-app', action: async () => console.log('Open my-app') },
|
||||
{ name: 'component-lib', action: async () => console.log('Open component-lib') },
|
||||
{ name: 'api-server', action: async () => console.log('Open api-server') },
|
||||
]},
|
||||
{ divider: true },
|
||||
{ name: 'Save All', shortcut: 'Cmd+Shift+S', iconName: 'save', action: async () => console.log('Save all') },
|
||||
{ divider: true },
|
||||
{ name: 'Close Project', action: async () => console.log('Close project') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
|
||||
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
|
||||
{ divider: true },
|
||||
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
|
||||
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
|
||||
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'View',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
|
||||
{ name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') },
|
||||
{ divider: true },
|
||||
{ name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoomIn', action: async () => console.log('Zoom in') },
|
||||
{ name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoomOut', action: async () => console.log('Zoom out') },
|
||||
{ name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Help',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
|
||||
{ name: 'Release Notes', iconName: 'fileText', action: async () => console.log('Release notes') },
|
||||
{ divider: true },
|
||||
{ name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') },
|
||||
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Main menu tabs (left sidebar)
|
||||
const mainMenuTabs: ITab[] = [
|
||||
{ key: 'dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard selected') },
|
||||
{ key: 'projects', iconName: 'lucide:folder', action: () => console.log('Projects selected') },
|
||||
{ key: 'analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics selected') },
|
||||
{ key: 'settings', iconName: 'lucide:settings', action: () => console.log('Settings selected') },
|
||||
];
|
||||
|
||||
// Selector options (second sidebar)
|
||||
const selectorOptions: (ISelectionOption | { divider: true })[] = [
|
||||
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview selected') },
|
||||
{ key: 'Components', iconName: 'package', action: () => console.log('Components selected') },
|
||||
{ key: 'Services', iconName: 'server', action: () => console.log('Services selected') },
|
||||
{ divider: true },
|
||||
{ key: 'Database', iconName: 'database', action: () => console.log('Database selected') },
|
||||
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings selected') },
|
||||
];
|
||||
|
||||
// Main content tabs
|
||||
const mainContentTabs: ITab[] = [
|
||||
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details tab') },
|
||||
{ key: 'Logs', iconName: 'lucide:list', action: () => console.log('Logs tab') },
|
||||
{ key: 'Metrics', iconName: 'lucide:lineChart', action: () => console.log('Metrics tab') },
|
||||
];
|
||||
|
||||
// Profile menu items
|
||||
const profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [
|
||||
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile settings') },
|
||||
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account settings') },
|
||||
{ divider: true },
|
||||
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
|
||||
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
|
||||
{ divider: true },
|
||||
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-appui-base
|
||||
.appbarMenuItems=${menuItems}
|
||||
.appbarBreadcrumbs=${'Dashboard'}
|
||||
.appbarUser=${{
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
status: 'online' as 'online' | 'offline' | 'busy' | 'away'
|
||||
}}
|
||||
.appbarProfileMenuItems=${profileMenuItems}
|
||||
.appbarShowWindowControls=${true}
|
||||
.appbarShowSearch=${true}
|
||||
.mainmenuTabs=${mainMenuTabs}
|
||||
.mainselectorOptions=${selectorOptions}
|
||||
.maincontentTabs=${mainContentTabs}
|
||||
@appbar-menu-select=${(e: CustomEvent) => console.log('Menu selected:', e.detail)}
|
||||
@appbar-breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb:', e.detail)}
|
||||
@appbar-search-click=${() => console.log('Search clicked')}
|
||||
@appbar-user-menu-open=${() => console.log('User menu opened')}
|
||||
@appbar-profile-menu-select=${(e: CustomEvent) => console.log('Profile menu selected:', e.detail)}
|
||||
@mainmenu-tab-select=${(e: CustomEvent) => console.log('Tab selected:', e.detail)}
|
||||
@mainselector-option-select=${(e: CustomEvent) => console.log('Option selected:', e.detail)}
|
||||
>
|
||||
<div slot="maincontent" style="padding: 40px; color: #ccc;">
|
||||
<h1>Application Content</h1>
|
||||
<p>This is the main content area where your application's primary interface would be displayed.</p>
|
||||
<p>The layout includes:</p>
|
||||
<ul>
|
||||
<li>App bar with menus, breadcrumbs, and user account</li>
|
||||
<li>Main menu (left sidebar) for primary navigation</li>
|
||||
<li>Selector menu (second sidebar) for sub-navigation</li>
|
||||
<li>Main content area (this section)</li>
|
||||
<li>Activity log (right sidebar)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dees-appui-base>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
219
ts_web/elements/dees-appui-base.ts
Normal file
219
ts_web/elements/dees-appui-base.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import * as plugins from './00plugins.js';
|
||||
import type { DeesAppuiBar } from './dees-appui-appbar/index.js';
|
||||
import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
|
||||
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
|
||||
import type { DeesAppuiMaincontent } from './dees-appui-maincontent.js';
|
||||
import type { DeesAppuiActivitylog } from './dees-appui-activitylog.js';
|
||||
import { demoFunc } from './dees-appui-base.demo.js';
|
||||
|
||||
// Import child components
|
||||
import './dees-appui-appbar/index.js';
|
||||
import './dees-appui-mainmenu.js';
|
||||
import './dees-appui-mainselector.js';
|
||||
import './dees-appui-maincontent.js';
|
||||
import './dees-appui-activitylog.js';
|
||||
|
||||
@customElement('dees-appui-base')
|
||||
export class DeesAppuiBase extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// Properties for appbar
|
||||
@property({ type: Array })
|
||||
public appbarMenuItems: interfaces.IAppBarMenuItem[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
public appbarBreadcrumbs: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
public appbarBreadcrumbSeparator: string = ' > ';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public appbarShowWindowControls: boolean = true;
|
||||
|
||||
|
||||
@property({ type: Object })
|
||||
public appbarUser?: {
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
status?: 'online' | 'offline' | 'busy' | 'away';
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public appbarProfileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
public appbarShowSearch: boolean = false;
|
||||
|
||||
// Properties for mainmenu
|
||||
@property({ type: Array })
|
||||
public mainmenuTabs: interfaces.ITab[] = [];
|
||||
|
||||
@property({ type: Object })
|
||||
public mainmenuSelectedTab?: interfaces.ITab;
|
||||
|
||||
// Properties for mainselector
|
||||
@property({ type: Array })
|
||||
public mainselectorOptions: (interfaces.ISelectionOption | { divider: true })[] = [];
|
||||
|
||||
@property({ type: Object })
|
||||
public mainselectorSelectedOption?: interfaces.ISelectionOption;
|
||||
|
||||
// Properties for maincontent
|
||||
@property({ type: Array })
|
||||
public maincontentTabs: interfaces.ITab[] = [];
|
||||
|
||||
// References to child components
|
||||
@state()
|
||||
public appbar?: DeesAppuiBar;
|
||||
|
||||
@state()
|
||||
public mainmenu?: DeesAppuiMainmenu;
|
||||
|
||||
@state()
|
||||
public mainselector?: DeesAppuiMainselector;
|
||||
|
||||
@state()
|
||||
public maincontent?: DeesAppuiMaincontent;
|
||||
|
||||
@state()
|
||||
public activitylog?: DeesAppuiActivitylog;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
|
||||
}
|
||||
.maingrid {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
height: calc(100% - 40px);
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 240px 1fr 240px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
// INSTANCE
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style></style>
|
||||
<dees-appui-appbar
|
||||
.menuItems=${this.appbarMenuItems}
|
||||
.breadcrumbs=${this.appbarBreadcrumbs}
|
||||
.breadcrumbSeparator=${this.appbarBreadcrumbSeparator}
|
||||
.showWindowControls=${this.appbarShowWindowControls}
|
||||
.user=${this.appbarUser}
|
||||
.profileMenuItems=${this.appbarProfileMenuItems}
|
||||
.showSearch=${this.appbarShowSearch}
|
||||
@menu-select=${(e: CustomEvent) => this.handleAppbarMenuSelect(e)}
|
||||
@breadcrumb-navigate=${(e: CustomEvent) => this.handleAppbarBreadcrumbNavigate(e)}
|
||||
@search-click=${() => this.handleAppbarSearchClick()}
|
||||
@user-menu-open=${() => this.handleAppbarUserMenuOpen()}
|
||||
@profile-menu-select=${(e: CustomEvent) => this.handleAppbarProfileMenuSelect(e)}
|
||||
></dees-appui-appbar>
|
||||
<div class="maingrid">
|
||||
<dees-appui-mainmenu
|
||||
.tabs=${this.mainmenuTabs}
|
||||
.selectedTab=${this.mainmenuSelectedTab}
|
||||
@tab-select=${(e: CustomEvent) => this.handleMainmenuTabSelect(e)}
|
||||
></dees-appui-mainmenu>
|
||||
<dees-appui-mainselector
|
||||
.selectionOptions=${this.mainselectorOptions}
|
||||
.selectedOption=${this.mainselectorSelectedOption}
|
||||
@option-select=${(e: CustomEvent) => this.handleMainselectorOptionSelect(e)}
|
||||
></dees-appui-mainselector>
|
||||
<dees-appui-maincontent
|
||||
.tabs=${this.maincontentTabs}
|
||||
>
|
||||
<slot name="maincontent"></slot>
|
||||
</dees-appui-maincontent>
|
||||
<dees-appui-activitylog></dees-appui-activitylog>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
// Get references to child components
|
||||
this.appbar = this.shadowRoot.querySelector('dees-appui-appbar');
|
||||
this.mainmenu = this.shadowRoot.querySelector('dees-appui-mainmenu');
|
||||
this.mainselector = this.shadowRoot.querySelector('dees-appui-mainselector');
|
||||
this.maincontent = this.shadowRoot.querySelector('dees-appui-maincontent');
|
||||
this.activitylog = this.shadowRoot.querySelector('dees-appui-activitylog');
|
||||
}
|
||||
|
||||
// Event handlers for appbar
|
||||
private handleAppbarMenuSelect(e: CustomEvent) {
|
||||
this.dispatchEvent(new CustomEvent('appbar-menu-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleAppbarBreadcrumbNavigate(e: CustomEvent) {
|
||||
this.dispatchEvent(new CustomEvent('appbar-breadcrumb-navigate', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleAppbarSearchClick() {
|
||||
this.dispatchEvent(new CustomEvent('appbar-search-click', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleAppbarUserMenuOpen() {
|
||||
this.dispatchEvent(new CustomEvent('appbar-user-menu-open', {
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
private handleAppbarProfileMenuSelect(e: CustomEvent) {
|
||||
this.dispatchEvent(new CustomEvent('appbar-profile-menu-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Event handlers for mainmenu
|
||||
private handleMainmenuTabSelect(e: CustomEvent) {
|
||||
this.mainmenuSelectedTab = e.detail.tab;
|
||||
this.dispatchEvent(new CustomEvent('mainmenu-tab-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Event handlers for mainselector
|
||||
private handleMainselectorOptionSelect(e: CustomEvent) {
|
||||
this.mainselectorSelectedOption = e.detail.option;
|
||||
this.dispatchEvent(new CustomEvent('mainselector-option-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
121
ts_web/elements/dees-appui-maincontent.ts
Normal file
121
ts_web/elements/dees-appui-maincontent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import './dees-appui-tabs.js';
|
||||
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
|
||||
|
||||
@customElement('dees-appui-maincontent')
|
||||
export class DeesAppuiMaincontent extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-appui-maincontent
|
||||
.tabs=${[
|
||||
{ key: 'Overview', iconName: 'lucide:home', action: () => console.log('Overview') },
|
||||
{ key: 'Details', iconName: 'lucide:file', action: () => console.log('Details') },
|
||||
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
|
||||
]}
|
||||
>
|
||||
<div slot="content" style="padding: 40px; color: #ccc;">
|
||||
<h1>Main Content Area</h1>
|
||||
<p>This is where your application content goes.</p>
|
||||
</div>
|
||||
</dees-appui-maincontent>
|
||||
`;
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public tabs: interfaces.ITab[] = [
|
||||
{ key: '⚠️ Please set tabs', action: () => console.warn('No tabs configured for maincontent') },
|
||||
];
|
||||
|
||||
@property({ type: Object })
|
||||
public selectedTab: interfaces.ITab | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#161616')};
|
||||
}
|
||||
.maincontainer {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="maincontainer">
|
||||
<div class="topbar">
|
||||
<dees-appui-tabs
|
||||
.tabs=${this.tabs}
|
||||
.selectedTab=${this.selectedTab}
|
||||
.showTabIndicator=${true}
|
||||
.tabStyle=${'horizontal'}
|
||||
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
|
||||
></dees-appui-tabs>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<slot></slot>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleTabSelect(e: CustomEvent) {
|
||||
this.selectedTab = e.detail.tab;
|
||||
|
||||
// Re-emit the event
|
||||
this.dispatchEvent(new CustomEvent('tab-select', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
await super.firstUpdated(_changedProperties);
|
||||
// Tab selection is now handled by the dees-appui-tabs component
|
||||
// But we need to ensure the tabs component is ready
|
||||
const tabsComponent = this.shadowRoot.querySelector('dees-appui-tabs') as DeesAppuiTabs;
|
||||
if (tabsComponent) {
|
||||
await tabsComponent.updateComplete;
|
||||
}
|
||||
}
|
||||
}
|
||||
161
ts_web/elements/dees-appui-mainmenu.ts
Normal file
161
ts_web/elements/dees-appui-mainmenu.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
import { zIndexLayers } from './00zindex.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
|
||||
/**
|
||||
* the most left menu
|
||||
* usually used as organization selector
|
||||
*/
|
||||
@customElement('dees-appui-mainmenu')
|
||||
export class DeesAppuiMainmenu extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-appui-mainmenu
|
||||
.tabs=${[
|
||||
{ key: 'Dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard') },
|
||||
{ key: 'Projects', iconName: 'lucide:folder', action: () => console.log('Projects') },
|
||||
{ key: 'Analytics', iconName: 'lucide:lineChart', action: () => console.log('Analytics') },
|
||||
{ key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings') },
|
||||
]}
|
||||
></dees-appui-mainmenu>
|
||||
`;
|
||||
|
||||
// INSTANCE
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
public tabs: interfaces.ITab[] = [
|
||||
{ key: '⚠️ Please set tabs', iconName: 'lucide:alertTriangle', action: () => console.warn('No tabs configured for mainmenu') },
|
||||
];
|
||||
|
||||
@property()
|
||||
public selectedTab: interfaces.ITab;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
.mainContainer {
|
||||
--menuSize: 60px;
|
||||
color: ${cssManager.bdTheme('#666', '#ccc')};
|
||||
z-index: ${zIndexLayers.fixed.appBar};
|
||||
display: block;
|
||||
position: relative;
|
||||
width: var(--menuSize);
|
||||
height: 100%;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#000000')};
|
||||
box-shadow: ${cssManager.bdTheme('0px 0px 5px rgba(0, 0, 0, 0.1)', '0px 0px 5px rgba(0, 0, 0, 0.5)')};
|
||||
user-select: none;
|
||||
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding-top: 18px;
|
||||
font-size: 24px;
|
||||
width: var(--menuSize);
|
||||
height: var(--menuSize);
|
||||
text-align: center;
|
||||
transition: color 0.1s, background 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.15)')};
|
||||
}
|
||||
|
||||
.tab.selectedTab {
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
}
|
||||
|
||||
.tabIndicator {
|
||||
opacity: 0;
|
||||
background: ${cssManager.bdTheme('#2196f3', '#4e729a')};
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: calc((var(--menuSize) / 3) * 2);
|
||||
left: 0px;
|
||||
top: calc(var(--menuSize) - (((var(--menuSize) / 3) * 2) / 2));
|
||||
border-top-right-radius: 7px;
|
||||
border-bottom-right-radius: 7px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="mainContainer" @contextmenu=${(eventArg) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [{
|
||||
name: 'app settings',
|
||||
action: async () => {},
|
||||
iconName: 'gear',
|
||||
}])
|
||||
}}>
|
||||
<div class="tabsContainer">
|
||||
${this.tabs.map((tabArg) => {
|
||||
return html`
|
||||
<div
|
||||
class="tab ${tabArg === this.selectedTab ? 'selectedTab' : null}"
|
||||
@click="${() => {
|
||||
this.updateTab(tabArg);
|
||||
}}"
|
||||
>
|
||||
<dees-icon .icon="${tabArg.iconName || ''}"></dees-icon>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
<div class="tabIndicator"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private updateTabIndicator() {
|
||||
let selectedTab = this.selectedTab;
|
||||
if (!selectedTab) {
|
||||
selectedTab = this.tabs[0];
|
||||
}
|
||||
const tabIndex = this.tabs.indexOf(selectedTab);
|
||||
const selectedTabElement: HTMLElement = this.shadowRoot.querySelector(
|
||||
`.tabsContainer .tab:nth-child(${tabIndex + 1})`
|
||||
);
|
||||
|
||||
if (!selectedTabElement) return;
|
||||
|
||||
const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabIndicator');
|
||||
if (!tabIndicator) return;
|
||||
|
||||
const offsetTop = selectedTabElement.offsetTop;
|
||||
tabIndicator.style.opacity = `1`;
|
||||
tabIndicator.style.top = `calc(${offsetTop}px + (var(--menuSize) / 6))`;
|
||||
}
|
||||
|
||||
updateTab(tabArg: interfaces.ITab) {
|
||||
this.selectedTab = tabArg;
|
||||
this.updateTabIndicator();
|
||||
this.selectedTab.action();
|
||||
|
||||
// Emit tab-select event
|
||||
this.dispatchEvent(new CustomEvent('tab-select', {
|
||||
detail: { tab: tabArg },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.updateTab(this.tabs[0]);
|
||||
}
|
||||
}
|
||||
211
ts_web/elements/dees-appui-mainselector.ts
Normal file
211
ts_web/elements/dees-appui-mainselector.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
import './dees-icon.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* the property selector menu
|
||||
* mainly used to select assets within in an organization
|
||||
*/
|
||||
@customElement('dees-appui-mainselector')
|
||||
export class DeesAppuiMainselector extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-appui-mainselector
|
||||
.selectionOptions=${[
|
||||
{ key: 'Overview', iconName: 'home', action: () => console.log('Overview') },
|
||||
{ key: 'Components', iconName: 'package', action: () => console.log('Components') },
|
||||
{ key: 'Services', iconName: 'server', action: () => console.log('Services') },
|
||||
{ key: 'Database', iconName: 'database', action: () => console.log('Database') },
|
||||
{ key: 'Settings', iconName: 'settings', action: () => console.log('Settings') },
|
||||
]}
|
||||
></dees-appui-mainselector>
|
||||
`;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
public selectionOptions: (interfaces.ISelectionOption | { divider: true })[] = [
|
||||
{ key: '⚠️ Please set selection options', action: () => console.warn('No selection options configured for mainselector') },
|
||||
];
|
||||
|
||||
@property()
|
||||
public selectedOption: interfaces.ISelectionOption = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 100%;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#000000')};
|
||||
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
}
|
||||
.maincontainer {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: absolute;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
}
|
||||
|
||||
.topbar .heading {
|
||||
padding-left: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.selectionOptions {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.selectionOptions .selectionOption {
|
||||
cursor: default;
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.selectionOptions .selectionOption:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
|
||||
}
|
||||
|
||||
.selectionOptions .selectionOption:active {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
||||
}
|
||||
|
||||
.selectionOptions .selectionOption.selectedOption {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selectionOptions .selectionOption.selectedOption::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: ${cssManager.bdTheme('#26a69a', '#26a69a')};
|
||||
}
|
||||
|
||||
.selectionOption {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selection-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
margin: 4px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style></style>
|
||||
<div class="maincontainer">
|
||||
<div class="topbar">
|
||||
<div class="heading">Selector</div>
|
||||
</div>
|
||||
<div class="selectionOptions">
|
||||
${this.selectionOptions.map((selectionOptionArg) => {
|
||||
if ('divider' in selectionOptionArg && selectionOptionArg.divider) {
|
||||
return html`<div class="selection-divider"></div>`;
|
||||
}
|
||||
|
||||
const option = selectionOptionArg as interfaces.ISelectionOption;
|
||||
return html`
|
||||
<div
|
||||
class="selectionOption ${this.selectedOption === option
|
||||
? 'selectedOption'
|
||||
: null}"
|
||||
@click="${() => {
|
||||
this.selectOption(option);
|
||||
}}"
|
||||
@contextmenu="${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'property settings',
|
||||
action: async () => {},
|
||||
iconName: 'gear',
|
||||
},
|
||||
]);
|
||||
}}"
|
||||
>
|
||||
${option.iconName ? html`
|
||||
<dees-icon .icon="${`lucide:${option.iconName}`}" style="font-size: 14px; opacity: 0.7;"></dees-icon>
|
||||
` : ''}
|
||||
<span style="flex: 1;">${option.key}</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectOption(optionArg: interfaces.ISelectionOption) {
|
||||
this.selectedOption = optionArg;
|
||||
this.selectedOption.action();
|
||||
|
||||
// Emit option-select event
|
||||
this.dispatchEvent(new CustomEvent('option-select', {
|
||||
detail: { option: optionArg },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
await super.firstUpdated(_changedProperties);
|
||||
if (this.selectionOptions && this.selectionOptions.length > 0) {
|
||||
await this.updateComplete;
|
||||
// Find first non-divider option
|
||||
const firstOption = this.selectionOptions.find(option => !('divider' in option)) as interfaces.ISelectionOption;
|
||||
if (firstOption) {
|
||||
this.selectOption(firstOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
402
ts_web/elements/dees-appui-profiledropdown.ts
Normal file
402
ts_web/elements/dees-appui-profiledropdown.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import { zIndexLayers } from './00zindex.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('dees-appui-profiledropdown')
|
||||
export class DeesAppuiProfileDropdown extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-appui-profiledropdown
|
||||
.user=${{
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
avatar: 'https://randomuser.me/api/portraits/men/1.jpg',
|
||||
status: 'online' as 'online'
|
||||
}}
|
||||
.menuItems=${[
|
||||
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile') },
|
||||
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account') },
|
||||
{ divider: true },
|
||||
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
|
||||
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
|
||||
{ divider: true },
|
||||
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
|
||||
]}
|
||||
.isOpen=${true}
|
||||
></dees-appui-profiledropdown>
|
||||
`;
|
||||
|
||||
@property({ type: Object })
|
||||
public user?: {
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
status?: 'online' | 'offline' | 'busy' | 'away';
|
||||
};
|
||||
|
||||
@property({ type: Array })
|
||||
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public isOpen: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public position: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' = 'top-right';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
min-width: 220px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
border-radius: 4px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
z-index: ${zIndexLayers.overlay.dropdown};
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:host([isopen]) .dropdown {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Position variants */
|
||||
.dropdown.top-right {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropdown.top-left {
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dropdown.bottom-right {
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dropdown.bottom-left {
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* User section */
|
||||
.user-section {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.user-status.offline {
|
||||
background: #757575;
|
||||
}
|
||||
|
||||
.user-status.busy {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.user-status.away {
|
||||
background: #ff9800;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Menu section */
|
||||
.menu-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
|
||||
}
|
||||
|
||||
.menu-item:active {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
||||
}
|
||||
|
||||
.menu-item dees-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menu-shortcut {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Backdrop for mobile */
|
||||
@media (max-width: 768px) {
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: ${zIndexLayers.backdrop.dropdown};
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host([isopen]) .backdrop {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
margin: 0;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:host([isopen]) .dropdown {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="backdrop" @click=${() => this.close()}></div>
|
||||
<div class="dropdown ${this.position}">
|
||||
${this.user ? html`
|
||||
<div class="user-section">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
${this.user.avatar
|
||||
? html`<img src="${this.user.avatar}" alt="${this.user.name}">`
|
||||
: this.getInitials(this.user.name)
|
||||
}
|
||||
${this.user.status ? html`
|
||||
<div class="user-status ${this.user.status}"></div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="user-name">${this.user.name}</div>
|
||||
${this.user.email ? html`
|
||||
<div class="user-email">${this.user.email}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="menu-section">
|
||||
${this.menuItems.map(item => this.renderMenuItem(item))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMenuItem(item: plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true }): TemplateResult {
|
||||
if ('divider' in item && item.divider) {
|
||||
return html`<div class="menu-divider"></div>`;
|
||||
}
|
||||
|
||||
const menuItem = item as plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string };
|
||||
return html`
|
||||
<div class="menu-item" @click=${() => this.handleMenuClick(menuItem)}>
|
||||
${menuItem.iconName ? html`
|
||||
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
|
||||
` : ''}
|
||||
<span class="menu-item-text">${menuItem.name}</span>
|
||||
${menuItem.shortcut ? html`
|
||||
<span class="menu-shortcut">${menuItem.shortcut}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
private async handleMenuClick(item: plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string }) {
|
||||
await item.action();
|
||||
this.close();
|
||||
|
||||
// Emit menu-select event
|
||||
this.dispatchEvent(new CustomEvent('menu-select', {
|
||||
detail: { item },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
public toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
// Handle clicks outside the dropdown
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
this.handleOutsideClick = this.handleOutsideClick.bind(this);
|
||||
document.addEventListener('click', this.handleOutsideClick);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.handleOutsideClick);
|
||||
}
|
||||
|
||||
private handleOutsideClick(event: MouseEvent) {
|
||||
if (this.isOpen && !this.contains(event.target as Node)) {
|
||||
// Check if the click is on the parent element (which contains the profile button)
|
||||
const parentElement = this.parentElement;
|
||||
if (parentElement && parentElement.contains(event.target as Node)) {
|
||||
// Don't close if clicking within the parent element (e.g., on the profile button)
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
451
ts_web/elements/dees-appui-tabs.ts
Normal file
451
ts_web/elements/dees-appui-tabs.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
|
||||
@customElement('dees-appui-tabs')
|
||||
export class DeesAppuiTabs extends DeesElement {
|
||||
public static demo = () => {
|
||||
const horizontalTabs: interfaces.ITab[] = [
|
||||
{ key: 'Home', iconName: 'lucide:home', action: () => console.log('Home clicked') },
|
||||
{ key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => console.log('Analytics clicked') },
|
||||
{ key: 'Reports', iconName: 'lucide:fileText', action: () => console.log('Reports clicked') },
|
||||
{ key: 'User Settings', iconName: 'lucide:settings', action: () => console.log('Settings clicked') },
|
||||
{ key: 'Help', iconName: 'lucide:helpCircle', action: () => console.log('Help clicked') },
|
||||
];
|
||||
|
||||
const verticalTabs: interfaces.ITab[] = [
|
||||
{ key: 'Profile', iconName: 'lucide:user', action: () => console.log('Profile clicked') },
|
||||
{ key: 'Security', iconName: 'lucide:shield', action: () => console.log('Security clicked') },
|
||||
{ key: 'Notifications', iconName: 'lucide:bell', action: () => console.log('Notifications clicked') },
|
||||
{ key: 'Integrations', iconName: 'lucide:link', action: () => console.log('Integrations clicked') },
|
||||
{ key: 'Advanced', iconName: 'lucide:code', action: () => console.log('Advanced clicked') },
|
||||
];
|
||||
|
||||
const noIndicatorTabs: interfaces.ITab[] = [
|
||||
{ key: 'All', action: () => console.log('All clicked') },
|
||||
{ key: 'Active', action: () => console.log('Active clicked') },
|
||||
{ key: 'Completed', action: () => console.log('Completed clicked') },
|
||||
{ key: 'Archived', action: () => console.log('Archived clicked') },
|
||||
];
|
||||
|
||||
const demoContent = (text: string) => html`
|
||||
<div style="padding: 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
|
||||
${text}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding: 48px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
</style>
|
||||
<div class="demo-container">
|
||||
<div class="section">
|
||||
<div class="section-title">Horizontal Tabs with Animated Indicator</div>
|
||||
<dees-appui-tabs .tabs=${horizontalTabs}>
|
||||
${demoContent('Select a tab to see the smooth sliding animation of the indicator. The indicator automatically adjusts its width to match the tab content with minimal padding.')}
|
||||
</dees-appui-tabs>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Vertical Tabs Layout</div>
|
||||
<div class="two-column">
|
||||
<dees-appui-tabs .tabStyle=${'vertical'} .tabs=${verticalTabs}></dees-appui-tabs>
|
||||
${demoContent('Vertical tabs work great for settings pages and navigation menus. The animated indicator smoothly transitions between selections.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Without Indicator</div>
|
||||
<dees-appui-tabs .showTabIndicator=${false} .tabs=${noIndicatorTabs}>
|
||||
${demoContent('Tabs can also be used without the animated indicator by setting showTabIndicator to false.')}
|
||||
</dees-appui-tabs>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public tabs: interfaces.ITab[] = [];
|
||||
|
||||
@property({ type: Object })
|
||||
public selectedTab: interfaces.ITab | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showTabIndicator: boolean = true;
|
||||
|
||||
@property({ type: String })
|
||||
public tabStyle: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabs-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tabs-wrapper.horizontal-wrapper {
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tabsContainer.horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tabsContainer.horizontal::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabsContainer.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
gap: 2px;
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.horizontal .tab {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
border-radius: 6px 6px 0 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.horizontal .tab:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 20px;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.horizontal .tab .tab-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vertical .tab {
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.horizontal .tab:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)')};
|
||||
}
|
||||
|
||||
.horizontal .tab:hover::after,
|
||||
.horizontal .tab:hover + .tab::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.vertical .tab:hover {
|
||||
background: ${cssManager.bdTheme('rgba(244, 244, 245, 0.5)', 'rgba(39, 39, 42, 0.5)')};
|
||||
}
|
||||
|
||||
.horizontal .tab.selectedTab {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.horizontal .tab.selectedTab::after,
|
||||
.horizontal .tab.selectedTab + .tab::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.vertical .tab.selectedTab {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.tab dees-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tabIndicator {
|
||||
position: absolute;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tabIndicator.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.tabs-wrapper .tabIndicator {
|
||||
height: 3px;
|
||||
bottom: 0;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
border-radius: 3px 3px 0 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.vertical-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vertical-wrapper .tabIndicator {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#27272a')};
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${this.renderTabsWrapper()}
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabsWrapper(): TemplateResult {
|
||||
const isHorizontal = this.tabStyle === 'horizontal';
|
||||
const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper';
|
||||
const containerClass = `tabsContainer ${this.tabStyle}`;
|
||||
|
||||
return html`
|
||||
<div class="${wrapperClass}">
|
||||
<div class="${containerClass}">
|
||||
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
|
||||
</div>
|
||||
${this.showTabIndicator ? html`<div class="tabIndicator"></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTab(tab: interfaces.ITab, isHorizontal: boolean): TemplateResult {
|
||||
const isSelected = tab === this.selectedTab;
|
||||
const classes = `tab ${isSelected ? 'selectedTab' : ''}`;
|
||||
|
||||
const content = isHorizontal ? html`
|
||||
<span class="tab-content">
|
||||
${this.renderTabIcon(tab)}
|
||||
${tab.key}
|
||||
</span>
|
||||
` : html`
|
||||
${this.renderTabIcon(tab)}
|
||||
${tab.key}
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="${classes}"
|
||||
@click="${() => this.selectTab(tab)}"
|
||||
>
|
||||
${content}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabIcon(tab: interfaces.ITab): TemplateResult | '' {
|
||||
return tab.iconName ? html`<dees-icon .icon=${tab.iconName}></dees-icon>` : '';
|
||||
}
|
||||
|
||||
private selectTab(tabArg: interfaces.ITab) {
|
||||
this.selectedTab = tabArg;
|
||||
tabArg.action();
|
||||
|
||||
// Emit tab-select event
|
||||
this.dispatchEvent(new CustomEvent('tab-select', {
|
||||
detail: { tab: tabArg },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
if (this.tabs && this.tabs.length > 0) {
|
||||
this.selectTab(this.tabs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('tabs') && this.tabs && this.tabs.length > 0 && !this.selectedTab) {
|
||||
this.selectTab(this.tabs[0]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {
|
||||
await this.updateComplete;
|
||||
// Wait for fonts to load on first update
|
||||
if (!this.indicatorInitialized && document.fonts) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
this.updateTabIndicator();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private indicatorInitialized = false;
|
||||
|
||||
private updateTabIndicator() {
|
||||
if (!this.shouldShowIndicator()) return;
|
||||
|
||||
const selectedTabElement = this.getSelectedTabElement();
|
||||
if (!selectedTabElement) return;
|
||||
|
||||
const indicator = this.getIndicatorElement();
|
||||
if (!indicator) return;
|
||||
|
||||
this.handleInitialTransition(indicator);
|
||||
|
||||
if (this.tabStyle === 'horizontal') {
|
||||
this.updateHorizontalIndicator(indicator, selectedTabElement);
|
||||
} else {
|
||||
this.updateVerticalIndicator(indicator, selectedTabElement);
|
||||
}
|
||||
|
||||
indicator.style.opacity = '1';
|
||||
}
|
||||
|
||||
private shouldShowIndicator(): boolean {
|
||||
return this.selectedTab && this.showTabIndicator && this.tabs.includes(this.selectedTab);
|
||||
}
|
||||
|
||||
private getSelectedTabElement(): HTMLElement | null {
|
||||
const selectedIndex = this.tabs.indexOf(this.selectedTab);
|
||||
const isHorizontal = this.tabStyle === 'horizontal';
|
||||
const selector = isHorizontal
|
||||
? `.tabs-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`
|
||||
: `.vertical-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`;
|
||||
|
||||
return this.shadowRoot.querySelector(selector);
|
||||
}
|
||||
|
||||
private getIndicatorElement(): HTMLElement | null {
|
||||
return this.shadowRoot.querySelector('.tabIndicator');
|
||||
}
|
||||
|
||||
private handleInitialTransition(indicator: HTMLElement): void {
|
||||
if (!this.indicatorInitialized) {
|
||||
indicator.classList.add('no-transition');
|
||||
this.indicatorInitialized = true;
|
||||
|
||||
setTimeout(() => {
|
||||
indicator.classList.remove('no-transition');
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
private updateHorizontalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void {
|
||||
const tabContent = tabElement.querySelector('.tab-content') as HTMLElement;
|
||||
if (!tabContent) return;
|
||||
|
||||
const wrapperRect = indicator.parentElement.getBoundingClientRect();
|
||||
const contentRect = tabContent.getBoundingClientRect();
|
||||
|
||||
const contentLeft = contentRect.left - wrapperRect.left;
|
||||
const indicatorWidth = contentRect.width + 8;
|
||||
const indicatorLeft = contentLeft - 4;
|
||||
|
||||
indicator.style.width = `${indicatorWidth}px`;
|
||||
indicator.style.left = `${indicatorLeft}px`;
|
||||
}
|
||||
|
||||
private updateVerticalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void {
|
||||
const tabsContainer = this.shadowRoot.querySelector('.vertical-wrapper .tabsContainer') as HTMLElement;
|
||||
if (!tabsContainer) return;
|
||||
|
||||
indicator.style.top = `${tabElement.offsetTop + tabsContainer.offsetTop}px`;
|
||||
indicator.style.height = `${tabElement.clientHeight}px`;
|
||||
}
|
||||
}
|
||||
192
ts_web/elements/dees-appui-view.ts
Normal file
192
ts_web/elements/dees-appui-view.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import * as interfaces from './interfaces/index.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import './dees-appui-tabs.js';
|
||||
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
|
||||
|
||||
export interface IAppViewTab extends interfaces.ITab {
|
||||
content?: TemplateResult | (() => TemplateResult);
|
||||
}
|
||||
|
||||
export interface IAppView {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconName?: string;
|
||||
tabs: IAppViewTab[];
|
||||
menuItems?: interfaces.ISelectionOption[];
|
||||
}
|
||||
|
||||
@customElement('dees-appui-view')
|
||||
export class DeesAppuiView extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-appui-view
|
||||
.viewConfig=${{
|
||||
id: 'demo-view',
|
||||
name: 'Demo View',
|
||||
description: 'A demonstration view',
|
||||
iconName: 'lucide:home',
|
||||
tabs: [
|
||||
{
|
||||
key: 'overview',
|
||||
iconName: 'lucide:lineChart',
|
||||
action: () => console.log('Overview tab'),
|
||||
content: html`<div style="padding: 20px;">Overview Content</div>`
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
iconName: 'lucide:fileText',
|
||||
action: () => console.log('Details tab'),
|
||||
content: html`<div style="padding: 20px;">Details Content</div>`
|
||||
}
|
||||
],
|
||||
menuItems: [
|
||||
{ key: 'General', action: () => console.log('General') },
|
||||
{ key: 'Advanced', action: () => console.log('Advanced') },
|
||||
]
|
||||
}}
|
||||
></dees-appui-view>
|
||||
`;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Object })
|
||||
public viewConfig: IAppView;
|
||||
|
||||
@state()
|
||||
private selectedTab: IAppViewTab | null = null;
|
||||
|
||||
@state()
|
||||
private tabs: DeesAppuiTabs;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #161616;
|
||||
}
|
||||
|
||||
.view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
background: #000000;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
dees-appui-tabs {
|
||||
height: 60px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (!this.viewConfig) {
|
||||
return html`<div>No view configuration provided</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<dees-appui-tabs
|
||||
.tabs=${this.viewConfig.tabs}
|
||||
.selectedTab=${this.selectedTab}
|
||||
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
|
||||
></dees-appui-tabs>
|
||||
</div>
|
||||
<div class="view-content">
|
||||
${this.viewConfig.tabs.map((tab) => {
|
||||
const isActive = tab === this.selectedTab;
|
||||
const content = typeof tab.content === 'function' ? tab.content() : tab.content;
|
||||
return html`
|
||||
<div class="tab-content ${isActive ? 'active' : ''}">
|
||||
${content || html`<slot name="${tab.key}"></slot>`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
this.tabs = this.shadowRoot.querySelector('dees-appui-tabs');
|
||||
|
||||
if (this.viewConfig?.tabs?.length > 0) {
|
||||
this.selectedTab = this.viewConfig.tabs[0];
|
||||
}
|
||||
}
|
||||
|
||||
private handleTabSelect(e: CustomEvent) {
|
||||
this.selectedTab = e.detail.tab;
|
||||
|
||||
// Re-emit the event with view context
|
||||
this.dispatchEvent(new CustomEvent('view-tab-select', {
|
||||
detail: {
|
||||
view: this.viewConfig,
|
||||
tab: e.detail.tab
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Public methods for external control
|
||||
public selectTab(tabKey: string) {
|
||||
const tab = this.viewConfig.tabs.find(t => t.key === tabKey);
|
||||
if (tab) {
|
||||
this.selectedTab = tab;
|
||||
if (this.tabs) {
|
||||
this.tabs.selectedTab = tab;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getMenuItems(): interfaces.ISelectionOption[] {
|
||||
return this.viewConfig?.menuItems || [];
|
||||
}
|
||||
|
||||
public getTabs(): IAppViewTab[] {
|
||||
return this.viewConfig?.tabs || [];
|
||||
}
|
||||
}
|
||||
12
ts_web/elements/dees-badge.demo.ts
Normal file
12
ts_web/elements/dees-badge.demo.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<dees-badge .text=${'Default'}></dees-badge>
|
||||
<dees-badge .type=${'primary'} .text=${'Primary'}></dees-badge>
|
||||
<dees-badge .type=${'success'} .text=${'Success'}></dees-badge>
|
||||
<dees-badge .type=${'warning'} .text=${'Warning'}></dees-badge>
|
||||
<dees-badge .type=${'error'} .text=${'Error'}></dees-badge>
|
||||
<dees-badge .type=${'primary'} .rounded=${true} .text=${'Rounded'}></dees-badge>
|
||||
</div>
|
||||
`;
|
||||
96
ts_web/elements/dees-badge.ts
Normal file
96
ts_web/elements/dees-badge.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
type CSSResult,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-badge.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-badge': DeesBadge;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-badge')
|
||||
export class DeesBadge extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
public type: 'default' | 'primary' | 'success' | 'warning' | 'error' = 'default';
|
||||
|
||||
@property({ type: String })
|
||||
public text: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rounded: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
domtools.elementBasic.setup();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.rounded {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.badge.default {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#333')};
|
||||
color: ${cssManager.bdTheme('#666', '#ccc')};
|
||||
}
|
||||
|
||||
.badge.primary {
|
||||
background: #0050b9;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: #2e7d32;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
background: #ed6c02;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: #e4002b;
|
||||
color: #ffffff;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="badge ${this.type} ${this.rounded ? 'rounded' : ''}">
|
||||
${this.text}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
48
ts_web/elements/dees-button-exit.ts
Normal file
48
ts_web/elements/dees-button-exit.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
cssManager,
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
type TemplateResult,
|
||||
css,
|
||||
type CSSResult,
|
||||
state,
|
||||
property
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('dees-button-exit')
|
||||
export class DeesButtonExit extends DeesElement {
|
||||
// DEMO
|
||||
public static demo = () => html`
|
||||
<dees-button-exit></dees-button-exit>
|
||||
`;
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: Number
|
||||
})
|
||||
public size: number = 24;
|
||||
|
||||
public styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
|
||||
`
|
||||
]
|
||||
|
||||
public render (): TemplateResult {
|
||||
return html`
|
||||
<style>
|
||||
.maincontainer {
|
||||
position: relative;
|
||||
width: ${this.size}px;
|
||||
height: ${this.size}px;
|
||||
}
|
||||
</style>
|
||||
<div class="maincontainer">
|
||||
<div class="firstLine"></div>
|
||||
<div class="secondLine"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
114
ts_web/elements/dees-button-group.demo.ts
Normal file
114
ts_web/elements/dees-button-group.demo.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
background: #000000;
|
||||
padding: 40px;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
}
|
||||
|
||||
.demo-description {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Basic Button Groups</h2>
|
||||
<p class="demo-description">Button groups without labels for simple grouping</p>
|
||||
|
||||
<dees-button-group>
|
||||
<dees-button>Option 1</dees-button>
|
||||
<dees-button>Option 2</dees-button>
|
||||
<dees-button>Option 3</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Labeled Button Groups</h2>
|
||||
<p class="demo-description">Button groups with descriptive labels</p>
|
||||
|
||||
<dees-button-group label="View Mode:">
|
||||
<dees-button type="highlighted">Grid</dees-button>
|
||||
<dees-button>List</dees-button>
|
||||
<dees-button>Cards</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Multiple Groups</h2>
|
||||
<p class="demo-description">Multiple button groups used together</p>
|
||||
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<dees-button-group label="Dataset:">
|
||||
<dees-button type="highlighted">System</dees-button>
|
||||
<dees-button>Network</dees-button>
|
||||
<dees-button>Sales</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Time Range:">
|
||||
<dees-button>1H</dees-button>
|
||||
<dees-button type="highlighted">24H</dees-button>
|
||||
<dees-button>7D</dees-button>
|
||||
<dees-button>30D</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Refresh</dees-button>
|
||||
<dees-button>Export</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Vertical Button Groups</h2>
|
||||
<p class="demo-description">Button groups with vertical layout</p>
|
||||
|
||||
<div style="display: flex; gap: 24px;">
|
||||
<dees-button-group direction="vertical" label="Navigation:">
|
||||
<dees-button>Dashboard</dees-button>
|
||||
<dees-button type="highlighted">Analytics</dees-button>
|
||||
<dees-button>Reports</dees-button>
|
||||
<dees-button>Settings</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group direction="vertical">
|
||||
<dees-button>Add Item</dees-button>
|
||||
<dees-button>Edit Item</dees-button>
|
||||
<dees-button>Delete Item</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Mixed Button Types</h2>
|
||||
<p class="demo-description">Different button types within groups</p>
|
||||
|
||||
<dees-button-group label="Status:">
|
||||
<dees-button type="success">Active</dees-button>
|
||||
<dees-button>Pending</dees-button>
|
||||
<dees-button type="danger">Inactive</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
83
ts_web/elements/dees-button-group.ts
Normal file
83
ts_web/elements/dees-button-group.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-button-group.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-button-group': DeesButtonGroup;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-button-group')
|
||||
export class DeesButtonGroup extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property()
|
||||
public label: string = '';
|
||||
|
||||
@property()
|
||||
public direction: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
domtools.elementBasic.setup();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.button-group.vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button-group.vertical .label {
|
||||
margin-right: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
::slotted(*) {
|
||||
margin: 0 !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="button-group ${this.direction}">
|
||||
${this.label ? html`<span class="label">${this.label}</span>` : ''}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
421
ts_web/elements/dees-button.demo.ts
Normal file
421
ts_web/elements/dees-button.demo.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { html, css, cssManager, domtools } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './dees-panel.js';
|
||||
import './dees-form.js';
|
||||
import './dees-form-submit.js';
|
||||
import './dees-input-text.js';
|
||||
import './dees-icon.js';
|
||||
import type { DeesButton } from './dees-button.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vertical-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
|
||||
.icon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.code-snippet {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
display: inline-block;
|
||||
margin: 4px 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Log button clicks for demo purposes
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const type = button.getAttribute('type') || 'default';
|
||||
console.log(`Button variant clicked: ${type}`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'1. Button Variants'} .subtitle=${'Different visual styles for various use cases'}>
|
||||
<div class="button-group">
|
||||
<dees-button type="default">Default</dees-button>
|
||||
<dees-button type="secondary">Secondary</dees-button>
|
||||
<dees-button type="destructive">Destructive</dees-button>
|
||||
<dees-button type="outline">Outline</dees-button>
|
||||
<dees-button type="ghost">Ghost</dees-button>
|
||||
<dees-button type="link">Link Button</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate size differences programmatically
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const size = button.getAttribute('size') || 'default';
|
||||
console.log(`Button size: ${size}`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'2. Button Sizes'} .subtitle=${'Multiple sizes for different contexts and use cases'}>
|
||||
<div class="button-group">
|
||||
<dees-button size="sm">Small Button</dees-button>
|
||||
<dees-button size="default">Default Size</dees-button>
|
||||
<dees-button size="lg">Large Button</dees-button>
|
||||
<dees-button size="icon" type="outline" .text=${'🚀'}></dees-button>
|
||||
</div>
|
||||
|
||||
<div class="button-group" style="margin-top: 16px;">
|
||||
<dees-button size="sm" type="secondary">Small Secondary</dees-button>
|
||||
<dees-button size="default" type="destructive">Default Destructive</dees-button>
|
||||
<dees-button size="lg" type="outline">Large Outline</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Track icon button clicks
|
||||
const iconButtons = elementArg.querySelectorAll('dees-button');
|
||||
iconButtons.forEach((button) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const hasIcon = button.querySelector('dees-icon');
|
||||
if (hasIcon) {
|
||||
const iconName = hasIcon.getAttribute('iconFA') || 'unknown';
|
||||
console.log(`Icon button clicked: ${iconName}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'3. Buttons with Icons'} .subtitle=${'Combining icons with text for enhanced visual communication'}>
|
||||
<div class="icon-row">
|
||||
<dees-button>
|
||||
<dees-icon iconFA="faPlus"></dees-icon>
|
||||
Add Item
|
||||
</dees-button>
|
||||
<dees-button type="destructive">
|
||||
<dees-icon iconFA="faTrash"></dees-icon>
|
||||
Delete
|
||||
</dees-button>
|
||||
<dees-button type="outline">
|
||||
<dees-icon iconFA="faDownload"></dees-icon>
|
||||
Download
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="icon-row">
|
||||
<dees-button type="secondary" size="sm">
|
||||
<dees-icon iconFA="faCog"></dees-icon>
|
||||
Settings
|
||||
</dees-button>
|
||||
<dees-button type="ghost">
|
||||
<dees-icon iconFA="faChevronLeft"></dees-icon>
|
||||
Back
|
||||
</dees-button>
|
||||
<dees-button type="ghost">
|
||||
Next
|
||||
<dees-icon iconFA="faChevronRight"></dees-icon>
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="icon-row">
|
||||
<dees-button size="icon" type="default">
|
||||
<dees-icon iconFA="faPlus"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="secondary">
|
||||
<dees-icon iconFA="faCog"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="outline">
|
||||
<dees-icon iconFA="faSearch"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="ghost">
|
||||
<dees-icon iconFA="faEllipsisV"></dees-icon>
|
||||
</dees-button>
|
||||
<dees-button size="icon" type="destructive">
|
||||
<dees-icon iconFA="faTrash"></dees-icon>
|
||||
</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate status changes
|
||||
const pendingButton = elementArg.querySelector('dees-button[status="pending"]');
|
||||
const successButton = elementArg.querySelector('dees-button[status="success"]');
|
||||
const errorButton = elementArg.querySelector('dees-button[status="error"]');
|
||||
|
||||
// Simulate status changes
|
||||
if (pendingButton) {
|
||||
setTimeout(() => {
|
||||
console.log('Pending button is showing loading state');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (successButton) {
|
||||
successButton.addEventListener('clicked', () => {
|
||||
console.log('Success state button clicked');
|
||||
});
|
||||
}
|
||||
|
||||
if (errorButton) {
|
||||
errorButton.addEventListener('clicked', () => {
|
||||
console.log('Error state button clicked');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'4. Button States'} .subtitle=${'Different states to indicate button status and loading conditions'}>
|
||||
<div class="button-group">
|
||||
<dees-button status="normal">Normal</dees-button>
|
||||
<dees-button status="pending">Processing...</dees-button>
|
||||
<dees-button status="success">Success!</dees-button>
|
||||
<dees-button status="error">Error!</dees-button>
|
||||
<dees-button disabled>Disabled</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="button-group" style="margin-top: 16px;">
|
||||
<dees-button type="secondary" status="pending" size="sm">Small Loading</dees-button>
|
||||
<dees-button type="outline" status="pending">Default Loading</dees-button>
|
||||
<dees-button type="destructive" status="pending" size="lg">Large Loading</dees-button>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Set up click handlers with the output element
|
||||
const output = elementArg.querySelector('#click-output');
|
||||
|
||||
const clickMeBtn = elementArg.querySelector('dees-button:first-of-type');
|
||||
const dataBtn = elementArg.querySelector('dees-button[type="secondary"]');
|
||||
const asyncBtn = elementArg.querySelector('dees-button[type="destructive"]');
|
||||
|
||||
if (clickMeBtn && output) {
|
||||
clickMeBtn.addEventListener('clicked', () => {
|
||||
output.textContent = `Clicked: Default button at ${new Date().toLocaleTimeString()}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (dataBtn && output) {
|
||||
dataBtn.addEventListener('clicked', (e: CustomEvent) => {
|
||||
output.textContent = `Clicked: Secondary button with data: ${e.detail.data}`;
|
||||
});
|
||||
}
|
||||
|
||||
if (asyncBtn && output) {
|
||||
asyncBtn.addEventListener('clicked', async () => {
|
||||
output.textContent = 'Processing...';
|
||||
await domtools.plugins.smartdelay.delayFor(2000);
|
||||
output.textContent = 'Action completed!';
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'5. Event Handling'} .subtitle=${'Interactive examples with click event handling'}>
|
||||
<div class="button-group">
|
||||
<dees-button>Click Me</dees-button>
|
||||
<dees-button type="secondary" .eventDetailData=${'custom-data-123'}>
|
||||
Click with Data
|
||||
</dees-button>
|
||||
<dees-button type="destructive">Async Action</dees-button>
|
||||
</div>
|
||||
|
||||
<div id="click-output" class="demo-output">
|
||||
<em>Click a button to see the result...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Set up form submission handling
|
||||
const form = elementArg.querySelector('dees-form');
|
||||
const output = elementArg.querySelector('#form-output');
|
||||
|
||||
if (form && output) {
|
||||
form.addEventListener('formData', (e: CustomEvent) => {
|
||||
output.innerHTML = '<strong>Form submitted with data:</strong><br>' +
|
||||
JSON.stringify(e.detail.data, null, 2);
|
||||
});
|
||||
}
|
||||
|
||||
// Track non-submit button clicks
|
||||
const draftBtn = elementArg.querySelector('dees-button[type="secondary"]');
|
||||
const cancelBtn = elementArg.querySelector('dees-button[type="ghost"]');
|
||||
|
||||
if (draftBtn) {
|
||||
draftBtn.addEventListener('clicked', () => {
|
||||
console.log('Save Draft clicked');
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('clicked', () => {
|
||||
console.log('Cancel clicked');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Buttons working within forms with automatic spacing'}>
|
||||
<dees-form>
|
||||
<dees-input-text label="Name" key="name" required></dees-input-text>
|
||||
<dees-input-text label="Email" key="email" type="email" required></dees-input-text>
|
||||
<dees-input-text label="Message" key="message" isMultiline></dees-input-text>
|
||||
|
||||
<dees-button type="secondary">Save Draft</dees-button>
|
||||
<dees-button type="ghost">Cancel</dees-button>
|
||||
<dees-form-submit>Submit Form</dees-form-submit>
|
||||
</dees-form>
|
||||
|
||||
<div id="form-output" class="demo-output" style="white-space: pre-wrap;">
|
||||
<em>Submit the form to see the data...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Log legacy type mappings
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach((button) => {
|
||||
const type = button.getAttribute('type');
|
||||
if (type) {
|
||||
console.log(`Legacy type "${type}" is supported for backward compatibility`);
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'7. Backward Compatibility'} .subtitle=${'Old button types are automatically mapped to new variants'}>
|
||||
<div class="button-group">
|
||||
<dees-button type="normal">Normal → Default</dees-button>
|
||||
<dees-button type="highlighted">Highlighted → Destructive</dees-button>
|
||||
<dees-button type="discreet">Discreet → Outline</dees-button>
|
||||
<dees-button type="big">Big → Large Size</dees-button>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 16px; font-size: 14px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};">
|
||||
These legacy type values are maintained for backward compatibility but we recommend using the new variant system.
|
||||
</p>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Track action group clicks
|
||||
const actionGroup = elementArg.querySelectorAll('.vertical-group')[0];
|
||||
const dangerGroup = elementArg.querySelectorAll('.vertical-group')[1];
|
||||
|
||||
if (actionGroup) {
|
||||
const buttons = actionGroup.querySelectorAll('dees-button');
|
||||
buttons.forEach((button, index) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const action = ['Save Changes', 'Discard', 'Help'][index];
|
||||
console.log(`Action group: ${action} clicked`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (dangerGroup) {
|
||||
const buttons = dangerGroup.querySelectorAll('dees-button');
|
||||
buttons.forEach((button, index) => {
|
||||
button.addEventListener('clicked', () => {
|
||||
const action = ['Delete Account', 'Archive Data', 'Not Available'][index];
|
||||
if (index !== 2) { // Skip disabled button
|
||||
console.log(`Danger zone: ${action} clicked`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'8. Advanced Examples'} .subtitle=${'Complex button configurations and real-world use cases'}>
|
||||
<div class="horizontal-group">
|
||||
<div class="vertical-group">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Action Group</h4>
|
||||
<dees-button type="default" size="sm">
|
||||
<dees-icon iconFA="faSave"></dees-icon>
|
||||
Save Changes
|
||||
</dees-button>
|
||||
<dees-button type="secondary" size="sm">
|
||||
<dees-icon iconFA="faUndo"></dees-icon>
|
||||
Discard
|
||||
</dees-button>
|
||||
<dees-button type="ghost" size="sm">
|
||||
<dees-icon iconFA="faQuestionCircle"></dees-icon>
|
||||
Help
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="vertical-group">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Danger Zone</h4>
|
||||
<dees-button type="destructive" size="sm">
|
||||
<dees-icon iconFA="faTrash"></dees-icon>
|
||||
Delete Account
|
||||
</dees-button>
|
||||
<dees-button type="outline" size="sm">
|
||||
<dees-icon iconFA="faArchive"></dees-icon>
|
||||
Archive Data
|
||||
</dees-button>
|
||||
<dees-button type="ghost" size="sm" disabled>
|
||||
<dees-icon iconFA="faBan"></dees-icon>
|
||||
Not Available
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<h4 style="margin: 0 0 8px 0; font-size: 14px; font-weight: 500;">Code Example:</h4>
|
||||
<div class="code-snippet">
|
||||
<dees-button type="default" size="sm" @clicked="\${handleClick}"><br>
|
||||
<dees-icon iconFA="faSave"></dees-icon><br>
|
||||
Save Changes<br>
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
||||
@@ -3,13 +3,15 @@ import {
|
||||
html,
|
||||
DeesElement,
|
||||
property,
|
||||
TemplateResult,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
type CSSResult,
|
||||
unsafeCSS,
|
||||
} from '@designestate/dees-element';
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@designestate/dees-domtools';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-button.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -19,26 +21,22 @@ declare global {
|
||||
|
||||
@customElement('dees-button')
|
||||
export class DeesButton extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-button>This is a slotted Text</dees-button>
|
||||
<p><dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button></p>
|
||||
<p><dees-button type="discreet">This is discreete button</dees-button></p>
|
||||
<p><dees-button disabled>This is a disabled button</dees-button></p>
|
||||
<p><dees-button type="big">This is a slotted Text</dees-button></p>
|
||||
<p><dees-button status="normal">Normal Status</dees-button></p>
|
||||
<p><dees-button disabled status="pending">Pending Status</dees-button></p>
|
||||
<p><dees-button disabled status="success">Success Status</dees-button></p>
|
||||
<p><dees-button disabled status="error">Error Status</dees-button></p>
|
||||
`;
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property()
|
||||
@property({
|
||||
reflect: true,
|
||||
hasChanged() {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
public text: string;
|
||||
|
||||
@property()
|
||||
public eventDetailData: string;
|
||||
|
||||
@property({
|
||||
type: Boolean
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
})
|
||||
public disabled = false;
|
||||
|
||||
@@ -50,139 +48,324 @@ export class DeesButton extends DeesElement {
|
||||
@property({
|
||||
type: String
|
||||
})
|
||||
public type: 'normal' | 'highlighted' | 'discreet' | 'big' = 'normal';
|
||||
public type: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'normal' | 'highlighted' | 'discreet' | 'big' = 'default';
|
||||
|
||||
@property({
|
||||
type: String
|
||||
})
|
||||
public size: 'default' | 'sm' | 'lg' | 'icon' = 'default';
|
||||
|
||||
@property({
|
||||
type: String
|
||||
})
|
||||
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true
|
||||
})
|
||||
public insideForm: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Auto-detect if inside a form
|
||||
if (!this.insideForm && this.closest('dees-form')) {
|
||||
this.insideForm = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form spacing styles */
|
||||
:host([inside-form]) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:host([inside-form]:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dees-form[horizontal-layout] :host([inside-form]) {
|
||||
display: inline-block;
|
||||
margin-right: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dees-form[horizontal-layout] :host([inside-form]:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: ${cssManager.bdTheme('#eee', '#333')};
|
||||
box-shadow: ${cssManager.bdTheme('0px 0px 5px rgba(0,0,0,0.1)', 'none')};
|
||||
border: 1px solid ${cssManager.bdTheme('#eee', '#333')};
|
||||
border-top: ${cssManager.bdTheme('1px solid #eee', '1px solid #444')};
|
||||
border-radius: 2px;
|
||||
line-height: 40px;
|
||||
padding: 0px 10px;
|
||||
min-width: 100px;
|
||||
user-select: none;
|
||||
color: ${cssManager.bdTheme('#333', ' #ccc')};
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
background: #039be5;
|
||||
border: 1px solid #039be5;
|
||||
border-top: 1px solid #039be5;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
letter-spacing: -0.01em;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
background: #0277bd;
|
||||
border-top: 1px solid #0277bd;
|
||||
/* Size variants */
|
||||
.button.size-default {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button.size-sm {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button.size-lg {
|
||||
height: 44px;
|
||||
padding: 0 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.button.size-icon {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Default variant */
|
||||
.button.default {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20.2% 11.8%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 16.8%)')};
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.button.default:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.2%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 20%)')};
|
||||
}
|
||||
|
||||
.button.default:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 9%)')};
|
||||
}
|
||||
|
||||
/* Destructive variant */
|
||||
.button.destructive {
|
||||
background: hsl(0 84.2% 60.2%);
|
||||
color: hsl(0 0% 98%);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.destructive:hover:not(.disabled) {
|
||||
background: hsl(0 84.2% 56.2%);
|
||||
}
|
||||
|
||||
.button.destructive:active:not(.disabled) {
|
||||
background: hsl(0 84.2% 52.2%);
|
||||
}
|
||||
|
||||
/* Outline variant */
|
||||
.button.outline {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')};
|
||||
}
|
||||
|
||||
.button.outline:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 26.8%)')};
|
||||
}
|
||||
|
||||
.button.outline:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
|
||||
}
|
||||
|
||||
/* Secondary variant */
|
||||
.button.secondary {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.secondary:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
|
||||
}
|
||||
|
||||
.button.secondary:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 85%)', 'hsl(215 20.2% 11.8%)')};
|
||||
}
|
||||
|
||||
/* Ghost variant */
|
||||
.button.ghost {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.ghost:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
}
|
||||
|
||||
.button.ghost:active:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 13.8%)')};
|
||||
}
|
||||
|
||||
/* Link variant */
|
||||
.button.link {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.button.link:hover:not(.disabled) {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
/* Status states */
|
||||
.button.pending,
|
||||
.button.success,
|
||||
.button.error {
|
||||
pointer-events: none;
|
||||
padding-left: 36px; /* Space for spinner */
|
||||
}
|
||||
|
||||
.button.size-sm.pending,
|
||||
.button.size-sm.success,
|
||||
.button.size-sm.error {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.button.size-lg.pending,
|
||||
.button.size-lg.success,
|
||||
.button.size-lg.error {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.button.pending {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.success {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button.error {
|
||||
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 62.8% 70.6% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 62.8% 70.6%)')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.button.disabled {
|
||||
background: ${cssManager.bdTheme('#ffffff00', '#11111100')};
|
||||
border: 1px dashed ${cssManager.bdTheme('#666666', '#666666')};
|
||||
color: #9b9b9e;
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.button.highlighted {
|
||||
background: #e4002b;
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.highlighted:hover {
|
||||
background: #b50021;
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.discreet {
|
||||
background: none;
|
||||
border: 1px solid #9b9b9e;
|
||||
color: ${cssManager.bdTheme('#000', '#fff')};
|
||||
}
|
||||
|
||||
.button.discreet:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
}
|
||||
/* Hidden state */
|
||||
.button.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button.big {
|
||||
width: 300px;
|
||||
line-height: 48px;
|
||||
font-size: 16px;
|
||||
padding: 0px 48px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.button.pending {
|
||||
border: 1px dashed ${cssManager.bdTheme('#0277bd', '#0277bd70')};
|
||||
background: ${cssManager.bdTheme('#0277bd', '#0277bd70')};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.success {
|
||||
border: 1px dashed ${cssManager.bdTheme('#689F38', '#8BC34A70')};
|
||||
background: ${cssManager.bdTheme('#689F38', '#8BC34A70')};
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button.error {
|
||||
border: 1px dashed ${cssManager.bdTheme('#B71C1C', '#E64A1970')};
|
||||
background: ${cssManager.bdTheme('#B71C1C', '#E64A1970')};
|
||||
color: #fff;
|
||||
/* Focus state */
|
||||
.button:focus-visible {
|
||||
outline: 2px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
dees-spinner {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.button.size-sm dees-spinner {
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.button.size-lg dees-spinner {
|
||||
left: 14px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Icon sizing within buttons */
|
||||
.button dees-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button.size-sm dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.button.size-lg dees-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
// Map old types to new types for backward compatibility
|
||||
const typeMap: {[key: string]: string} = {
|
||||
'normal': 'default',
|
||||
'highlighted': 'destructive',
|
||||
'discreet': 'outline',
|
||||
'big': 'default' // Will use size instead
|
||||
};
|
||||
|
||||
const actualType = typeMap[this.type] || this.type;
|
||||
const actualSize = this.type === 'big' ? 'lg' : this.size;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="button ${this.isHidden ? 'hidden' : 'block'} ${this.type} ${this.status} ${this.disabled
|
||||
class="button ${this.isHidden ? 'hidden' : ''} ${actualType} size-${actualSize} ${this.status} ${this.disabled
|
||||
? 'disabled'
|
||||
: null}"
|
||||
: ''}"
|
||||
@click="${this.dispatchClick}"
|
||||
>
|
||||
${this.status === 'normal' ? html``: html`
|
||||
<dees-spinner status="${this.status}"></dees-spinner>
|
||||
<dees-spinner
|
||||
.bnw=${true}
|
||||
status="${this.status}"
|
||||
size="${actualSize === 'sm' ? 14 : actualSize === 'lg' ? 18 : 16}"
|
||||
></dees-spinner>
|
||||
`}
|
||||
<div class="textbox">${this.text ? this.text : html`<slot></slot>`}</div>
|
||||
<div class="textbox">${this.text || html`<slot>Button</slot>`}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -203,9 +386,6 @@ export class DeesButton extends DeesElement {
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
if (!this.textContent) {
|
||||
this.textContent = 'Button';
|
||||
this.performUpdate();
|
||||
}
|
||||
// Don't set default text here as it interferes with slotted content
|
||||
}
|
||||
}
|
||||
|
||||
667
ts_web/elements/dees-chart-area/component.ts
Normal file
667
ts_web/elements/dees-chart-area/component.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { chartAreaStyles } from './styles.js';
|
||||
import { renderChartArea } from './template.js';
|
||||
|
||||
import ApexCharts from 'apexcharts';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-chart-area': DeesChartArea;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-chart-area')
|
||||
export class DeesChartArea extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// instance
|
||||
@state()
|
||||
public chart: ApexCharts;
|
||||
|
||||
@property()
|
||||
public label: string = 'Untitled Chart';
|
||||
|
||||
@property({ type: Array })
|
||||
public series: ApexAxisChartSeries = [];
|
||||
|
||||
// Override getter to return internal chart data
|
||||
get chartSeries(): ApexAxisChartSeries {
|
||||
return this.internalChartData.length > 0 ? this.internalChartData : this.series;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
public yAxisFormatter: (value: number) => string = (val) => `${val} Mbps`;
|
||||
|
||||
@property({ type: Number })
|
||||
public rollingWindow: number = 0; // 0 means no rolling window
|
||||
|
||||
@property({ type: Boolean })
|
||||
public realtimeMode: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public yAxisScaling: 'fixed' | 'dynamic' | 'percentage' = 'dynamic';
|
||||
|
||||
@property({ type: Number })
|
||||
public yAxisMax: number = 100; // Used when yAxisScaling is 'fixed' or 'percentage'
|
||||
|
||||
@property({ type: Number })
|
||||
public autoScrollInterval: number = 1000; // Auto-scroll interval in milliseconds (0 to disable)
|
||||
|
||||
private resizeObserver: ResizeObserver;
|
||||
private resizeTimeout: number;
|
||||
private internalChartData: ApexAxisChartSeries = [];
|
||||
private autoScrollTimer: number | null = null;
|
||||
private readonly DEBUG_RESIZE = false; // Set to true to enable resize debugging
|
||||
|
||||
// Chart color schemes
|
||||
private readonly CHART_COLORS = {
|
||||
dark: [
|
||||
'hsl(217.2 91.2% 59.8%)', // Blue
|
||||
'hsl(173.4 80.4% 40%)', // Teal
|
||||
'hsl(280.3 87.4% 66.7%)', // Purple
|
||||
'hsl(24.6 95% 53.1%)', // Orange
|
||||
],
|
||||
light: [
|
||||
'hsl(222.2 47.4% 51.2%)', // Blue (shadcn primary)
|
||||
'hsl(142.1 76.2% 36.3%)', // Green (shadcn success)
|
||||
'hsl(280.3 47.7% 50.2%)', // Purple (muted)
|
||||
'hsl(20.5 90.2% 48.2%)', // Orange (shadcn destructive variant)
|
||||
]
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
domtools.elementBasic.setup();
|
||||
|
||||
this.resizeObserver = new ResizeObserver((entries) => {
|
||||
// Debounce resize calls to prevent excessive updates
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
|
||||
this.resizeTimeout = window.setTimeout(() => {
|
||||
// Simply resize if we have a chart, since we're only observing the mainbox
|
||||
if (this.chart) {
|
||||
// Log resize event for debugging
|
||||
if (this.DEBUG_RESIZE && entries.length > 0) {
|
||||
const entry = entries[0];
|
||||
console.log('DeesChartArea - Resize detected:', {
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height
|
||||
});
|
||||
}
|
||||
this.resizeChart();
|
||||
}
|
||||
}, 100); // 100ms debounce
|
||||
});
|
||||
|
||||
// Note: ResizeObserver is now set up after chart initialization in firstUpdated()
|
||||
// to ensure proper timing and avoid race conditions
|
||||
|
||||
this.registerGarbageFunction(async () => {
|
||||
if (this.resizeTimeout) {
|
||||
clearTimeout(this.resizeTimeout);
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
this.stopAutoScroll();
|
||||
|
||||
// Critical: Destroy chart instance to prevent memory leak
|
||||
if (this.chart) {
|
||||
try {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
} catch (error) {
|
||||
console.error('Error destroying chart:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Trigger resize when element is connected to DOM
|
||||
// This helps with dynamically added charts
|
||||
if (this.chart) {
|
||||
// Wait a frame for layout to settle
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
await this.resizeChart();
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = chartAreaStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
return renderChartArea(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async firstUpdated() {
|
||||
await this.domtoolsPromise;
|
||||
|
||||
// Wait for next animation frame to ensure layout is complete
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Get actual dimensions of the container
|
||||
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
|
||||
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
|
||||
|
||||
if (!mainbox || !chartContainer) {
|
||||
console.error('Chart containers not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate initial dimensions
|
||||
const styleChartContainer = window.getComputedStyle(chartContainer);
|
||||
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
|
||||
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
|
||||
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
|
||||
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
|
||||
|
||||
const initialWidth = mainbox.clientWidth - paddingLeft - paddingRight;
|
||||
const initialHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
|
||||
|
||||
// Use provided series data or default demo data
|
||||
const chartSeries = this.series.length > 0 ? this.series : [
|
||||
{
|
||||
name: 'cpu',
|
||||
data: [
|
||||
{ x: '2025-01-15T03:00:00', y: 25 },
|
||||
{ x: '2025-01-15T07:00:00', y: 30 },
|
||||
{ x: '2025-01-15T11:00:00', y: 20 },
|
||||
{ x: '2025-01-15T15:00:00', y: 35 },
|
||||
{ x: '2025-01-15T19:00:00', y: 25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
data: [
|
||||
{ x: '2025-01-15T03:00:00', y: 10 },
|
||||
{ x: '2025-01-15T07:00:00', y: 12 },
|
||||
{ x: '2025-01-15T11:00:00', y: 10 },
|
||||
{ x: '2025-01-15T15:00:00', y: 30 },
|
||||
{ x: '2025-01-15T19:00:00', y: 40 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Store internal data
|
||||
this.internalChartData = chartSeries;
|
||||
|
||||
// Get current theme
|
||||
const isDark = !this.goBright;
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
|
||||
var options: ApexCharts.ApexOptions = {
|
||||
series: chartSeries,
|
||||
chart: {
|
||||
width: initialWidth || 100, // Use actual width or fallback
|
||||
height: initialHeight || 100, // Use actual height or fallback
|
||||
type: 'area',
|
||||
background: 'transparent', // Transparent background to inherit from container
|
||||
toolbar: {
|
||||
show: false, // This line disables the toolbar
|
||||
},
|
||||
animations: {
|
||||
enabled: !this.realtimeMode, // Disable animations in realtime mode
|
||||
speed: 400,
|
||||
animateGradually: {
|
||||
enabled: false, // Disable gradual animation for cleaner updates
|
||||
delay: 0
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 350
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
enabled: false, // Disable zoom for cleaner interaction
|
||||
},
|
||||
selection: {
|
||||
enabled: false, // Disable selection
|
||||
},
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
stroke: {
|
||||
width: 2,
|
||||
curve: 'smooth',
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime', // Time-series data
|
||||
labels: {
|
||||
format: 'HH:mm:ss', // Time formatting with seconds
|
||||
datetimeUTC: false,
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color
|
||||
fontSize: '12px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false, // Hide x-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide x-axis ticks
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax,
|
||||
labels: {
|
||||
formatter: this.yAxisFormatter,
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'], // Label color
|
||||
fontSize: '12px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
axisBorder: {
|
||||
show: false, // Hide y-axis border
|
||||
},
|
||||
axisTicks: {
|
||||
show: false, // Hide y-axis ticks
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
shared: true, // Enables the tooltip to display across series
|
||||
intersect: false, // Allows hovering anywhere on the chart
|
||||
followCursor: true, // Makes tooltip follow mouse even between points
|
||||
x: {
|
||||
format: 'dd/MM/yy HH:mm',
|
||||
},
|
||||
custom: ({ series, dataPointIndex, w }: any) => {
|
||||
// Iterate through each series and get its value
|
||||
// Note: We can't access component instance here, so we'll use w.config.theme.mode
|
||||
const currentTheme = w.config.theme.mode;
|
||||
const isDarkMode = currentTheme === 'dark';
|
||||
const bgColor = isDarkMode ? 'hsl(0 0% 9%)' : 'hsl(0 0% 100%)';
|
||||
const textColor = isDarkMode ? 'hsl(0 0% 95%)' : 'hsl(0 0% 9%)';
|
||||
const borderColor = isDarkMode ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 89.8%)';
|
||||
|
||||
// Get formatter from chart config
|
||||
const formatter = w.config.yaxis[0]?.labels?.formatter || ((val: number) => val.toString());
|
||||
|
||||
let tooltipContent = `<div style="padding: 12px; background: ${bgColor}; color: ${textColor}; border-radius: 6px; box-shadow: 0 2px 8px 0 hsl(0 0% 0% / ${isDarkMode ? '0.2' : '0.1'}); border: 1px solid ${borderColor};font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 12px;">`;
|
||||
|
||||
series.forEach((s: number[], index: number) => {
|
||||
const label = w.globals.seriesNames[index]; // Get series label
|
||||
const value = s[dataPointIndex]; // Get value at data point
|
||||
const color = w.globals.colors[index];
|
||||
const formattedValue = formatter(value);
|
||||
tooltipContent += `<div style="display: flex; align-items: center; gap: 8px; margin: ${index > 0 ? '6px' : '0'} 0;">
|
||||
<span style="display: inline-block; width: 10px; height: 10px; background: ${color}; border-radius: 2px;"></span>
|
||||
<span style="font-weight: 500;">${label}:</span>
|
||||
<span style="margin-left: auto; font-weight: 600;">${formattedValue}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
tooltipContent += `</div>`;
|
||||
return tooltipContent;
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: false, // Hide vertical grid lines for cleaner look
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)', // Very subtle grid lines
|
||||
strokeDashArray: 0, // Solid line
|
||||
padding: {
|
||||
top: 10,
|
||||
right: 20,
|
||||
bottom: 10,
|
||||
left: 20,
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient', // Gradient fill for the area
|
||||
gradient: {
|
||||
shade: isDark ? 'dark' : 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0.1,
|
||||
opacityFrom: isDark ? 0.2 : 0.3,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light,
|
||||
theme: {
|
||||
mode: theme,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.chart = new ApexCharts(this.shadowRoot.querySelector('.chartContainer'), options);
|
||||
await this.chart.render();
|
||||
|
||||
// Give the chart a moment to fully initialize before resizing
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await this.resizeChart();
|
||||
|
||||
// Ensure resize observer is watching the mainbox
|
||||
const mainbox = this.shadowRoot.querySelector('.mainbox');
|
||||
if (mainbox && this.resizeObserver) {
|
||||
// Disconnect any previous observations
|
||||
this.resizeObserver.disconnect();
|
||||
// Start observing the mainbox
|
||||
this.resizeObserver.observe(mainbox);
|
||||
if (this.DEBUG_RESIZE) {
|
||||
console.log('DeesChartArea - ResizeObserver attached to mainbox');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize chart:', error);
|
||||
// Optionally, you could set an error state here
|
||||
// this.chartState = 'error';
|
||||
// this.errorMessage = 'Failed to initialize chart';
|
||||
}
|
||||
}
|
||||
|
||||
public async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// Update chart theme when goBright changes
|
||||
if (changedProperties.has('goBright') && this.chart) {
|
||||
await this.updateChartTheme();
|
||||
}
|
||||
|
||||
// Update chart if series data changes
|
||||
if (changedProperties.has('series') && this.chart && this.series.length > 0) {
|
||||
await this.updateSeries(this.series);
|
||||
}
|
||||
|
||||
// Update y-axis formatter if it changes
|
||||
if (changedProperties.has('yAxisFormatter') && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: this.yAxisFormatter,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle realtime mode changes
|
||||
if (changedProperties.has('realtimeMode') && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
chart: {
|
||||
animations: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 400,
|
||||
animateGradually: {
|
||||
enabled: false,
|
||||
delay: 0
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: !this.realtimeMode,
|
||||
speed: 350
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start/stop auto-scroll based on realtime mode
|
||||
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
||||
this.startAutoScroll();
|
||||
} else {
|
||||
this.stopAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle auto-scroll interval changes
|
||||
if (changedProperties.has('autoScrollInterval') && this.chart) {
|
||||
this.stopAutoScroll();
|
||||
if (this.realtimeMode && this.rollingWindow > 0 && this.autoScrollInterval > 0) {
|
||||
this.startAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle y-axis scaling changes
|
||||
if ((changedProperties.has('yAxisScaling') || changedProperties.has('yAxisMax')) && this.chart) {
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: this.yAxisScaling === 'dynamic' ? undefined : this.yAxisMax
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSeries(newSeries: ApexAxisChartSeries, animate: boolean = true) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store the new data first
|
||||
this.internalChartData = newSeries;
|
||||
|
||||
// Handle rolling window if enabled
|
||||
if (this.rollingWindow > 0 && this.realtimeMode) {
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - this.rollingWindow;
|
||||
|
||||
// Filter data to only include points within the rolling window
|
||||
const filteredSeries = newSeries.map(series => ({
|
||||
name: series.name,
|
||||
data: (series.data as any[]).filter(point => {
|
||||
if (typeof point === 'object' && point !== null && 'x' in point) {
|
||||
return new Date(point.x).getTime() > cutoffTime;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}));
|
||||
|
||||
// Only update if we have data
|
||||
if (filteredSeries.some(s => s.data.length > 0)) {
|
||||
// Handle y-axis scaling first
|
||||
if (this.yAxisScaling === 'dynamic') {
|
||||
const allValues = filteredSeries.flatMap(s => (s.data as any[]).map(d => d.y));
|
||||
if (allValues.length > 0) {
|
||||
const maxValue = Math.max(...allValues);
|
||||
const dynamicMax = Math.ceil(maxValue * 1.1);
|
||||
await this.chart.updateOptions({
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: dynamicMax
|
||||
}
|
||||
}, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
await this.chart.updateSeries(filteredSeries, false);
|
||||
}
|
||||
} else {
|
||||
await this.chart.updateSeries(newSeries, animate);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update chart series:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update just the x-axis for smooth scrolling in realtime mode
|
||||
// Public for advanced usage in demos, but typically handled automatically
|
||||
public async updateTimeWindow() {
|
||||
if (!this.chart || this.rollingWindow <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cutoffTime = now - this.rollingWindow;
|
||||
|
||||
await this.chart.updateOptions({
|
||||
xaxis: {
|
||||
min: cutoffTime,
|
||||
max: now,
|
||||
labels: {
|
||||
format: 'HH:mm:ss',
|
||||
datetimeUTC: false,
|
||||
style: {
|
||||
colors: [!this.goBright ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
|
||||
fontSize: '12px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
fontWeight: '400',
|
||||
},
|
||||
},
|
||||
tickAmount: 6,
|
||||
}
|
||||
}, false, false);
|
||||
}
|
||||
|
||||
public async appendData(newData: { data: any[] }[]) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ApexCharts' appendData method for smoother real-time updates
|
||||
this.chart.appendData(newData);
|
||||
}
|
||||
|
||||
public async updateOptions(options: ApexCharts.ApexOptions, redrawPaths?: boolean, animate?: boolean) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.chart.updateOptions(options, redrawPaths, animate);
|
||||
}
|
||||
|
||||
public async resizeChart() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.DEBUG_RESIZE) {
|
||||
console.log('DeesChartArea - resizeChart called');
|
||||
}
|
||||
|
||||
try {
|
||||
const mainbox: HTMLDivElement = this.shadowRoot.querySelector('.mainbox');
|
||||
const chartContainer: HTMLDivElement = this.shadowRoot.querySelector('.chartContainer');
|
||||
|
||||
if (!mainbox || !chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force layout recalculation
|
||||
void mainbox.offsetHeight;
|
||||
|
||||
// Get computed style of the element
|
||||
const styleChartContainer = window.getComputedStyle(chartContainer);
|
||||
|
||||
// Extract padding values
|
||||
const paddingTop = parseInt(styleChartContainer.paddingTop, 10);
|
||||
const paddingBottom = parseInt(styleChartContainer.paddingBottom, 10);
|
||||
const paddingLeft = parseInt(styleChartContainer.paddingLeft, 10);
|
||||
const paddingRight = parseInt(styleChartContainer.paddingRight, 10);
|
||||
|
||||
// Calculate the actual width and height to use, subtracting padding
|
||||
const actualWidth = mainbox.clientWidth - paddingLeft - paddingRight;
|
||||
const actualHeight = mainbox.offsetHeight - paddingTop - paddingBottom;
|
||||
|
||||
// Validate dimensions
|
||||
if (actualWidth > 0 && actualHeight > 0) {
|
||||
if (this.DEBUG_RESIZE) {
|
||||
console.log('DeesChartArea - Updating chart dimensions:', {
|
||||
width: actualWidth,
|
||||
height: actualHeight
|
||||
});
|
||||
}
|
||||
|
||||
await this.chart.updateOptions({
|
||||
chart: {
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
},
|
||||
}, true, false); // Redraw paths but don't animate
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to resize chart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a chart resize. Useful when automatic detection doesn't work.
|
||||
* This is a convenience method that can be called from outside the component.
|
||||
*/
|
||||
public async forceResize() {
|
||||
await this.resizeChart();
|
||||
}
|
||||
|
||||
private startAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
this.autoScrollTimer = window.setInterval(() => {
|
||||
this.updateTimeWindow();
|
||||
}, this.autoScrollInterval);
|
||||
}
|
||||
|
||||
private stopAutoScroll() {
|
||||
if (this.autoScrollTimer) {
|
||||
window.clearInterval(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateChartTheme() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDark = !this.goBright;
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
|
||||
await this.chart.updateOptions({
|
||||
theme: {
|
||||
mode: theme,
|
||||
},
|
||||
colors: isDark ? this.CHART_COLORS.dark : this.CHART_COLORS.light,
|
||||
xaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
|
||||
},
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
colors: [isDark ? 'hsl(0 0% 63.9%)' : 'hsl(0 0% 20%)'],
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: isDark ? 'hsl(0 0% 14.9%)' : 'hsl(0 0% 94%)',
|
||||
},
|
||||
fill: {
|
||||
gradient: {
|
||||
shade: isDark ? 'dark' : 'light',
|
||||
opacityFrom: isDark ? 0.2 : 0.3,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
484
ts_web/elements/dees-chart-area/demo.ts
Normal file
484
ts_web/elements/dees-chart-area/demo.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesChartArea } from './component.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './component.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
// Initial dataset values
|
||||
const initialDatasets = {
|
||||
system: {
|
||||
label: 'System Usage (%)',
|
||||
series: [
|
||||
{
|
||||
name: 'CPU',
|
||||
data: [
|
||||
{ x: new Date(Date.now() - 300000).toISOString(), y: 25 },
|
||||
{ x: new Date(Date.now() - 240000).toISOString(), y: 30 },
|
||||
{ x: new Date(Date.now() - 180000).toISOString(), y: 28 },
|
||||
{ x: new Date(Date.now() - 120000).toISOString(), y: 35 },
|
||||
{ x: new Date(Date.now() - 60000).toISOString(), y: 32 },
|
||||
{ x: new Date().toISOString(), y: 38 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
data: [
|
||||
{ x: new Date(Date.now() - 300000).toISOString(), y: 45 },
|
||||
{ x: new Date(Date.now() - 240000).toISOString(), y: 48 },
|
||||
{ x: new Date(Date.now() - 180000).toISOString(), y: 46 },
|
||||
{ x: new Date(Date.now() - 120000).toISOString(), y: 52 },
|
||||
{ x: new Date(Date.now() - 60000).toISOString(), y: 50 },
|
||||
{ x: new Date().toISOString(), y: 55 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const initialFormatters = {
|
||||
system: (val: number) => `${val}%`,
|
||||
};
|
||||
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Get the chart elements
|
||||
const chartElement = elementArg.querySelector('#main-chart') as DeesChartArea;
|
||||
const connectionsChartElement = elementArg.querySelector('#connections-chart') as DeesChartArea;
|
||||
let intervalId: number;
|
||||
let connectionsIntervalId: number;
|
||||
let currentDataset = 'system';
|
||||
|
||||
// Y-axis formatters for different datasets
|
||||
const formatters = {
|
||||
system: (val: number) => `${val}%`,
|
||||
network: (val: number) => `${val} Mbps`,
|
||||
sales: (val: number) => `$${val.toLocaleString()}`,
|
||||
};
|
||||
|
||||
// Time window configuration (in milliseconds)
|
||||
const TIME_WINDOW = 2 * 60 * 1000; // 2 minutes
|
||||
const UPDATE_INTERVAL = 1000; // 1 second
|
||||
const DATA_POINT_INTERVAL = 5000; // Show data points every 5 seconds
|
||||
|
||||
// Store previous values for smooth transitions
|
||||
let previousValues = {
|
||||
cpu: 30,
|
||||
memory: 50,
|
||||
download: 150,
|
||||
upload: 30,
|
||||
connections: 150
|
||||
};
|
||||
|
||||
// Generate initial data points for time window
|
||||
const generateInitialData = (baseValue: number, variance: number, interval: number = DATA_POINT_INTERVAL) => {
|
||||
const data = [];
|
||||
const now = Date.now();
|
||||
const pointCount = Math.floor(TIME_WINDOW / interval);
|
||||
|
||||
for (let i = pointCount; i >= 0; i--) {
|
||||
const timestamp = new Date(now - (i * interval)).toISOString();
|
||||
const value = baseValue + (Math.random() - 0.5) * variance;
|
||||
data.push({ x: timestamp, y: Math.round(value) });
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// Different datasets to showcase
|
||||
const datasets = {
|
||||
system: {
|
||||
label: 'System Usage (%)',
|
||||
series: [
|
||||
{
|
||||
name: 'CPU',
|
||||
data: generateInitialData(previousValues.cpu, 10),
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
data: generateInitialData(previousValues.memory, 8),
|
||||
},
|
||||
],
|
||||
},
|
||||
network: {
|
||||
label: 'Network Traffic (Mbps)',
|
||||
series: [
|
||||
{
|
||||
name: 'Download',
|
||||
data: generateInitialData(previousValues.download, 30),
|
||||
},
|
||||
{
|
||||
name: 'Upload',
|
||||
data: generateInitialData(previousValues.upload, 10),
|
||||
},
|
||||
],
|
||||
},
|
||||
sales: {
|
||||
label: 'Sales Analytics',
|
||||
series: [
|
||||
{
|
||||
name: 'Revenue',
|
||||
data: [
|
||||
{ x: '2025-01-01', y: 45000 },
|
||||
{ x: '2025-01-02', y: 52000 },
|
||||
{ x: '2025-01-03', y: 48000 },
|
||||
{ x: '2025-01-04', y: 61000 },
|
||||
{ x: '2025-01-05', y: 58000 },
|
||||
{ x: '2025-01-06', y: 65000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Profit',
|
||||
data: [
|
||||
{ x: '2025-01-01', y: 12000 },
|
||||
{ x: '2025-01-02', y: 14000 },
|
||||
{ x: '2025-01-03', y: 11000 },
|
||||
{ x: '2025-01-04', y: 18000 },
|
||||
{ x: '2025-01-05', y: 16000 },
|
||||
{ x: '2025-01-06', y: 20000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Generate smooth value transitions
|
||||
const getNextValue = (current: number, min: number, max: number, maxChange: number = 5) => {
|
||||
// Add some randomness but keep it close to current value
|
||||
const change = (Math.random() - 0.5) * maxChange * 2;
|
||||
let newValue = current + change;
|
||||
|
||||
// Apply some "pressure" to move towards center of range
|
||||
const center = (min + max) / 2;
|
||||
const pressure = (center - newValue) * 0.1;
|
||||
newValue += pressure;
|
||||
|
||||
// Ensure within bounds
|
||||
newValue = Math.max(min, Math.min(max, newValue));
|
||||
return Math.round(newValue);
|
||||
};
|
||||
|
||||
// Track time of last data point
|
||||
let lastDataPointTime = Date.now();
|
||||
let connectionsLastUpdate = Date.now();
|
||||
|
||||
// Add real-time data
|
||||
const addRealtimeData = () => {
|
||||
if (!chartElement) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Only add new data point every DATA_POINT_INTERVAL
|
||||
const shouldAddPoint = (now - lastDataPointTime) >= DATA_POINT_INTERVAL;
|
||||
|
||||
if (shouldAddPoint) {
|
||||
lastDataPointTime = now;
|
||||
const newTimestamp = new Date(now).toISOString();
|
||||
|
||||
// Generate smooth transitions for new values
|
||||
if (currentDataset === 'system') {
|
||||
// Generate new values
|
||||
previousValues.cpu = getNextValue(previousValues.cpu, 20, 50, 3);
|
||||
previousValues.memory = getNextValue(previousValues.memory, 40, 70, 2);
|
||||
|
||||
// Get current data and add new points
|
||||
const currentSeries = chartElement.chartSeries.map((series, index) => ({
|
||||
name: series.name,
|
||||
data: [
|
||||
...(series.data as Array<{x: any; y: any}>),
|
||||
index === 0
|
||||
? { x: newTimestamp, y: previousValues.cpu }
|
||||
: { x: newTimestamp, y: previousValues.memory }
|
||||
]
|
||||
}));
|
||||
|
||||
chartElement.updateSeries(currentSeries, false);
|
||||
|
||||
} else if (currentDataset === 'network') {
|
||||
// Generate new values
|
||||
previousValues.download = getNextValue(previousValues.download, 100, 200, 10);
|
||||
previousValues.upload = getNextValue(previousValues.upload, 20, 50, 5);
|
||||
|
||||
// Get current data and add new points
|
||||
const currentSeries = chartElement.chartSeries.map((series, index) => ({
|
||||
name: series.name,
|
||||
data: [
|
||||
...(series.data as Array<{x: any; y: any}>),
|
||||
index === 0
|
||||
? { x: newTimestamp, y: previousValues.download }
|
||||
: { x: newTimestamp, y: previousValues.upload }
|
||||
]
|
||||
}));
|
||||
|
||||
chartElement.updateSeries(currentSeries, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Update connections chart data
|
||||
const updateConnections = () => {
|
||||
if (!connectionsChartElement) return;
|
||||
|
||||
const now = Date.now();
|
||||
const newTimestamp = new Date(now).toISOString();
|
||||
|
||||
// Generate new connections value with discrete changes
|
||||
const change = Math.floor(Math.random() * 21) - 10; // -10 to +10 connections
|
||||
previousValues.connections = Math.max(50, Math.min(300, previousValues.connections + change));
|
||||
|
||||
// Get current data and add new point
|
||||
const currentSeries = connectionsChartElement.chartSeries;
|
||||
const newData = [{
|
||||
name: currentSeries[0]?.name || 'Connections',
|
||||
data: [
|
||||
...(currentSeries[0]?.data as Array<{x: any; y: any}> || []),
|
||||
{ x: newTimestamp, y: previousValues.connections }
|
||||
]
|
||||
}];
|
||||
|
||||
connectionsChartElement.updateSeries(newData, false);
|
||||
};
|
||||
|
||||
// Switch dataset
|
||||
const switchDataset = (name: string) => {
|
||||
currentDataset = name;
|
||||
const dataset = datasets[name];
|
||||
chartElement.label = dataset.label;
|
||||
chartElement.series = dataset.series;
|
||||
chartElement.yAxisFormatter = formatters[name];
|
||||
|
||||
// Set appropriate y-axis scaling
|
||||
if (name === 'system') {
|
||||
chartElement.yAxisScaling = 'percentage';
|
||||
chartElement.yAxisMax = 100;
|
||||
} else if (name === 'network') {
|
||||
chartElement.yAxisScaling = 'dynamic';
|
||||
} else {
|
||||
chartElement.yAxisScaling = 'dynamic';
|
||||
}
|
||||
|
||||
// Reset last data point time to get fresh data immediately
|
||||
lastDataPointTime = Date.now() - DATA_POINT_INTERVAL;
|
||||
};
|
||||
|
||||
// Start/stop real-time updates
|
||||
const startRealtime = () => {
|
||||
if (!intervalId && (currentDataset === 'system' || currentDataset === 'network')) {
|
||||
chartElement.realtimeMode = true;
|
||||
// Only add data every 5 seconds, chart auto-scrolls independently
|
||||
intervalId = window.setInterval(() => addRealtimeData(), DATA_POINT_INTERVAL);
|
||||
}
|
||||
|
||||
// Start connections updates
|
||||
if (!connectionsIntervalId) {
|
||||
connectionsChartElement.realtimeMode = true;
|
||||
// Update connections every second
|
||||
connectionsIntervalId = window.setInterval(() => updateConnections(), UPDATE_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRealtime = () => {
|
||||
if (intervalId) {
|
||||
window.clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
chartElement.realtimeMode = false;
|
||||
}
|
||||
|
||||
// Stop connections updates
|
||||
if (connectionsIntervalId) {
|
||||
window.clearInterval(connectionsIntervalId);
|
||||
connectionsIntervalId = null;
|
||||
connectionsChartElement.realtimeMode = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Randomize current data (spike/drop simulation)
|
||||
const randomizeData = () => {
|
||||
if (currentDataset === 'system') {
|
||||
// Simulate CPU/Memory spike
|
||||
previousValues.cpu = Math.random() > 0.5 ? 85 : 25;
|
||||
previousValues.memory = Math.random() > 0.5 ? 80 : 45;
|
||||
} else if (currentDataset === 'network') {
|
||||
// Simulate network traffic spike
|
||||
previousValues.download = Math.random() > 0.5 ? 250 : 100;
|
||||
previousValues.upload = Math.random() > 0.5 ? 80 : 20;
|
||||
}
|
||||
|
||||
// Also spike connections
|
||||
previousValues.connections = Math.random() > 0.5 ? 280 : 80;
|
||||
|
||||
// Force immediate update by resetting timers
|
||||
lastDataPointTime = 0;
|
||||
connectionsLastUpdate = 0;
|
||||
};
|
||||
|
||||
// Wire up button click handlers
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
if (text === 'System Usage') {
|
||||
button.addEventListener('click', () => switchDataset('system'));
|
||||
} else if (text === 'Network Traffic') {
|
||||
button.addEventListener('click', () => switchDataset('network'));
|
||||
} else if (text === 'Sales Data') {
|
||||
button.addEventListener('click', () => switchDataset('sales'));
|
||||
} else if (text === 'Start Live') {
|
||||
button.addEventListener('click', () => startRealtime());
|
||||
} else if (text === 'Stop Live') {
|
||||
button.addEventListener('click', () => stopRealtime());
|
||||
} else if (text === 'Spike Values') {
|
||||
button.addEventListener('click', () => randomizeData());
|
||||
}
|
||||
});
|
||||
|
||||
// Update button states based on current dataset
|
||||
const updateButtonStates = () => {
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
if (text === 'System Usage') {
|
||||
button.type = currentDataset === 'system' ? 'highlighted' : 'normal';
|
||||
} else if (text === 'Network Traffic') {
|
||||
button.type = currentDataset === 'network' ? 'highlighted' : 'normal';
|
||||
} else if (text === 'Sales Data') {
|
||||
button.type = currentDataset === 'sales' ? 'highlighted' : 'normal';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Configure main chart with rolling window
|
||||
chartElement.rollingWindow = TIME_WINDOW;
|
||||
chartElement.realtimeMode = false; // Will be enabled when starting live updates
|
||||
chartElement.yAxisScaling = 'percentage'; // Initial system dataset uses percentage
|
||||
chartElement.yAxisMax = 100;
|
||||
chartElement.autoScrollInterval = 1000; // Auto-scroll every second
|
||||
|
||||
// Set initial time window
|
||||
setTimeout(() => {
|
||||
chartElement.updateTimeWindow();
|
||||
}, 100);
|
||||
|
||||
// Update button states when dataset changes
|
||||
const originalSwitchDataset = switchDataset;
|
||||
const switchDatasetWithButtonUpdate = (name: string) => {
|
||||
originalSwitchDataset(name);
|
||||
updateButtonStates();
|
||||
};
|
||||
|
||||
// Replace switchDataset with the one that updates buttons
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
if (text === 'System Usage') {
|
||||
button.removeEventListener('click', () => switchDataset('system'));
|
||||
button.addEventListener('click', () => switchDatasetWithButtonUpdate('system'));
|
||||
} else if (text === 'Network Traffic') {
|
||||
button.removeEventListener('click', () => switchDataset('network'));
|
||||
button.addEventListener('click', () => switchDatasetWithButtonUpdate('network'));
|
||||
} else if (text === 'Sales Data') {
|
||||
button.removeEventListener('click', () => switchDataset('sales'));
|
||||
button.addEventListener('click', () => switchDatasetWithButtonUpdate('sales'));
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize connections chart with data
|
||||
if (connectionsChartElement) {
|
||||
const initialConnectionsData = generateInitialData(previousValues.connections, 30, UPDATE_INTERVAL);
|
||||
connectionsChartElement.series = [{
|
||||
name: 'Connections',
|
||||
data: initialConnectionsData
|
||||
}];
|
||||
|
||||
// Configure connections chart
|
||||
connectionsChartElement.rollingWindow = TIME_WINDOW;
|
||||
connectionsChartElement.realtimeMode = false; // Will be enabled when starting live updates
|
||||
connectionsChartElement.yAxisScaling = 'fixed';
|
||||
connectionsChartElement.yAxisMax = 350;
|
||||
connectionsChartElement.autoScrollInterval = 1000; // Auto-scroll every second
|
||||
|
||||
// Set initial time window
|
||||
setTimeout(() => {
|
||||
connectionsChartElement.updateTimeWindow();
|
||||
}, 100);
|
||||
}
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="controls">
|
||||
<dees-button-group label="Dataset:">
|
||||
<dees-button type="highlighted">System Usage</dees-button>
|
||||
<dees-button>Network Traffic</dees-button>
|
||||
<dees-button>Sales Data</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Real-time:">
|
||||
<dees-button>Start Live</dees-button>
|
||||
<dees-button>Stop Live</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Spike Values</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<dees-chart-area
|
||||
id="main-chart"
|
||||
.label=${initialDatasets.system.label}
|
||||
.series=${initialDatasets.system.series}
|
||||
.yAxisFormatter=${initialFormatters.system}
|
||||
></dees-chart-area>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" style="margin-top: 20px;">
|
||||
<dees-chart-area
|
||||
id="connections-chart"
|
||||
.label=${'Active Connections'}
|
||||
.series=${[{
|
||||
name: 'Connections',
|
||||
data: [] as Array<{x: any; y: any}>
|
||||
}]}
|
||||
.yAxisFormatter=${(val: number) => `${val}`}
|
||||
></dees-chart-area>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Real-time monitoring with 2-minute rolling window •
|
||||
Updates every second with smooth value transitions •
|
||||
Click 'Spike Values' to simulate load spikes
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
3
ts_web/elements/dees-chart-area/index.ts
Normal file
3
ts_web/elements/dees-chart-area/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './component.js';
|
||||
export { chartAreaStyles } from './styles.js';
|
||||
export { renderChartArea } from './template.js';
|
||||
60
ts_web/elements/dees-chart-area/styles.ts
Normal file
60
ts_web/elements/dees-chart-area/styles.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const chartAreaStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mainbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 16px 24px;
|
||||
z-index: 10;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 63.9%)')};
|
||||
}
|
||||
.chartContainer {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
padding: 44px 16px 16px 0px;
|
||||
overflow: hidden;
|
||||
background: transparent; /* Ensure container doesn't override chart background */
|
||||
}
|
||||
|
||||
/* ApexCharts theme overrides */
|
||||
.apexcharts-canvas {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.apexcharts-inner {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.apexcharts-graphical {
|
||||
background: transparent !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
12
ts_web/elements/dees-chart-area/template.ts
Normal file
12
ts_web/elements/dees-chart-area/template.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesChartArea } from './component.js';
|
||||
|
||||
export const renderChartArea = (component: DeesChartArea): TemplateResult => {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
<div class="chartTitle">${component.label}</div>
|
||||
<div class="chartContainer"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
170
ts_web/elements/dees-chart-log.demo.ts
Normal file
170
ts_web/elements/dees-chart-log.demo.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import type { DeesChartLog } from './dees-chart-log.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Get the log element
|
||||
const logElement = elementArg.querySelector('dees-chart-log') as DeesChartLog;
|
||||
let intervalId: number;
|
||||
|
||||
const serverSources = ['Server', 'Database', 'API', 'Auth', 'Cache', 'Queue', 'WebSocket', 'Scheduler'];
|
||||
|
||||
const logTemplates = {
|
||||
debug: [
|
||||
'Loading module: {{module}}',
|
||||
'Cache hit for key: {{key}}',
|
||||
'SQL query executed in {{time}}ms',
|
||||
'Request headers: {{headers}}',
|
||||
'Environment variable loaded: {{var}}',
|
||||
],
|
||||
info: [
|
||||
'Request received: {{method}} {{path}}',
|
||||
'User {{userId}} authenticated successfully',
|
||||
'Processing job {{jobId}} from queue',
|
||||
'Scheduled task "{{task}}" started',
|
||||
'WebSocket connection established from {{ip}}',
|
||||
],
|
||||
warn: [
|
||||
'Slow query detected: {{query}} ({{time}}ms)',
|
||||
'Memory usage at {{percent}}%',
|
||||
'Rate limit approaching for IP {{ip}}',
|
||||
'Deprecated API endpoint called: {{endpoint}}',
|
||||
'Certificate expires in {{days}} days',
|
||||
],
|
||||
error: [
|
||||
'Database connection lost: {{error}}',
|
||||
'Failed to process request: {{error}}',
|
||||
'Authentication failed for user {{user}}',
|
||||
'File not found: {{path}}',
|
||||
'Service unavailable: {{service}}',
|
||||
],
|
||||
success: [
|
||||
'Server started successfully on port {{port}}',
|
||||
'Database migration completed',
|
||||
'Backup completed: {{size}} MB',
|
||||
'SSL certificate renewed',
|
||||
'Health check passed: all systems operational',
|
||||
],
|
||||
};
|
||||
|
||||
const generateRandomLog = () => {
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error' | 'success'> = ['debug', 'info', 'warn', 'error', 'success'];
|
||||
const weights = [0.2, 0.5, 0.15, 0.1, 0.05]; // Weighted probability
|
||||
|
||||
const random = Math.random();
|
||||
let cumulative = 0;
|
||||
let level: typeof levels[0] = 'info';
|
||||
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
cumulative += weights[i];
|
||||
if (random < cumulative) {
|
||||
level = levels[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const source = serverSources[Math.floor(Math.random() * serverSources.length)];
|
||||
const templates = logTemplates[level];
|
||||
const template = templates[Math.floor(Math.random() * templates.length)];
|
||||
|
||||
// Replace placeholders with random values
|
||||
const message = template
|
||||
.replace('{{module}}', ['express', 'mongoose', 'redis', 'socket.io'][Math.floor(Math.random() * 4)])
|
||||
.replace('{{key}}', 'user:' + Math.floor(Math.random() * 1000))
|
||||
.replace('{{time}}', String(Math.floor(Math.random() * 500) + 50))
|
||||
.replace('{{headers}}', 'Content-Type: application/json, Authorization: Bearer ...')
|
||||
.replace('{{var}}', ['NODE_ENV', 'DATABASE_URL', 'API_KEY', 'PORT'][Math.floor(Math.random() * 4)])
|
||||
.replace('{{method}}', ['GET', 'POST', 'PUT', 'DELETE'][Math.floor(Math.random() * 4)])
|
||||
.replace('{{path}}', ['/api/users', '/api/auth/login', '/api/products', '/health'][Math.floor(Math.random() * 4)])
|
||||
.replace('{{userId}}', String(Math.floor(Math.random() * 10000)))
|
||||
.replace('{{jobId}}', 'job_' + Math.random().toString(36).substring(2, 11))
|
||||
.replace('{{task}}', ['cleanup', 'backup', 'report-generation', 'cache-refresh'][Math.floor(Math.random() * 4)])
|
||||
.replace('{{ip}}', `192.168.1.${Math.floor(Math.random() * 255)}`)
|
||||
.replace('{{query}}', 'SELECT * FROM users WHERE ...')
|
||||
.replace('{{percent}}', String(Math.floor(Math.random() * 30) + 70))
|
||||
.replace('{{endpoint}}', '/api/v1/legacy')
|
||||
.replace('{{days}}', String(Math.floor(Math.random() * 30) + 1))
|
||||
.replace('{{error}}', ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'][Math.floor(Math.random() * 3)])
|
||||
.replace('{{user}}', 'user_' + Math.floor(Math.random() * 1000))
|
||||
.replace('{{service}}', ['Redis', 'MongoDB', 'ElasticSearch'][Math.floor(Math.random() * 3)])
|
||||
.replace('{{port}}', String(3000 + Math.floor(Math.random() * 10)))
|
||||
.replace('{{size}}', String(Math.floor(Math.random() * 500) + 100));
|
||||
|
||||
logElement.addLog(level, message, source);
|
||||
};
|
||||
|
||||
const startSimulation = () => {
|
||||
if (!intervalId) {
|
||||
// Generate logs at random intervals between 500ms and 2500ms
|
||||
const scheduleNext = () => {
|
||||
generateRandomLog();
|
||||
const nextDelay = Math.random() * 2000 + 500;
|
||||
intervalId = window.setTimeout(() => {
|
||||
if (intervalId) {
|
||||
scheduleNext();
|
||||
}
|
||||
}, nextDelay);
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
};
|
||||
|
||||
const stopSimulation = () => {
|
||||
if (intervalId) {
|
||||
window.clearTimeout(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Wire up button click handlers
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
if (text === 'Add Single Log') {
|
||||
button.addEventListener('click', () => generateRandomLog());
|
||||
} else if (text === 'Start Simulation') {
|
||||
button.addEventListener('click', () => startSimulation());
|
||||
} else if (text === 'Stop Simulation') {
|
||||
button.addEventListener('click', () => stopSimulation());
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<style>
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: #000000;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.info {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="controls">
|
||||
<dees-button>Add Single Log</dees-button>
|
||||
<dees-button>Start Simulation</dees-button>
|
||||
<dees-button>Stop Simulation</dees-button>
|
||||
</div>
|
||||
<div class="info">Simulating realistic server logs with various levels and sources</div>
|
||||
<dees-chart-log
|
||||
.label=${'Production Server Logs'}
|
||||
></dees-chart-log>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
326
ts_web/elements/dees-chart-log.ts
Normal file
326
ts_web/elements/dees-chart-log.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-chart-log.demo.js';
|
||||
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-chart-log': DeesChartLog;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ILogEntry {
|
||||
timestamp: string;
|
||||
level: 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||
message: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
@customElement('dees-chart-log')
|
||||
export class DeesChartLog extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property()
|
||||
public label: string = 'Server Logs';
|
||||
|
||||
@property({ type: Array })
|
||||
public logEntries: ILogEntry[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
public autoScroll: boolean = true;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxEntries: number = 1000;
|
||||
|
||||
private logContainer: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
domtools.elementBasic.setup();
|
||||
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mainbox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 14.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.control-button.active {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 93.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
|
||||
}
|
||||
|
||||
.logContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level {
|
||||
margin-right: 8px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level.debug {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 45.1% / 0.1)', 'hsl(0 0% 63.9% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.info {
|
||||
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.warn {
|
||||
color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||
background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.error {
|
||||
color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
|
||||
}
|
||||
|
||||
.level.success {
|
||||
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
|
||||
}
|
||||
|
||||
.source {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.logContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.logContainer::-webkit-scrollbar-track {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 10%)')};
|
||||
}
|
||||
|
||||
.logContainer::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 30%)')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 40%)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
<div class="header">
|
||||
<div class="title">${this.label}</div>
|
||||
<div class="controls">
|
||||
<button
|
||||
class="control-button ${this.autoScroll ? 'active' : ''}"
|
||||
@click=${() => { this.autoScroll = !this.autoScroll; }}
|
||||
>
|
||||
Auto Scroll
|
||||
</button>
|
||||
<button
|
||||
class="control-button"
|
||||
@click=${() => { this.clearLogs(); }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logContainer">
|
||||
${this.logEntries.length === 0
|
||||
? html`<div class="empty-state">No logs to display</div>`
|
||||
: this.logEntries.map(entry => this.renderLogEntry(entry))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLogEntry(entry: ILogEntry): TemplateResult {
|
||||
const timestamp = new Date(entry.timestamp).toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="logEntry">
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
<span class="level ${entry.level}">${entry.level}</span>
|
||||
${entry.source ? html`<span class="source">[${entry.source}]</span>` : ''}
|
||||
<span class="message">${entry.message}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
await this.domtoolsPromise;
|
||||
this.logContainer = this.shadowRoot.querySelector('.logContainer');
|
||||
|
||||
// Initialize with demo server logs
|
||||
const demoLogs: ILogEntry[] = [
|
||||
{ timestamp: new Date().toISOString(), level: 'info', message: 'Server started on port 3000', source: 'Server' },
|
||||
{ timestamp: new Date().toISOString(), level: 'debug', message: 'Loading configuration from /etc/app/config.json', source: 'Config' },
|
||||
{ timestamp: new Date().toISOString(), level: 'info', message: 'Connected to MongoDB at mongodb://localhost:27017', source: 'Database' },
|
||||
{ timestamp: new Date().toISOString(), level: 'success', message: 'Database connection established successfully', source: 'Database' },
|
||||
{ timestamp: new Date().toISOString(), level: 'warn', message: 'No SSL certificate found, using self-signed certificate', source: 'Security' },
|
||||
{ timestamp: new Date().toISOString(), level: 'info', message: 'API routes initialized: GET /api/users, POST /api/users, DELETE /api/users/:id', source: 'Router' },
|
||||
{ timestamp: new Date().toISOString(), level: 'debug', message: 'Middleware stack: cors, bodyParser, authentication, errorHandler', source: 'Middleware' },
|
||||
{ timestamp: new Date().toISOString(), level: 'info', message: 'WebSocket server listening on ws://localhost:3001', source: 'WebSocket' },
|
||||
];
|
||||
|
||||
this.logEntries = demoLogs;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
public async updateLog(entries?: ILogEntry[]) {
|
||||
if (entries) {
|
||||
// Add new entries
|
||||
this.logEntries = [...this.logEntries, ...entries];
|
||||
|
||||
// Trim if exceeds max entries
|
||||
if (this.logEntries.length > this.maxEntries) {
|
||||
this.logEntries = this.logEntries.slice(-this.maxEntries);
|
||||
}
|
||||
|
||||
// Trigger re-render
|
||||
this.requestUpdate();
|
||||
|
||||
// Auto-scroll if enabled
|
||||
await this.updateComplete;
|
||||
if (this.autoScroll) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clearLogs() {
|
||||
this.logEntries = [];
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private scrollToBottom() {
|
||||
if (this.logContainer) {
|
||||
this.logContainer.scrollTop = this.logContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
public addLog(level: ILogEntry['level'], message: string, source?: string) {
|
||||
const newEntry: ILogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
source
|
||||
};
|
||||
this.updateLog([newEntry]);
|
||||
}
|
||||
}
|
||||
112
ts_web/elements/dees-chips.demo.ts
Normal file
112
ts_web/elements/dees-chips.demo.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { html, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
.demoContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding: 48px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
<div class="demoContainer">
|
||||
<div class="section">
|
||||
<div class="section-title">Non-Selectable Chips</div>
|
||||
<div class="section-description">Basic chips without selection capability. Use for display-only tags.</div>
|
||||
<dees-chips
|
||||
selectionMode="none"
|
||||
.selectableChips=${[
|
||||
{ key: 'status', value: 'Active' },
|
||||
{ key: 'tier', value: 'Premium' },
|
||||
{ key: 'region', value: 'EU-West' },
|
||||
{ key: 'type', value: 'Enterprise' },
|
||||
]}
|
||||
></dees-chips>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Single Selection Chips</div>
|
||||
<div class="section-description">Click to select one chip at a time. Useful for filters and options.</div>
|
||||
<dees-chips
|
||||
selectionMode="single"
|
||||
.selectableChips=${[
|
||||
{ key: 'all', value: 'All Projects' },
|
||||
{ key: 'active', value: 'Active' },
|
||||
{ key: 'archived', value: 'Archived' },
|
||||
{ key: 'drafts', value: 'Drafts' },
|
||||
]}
|
||||
></dees-chips>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Multiple Selection Chips</div>
|
||||
<div class="section-description">Select multiple chips simultaneously. Great for tag selection.</div>
|
||||
<dees-chips
|
||||
selectionMode="multiple"
|
||||
.selectableChips=${[
|
||||
{ key: 'js', value: 'JavaScript' },
|
||||
{ key: 'ts', value: 'TypeScript' },
|
||||
{ key: 'react', value: 'React' },
|
||||
{ key: 'vue', value: 'Vue' },
|
||||
{ key: 'angular', value: 'Angular' },
|
||||
{ key: 'node', value: 'Node.js' },
|
||||
]}
|
||||
></dees-chips>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Removable Chips with Keys</div>
|
||||
<div class="section-description">Chips with remove buttons and key-value pairs. Perfect for dynamic lists.</div>
|
||||
<dees-chips
|
||||
selectionMode="single"
|
||||
chipsAreRemovable
|
||||
.selectableChips=${[
|
||||
{ key: 'env', value: 'Production' },
|
||||
{ key: 'version', value: '2.4.1' },
|
||||
{ key: 'branch', value: 'main' },
|
||||
{ key: 'author', value: 'John Doe' },
|
||||
]}
|
||||
></dees-chips>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Mixed Content Example</div>
|
||||
<div class="section-description">Combining different chip types for complex UIs.</div>
|
||||
<dees-chips
|
||||
selectionMode="multiple"
|
||||
chipsAreRemovable
|
||||
.selectableChips=${[
|
||||
{ key: 'priority', value: 'High' },
|
||||
{ key: 'status', value: 'In Progress' },
|
||||
{ key: 'bug', value: 'Bug' },
|
||||
{ key: 'feature', value: 'Feature' },
|
||||
{ key: 'sprint', value: 'Sprint 23' },
|
||||
{ key: 'assignee', value: 'Alice' },
|
||||
]}
|
||||
></dees-chips>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -3,13 +3,15 @@ import {
|
||||
html,
|
||||
DeesElement,
|
||||
property,
|
||||
TemplateResult,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
type CSSResult,
|
||||
unsafeCSS,
|
||||
} from '@designestate/dees-element';
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@designestate/dees-domtools';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-chips.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -17,30 +19,32 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type Tag = { key: string; value: string };
|
||||
|
||||
@customElement('dees-chips')
|
||||
export class DeesChips extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<dees-chips .selectableChips=${['Payment Account 1', 'PaymentAccount2', 'Payment Account 3']}></dees-chips>
|
||||
<dees-chips selectionMode="multiple" .selectableChips=${['Payment Account 1', 'PaymentAccount2', 'Payment Account 3']}></dees-chips>
|
||||
|
||||
`;
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property()
|
||||
public selectionMode: 'single' | 'multiple' = 'single';
|
||||
public selectionMode: 'none' | 'single' | 'multiple' = 'single';
|
||||
|
||||
@property({
|
||||
type: Array
|
||||
type: Boolean,
|
||||
})
|
||||
public selectableChips: string[] = [];
|
||||
public chipsAreRemovable: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public selectableChips: Tag[] = [];
|
||||
|
||||
@property()
|
||||
public selectedChip: string = null;
|
||||
public selectedChip: Tag = null;
|
||||
|
||||
@property({
|
||||
type: Array
|
||||
type: Array,
|
||||
})
|
||||
public selectedChips: string[] = [];
|
||||
|
||||
public selectedChips: Tag[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -49,75 +53,190 @@ export class DeesChips extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mainbox {
|
||||
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: #494949;
|
||||
display: inline-block;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0px 12px;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
border-radius: 30px;
|
||||
margin-right: 3px;
|
||||
margin-bottom: 7px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: #666666;
|
||||
cursor: pointer;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#52525b')};
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.chip.selected {
|
||||
background: #00A3FF;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chip.selected:hover {
|
||||
background: ${cssManager.bdTheme('#2563eb', '#2563eb')};
|
||||
border-color: ${cssManager.bdTheme('#2563eb', '#2563eb')};
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: -8px;
|
||||
padding: 0px 8px;
|
||||
margin-right: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.chip.selected .chipKey {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
dees-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 8px;
|
||||
margin-right: -6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.chip.selected dees-icon {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
dees-icon:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#ef4444', '#ef4444')};
|
||||
}
|
||||
|
||||
.chip.selected dees-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
${this.selectableChips.map(chipArg => html`
|
||||
<div @click=${() => this.selectChip(chipArg)} class="chip ${this.selectedChip === chipArg || this.selectedChips.includes(chipArg) ? 'selected' : ''}">
|
||||
${chipArg}
|
||||
</div>
|
||||
`)}
|
||||
${this.selectableChips.map(
|
||||
(chip) => html`
|
||||
<div
|
||||
@click=${() => this.selectChip(chip)}
|
||||
class="chip ${this.isSelected(chip) ? 'selected' : ''}"
|
||||
>
|
||||
${chip.key ? html`<div class="chipKey">${chip.key}</div>` : html``} ${chip.value}
|
||||
${this.chipsAreRemovable
|
||||
? html`
|
||||
<dees-icon
|
||||
@click=${(event: Event) => {
|
||||
event.stopPropagation(); // prevent the selectChip event from being triggered
|
||||
this.removeChip(chip);
|
||||
}}
|
||||
.icon=${'fa:xmark'}
|
||||
></dees-icon>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
if (!this.textContent) {
|
||||
this.textContent = 'Button';
|
||||
this.performUpdate();
|
||||
// Component initialized
|
||||
}
|
||||
|
||||
private isSelected(chip: Tag): boolean {
|
||||
if (this.selectionMode === 'single') {
|
||||
return this.selectedChip ? this.isSameChip(this.selectedChip, chip) : false;
|
||||
} else {
|
||||
return this.selectedChips.some((selected) => this.isSameChip(selected, chip));
|
||||
}
|
||||
}
|
||||
|
||||
public async selectChip(chipArg: string) {
|
||||
private isSameChip(chip1: Tag, chip2: Tag): boolean {
|
||||
// If both have keys, compare by key
|
||||
if (chip1.key && chip2.key) {
|
||||
return chip1.key === chip2.key;
|
||||
}
|
||||
// Otherwise compare by value (and key if present)
|
||||
return chip1.value === chip2.value && chip1.key === chip2.key;
|
||||
}
|
||||
|
||||
public async selectChip(chip: Tag) {
|
||||
if (this.selectionMode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectionMode === 'single') {
|
||||
if (this.selectedChip === chipArg) {
|
||||
if (this.isSelected(chip)) {
|
||||
this.selectedChip = null;
|
||||
this.selectedChips = [];
|
||||
} else {
|
||||
this.selectedChip = chipArg;
|
||||
this.selectedChips = [chipArg];
|
||||
this.selectedChip = chip;
|
||||
this.selectedChips = [chip];
|
||||
}
|
||||
} else if(this.selectionMode === 'multiple') {
|
||||
if (this.selectedChips.includes(chipArg)) {
|
||||
this.selectedChips = this.selectedChips.filter(chipArg2 => chipArg !== chipArg2)
|
||||
} else if (this.selectionMode === 'multiple') {
|
||||
if (this.isSelected(chip)) {
|
||||
this.selectedChips = this.selectedChips.filter((selected) => !this.isSameChip(selected, chip));
|
||||
} else {
|
||||
this.selectedChips.push(chipArg);
|
||||
this.selectedChips = [...this.selectedChips, chip];
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
console.log(this.selectedChips);
|
||||
}
|
||||
|
||||
public removeChip(chipToRemove: Tag): void {
|
||||
// Remove the chip from selectableChips
|
||||
this.selectableChips = this.selectableChips.filter((chip) => !this.isSameChip(chip, chipToRemove));
|
||||
|
||||
// Remove the chip from selectedChips if present
|
||||
this.selectedChips = this.selectedChips.filter((chip) => !this.isSameChip(chip, chipToRemove));
|
||||
|
||||
// If the removed chip was the selectedChip, set selectedChip to null
|
||||
if (this.selectedChip && this.isSameChip(this.selectedChip, chipToRemove)) {
|
||||
this.selectedChip = null;
|
||||
}
|
||||
|
||||
// Trigger an update to re-render the component
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
215
ts_web/elements/dees-contextmenu.demo.ts
Normal file
215
ts_web/elements/dees-contextmenu.demo.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import * as plugins from './00plugins.js';
|
||||
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
.withMargin {
|
||||
display: block;
|
||||
margin: 20px;
|
||||
}
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
min-height: 400px;
|
||||
}
|
||||
.demo-area {
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
cursor: context-menu;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.demo-area:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
</style>
|
||||
<div class="demo-container">
|
||||
<dees-panel heading="Basic Context Menu with Nested Submenus">
|
||||
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'File',
|
||||
iconName: 'fileText',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'New', iconName: 'filePlus', shortcut: 'Cmd+N', action: async () => console.log('New file') },
|
||||
{ name: 'Open', iconName: 'folderOpen', shortcut: 'Cmd+O', action: async () => console.log('Open file') },
|
||||
{ name: 'Save', iconName: 'save', shortcut: 'Cmd+S', action: async () => console.log('Save') },
|
||||
{ divider: true },
|
||||
{ name: 'Export as PDF', iconName: 'download', action: async () => console.log('Export PDF') },
|
||||
{ name: 'Export as HTML', iconName: 'code', action: async () => console.log('Export HTML') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'edit3',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Cut', iconName: 'scissors', shortcut: 'Cmd+X', action: async () => console.log('Cut') },
|
||||
{ name: 'Copy', iconName: 'copy', shortcut: 'Cmd+C', action: async () => console.log('Copy') },
|
||||
{ name: 'Paste', iconName: 'clipboard', shortcut: 'Cmd+V', action: async () => console.log('Paste') },
|
||||
{ divider: true },
|
||||
{ name: 'Find', iconName: 'search', shortcut: 'Cmd+F', action: async () => console.log('Find') },
|
||||
{ name: 'Replace', iconName: 'repeat', shortcut: 'Cmd+H', action: async () => console.log('Replace') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'View',
|
||||
iconName: 'eye',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Zoom In', iconName: 'zoomIn', shortcut: 'Cmd++', action: async () => console.log('Zoom in') },
|
||||
{ name: 'Zoom Out', iconName: 'zoomOut', shortcut: 'Cmd+-', action: async () => console.log('Zoom out') },
|
||||
{ name: 'Reset Zoom', iconName: 'maximize2', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') },
|
||||
{ divider: true },
|
||||
{ name: 'Full Screen', iconName: 'maximize', shortcut: 'F11', action: async () => console.log('Full screen') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'settings',
|
||||
action: async () => console.log('Settings')
|
||||
},
|
||||
{
|
||||
name: 'Help',
|
||||
iconName: 'helpCircle',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') },
|
||||
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', action: async () => console.log('Shortcuts') },
|
||||
{ divider: true },
|
||||
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
|
||||
]
|
||||
}
|
||||
]);
|
||||
}}>
|
||||
<h3>Right-click anywhere in this area</h3>
|
||||
<p>A context menu with nested submenus will appear</p>
|
||||
</div>
|
||||
</dees-panel>
|
||||
<dees-panel heading="Component-Specific Context Menu">
|
||||
<dees-button style="margin: 20px;" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Button Actions',
|
||||
iconName: 'mousePointer',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Click', iconName: 'mouse', action: async () => console.log('Click action') },
|
||||
{ name: 'Double Click', iconName: 'zap', action: async () => console.log('Double click') },
|
||||
{ name: 'Long Press', iconName: 'clock', action: async () => console.log('Long press') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Button State',
|
||||
iconName: 'toggleLeft',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Enable', iconName: 'checkCircle', action: async () => console.log('Enable') },
|
||||
{ name: 'Disable', iconName: 'xCircle', action: async () => console.log('Disable') },
|
||||
{ divider: true },
|
||||
{ name: 'Show', iconName: 'eye', action: async () => console.log('Show') },
|
||||
{ name: 'Hide', iconName: 'eyeOff', action: async () => console.log('Hide') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Disabled Action',
|
||||
iconName: 'ban',
|
||||
disabled: true,
|
||||
action: async () => console.log('This should not run'),
|
||||
},
|
||||
{
|
||||
name: 'Properties',
|
||||
iconName: 'settings',
|
||||
action: async () => console.log('Button properties'),
|
||||
},
|
||||
]);
|
||||
}}>Right-click on this button</dees-button>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel heading="Advanced Context Menu Example">
|
||||
<div class="demo-area" @contextmenu=${(eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'Format',
|
||||
iconName: 'type',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Bold', iconName: 'bold', shortcut: 'Cmd+B', action: async () => console.log('Bold') },
|
||||
{ name: 'Italic', iconName: 'italic', shortcut: 'Cmd+I', action: async () => console.log('Italic') },
|
||||
{ name: 'Underline', iconName: 'underline', shortcut: 'Cmd+U', action: async () => console.log('Underline') },
|
||||
{ divider: true },
|
||||
{ name: 'Font Size', iconName: 'type', action: async () => console.log('Font size menu') },
|
||||
{ name: 'Font Color', iconName: 'palette', action: async () => console.log('Font color menu') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Transform',
|
||||
iconName: 'shuffle',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'To Uppercase', iconName: 'arrowUp', action: async () => console.log('Uppercase') },
|
||||
{ name: 'To Lowercase', iconName: 'arrowDown', action: async () => console.log('Lowercase') },
|
||||
{ name: 'Capitalize', iconName: 'type', action: async () => console.log('Capitalize') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'trash2',
|
||||
action: async () => console.log('Delete')
|
||||
}
|
||||
]);
|
||||
}}>
|
||||
<h3>Advanced Nested Menu Example</h3>
|
||||
<p>This shows deeply nested submenus and various formatting options</p>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel heading="Static Context Menu (Always Visible)">
|
||||
<dees-contextmenu
|
||||
class="withMargin"
|
||||
.menuItems=${[
|
||||
{
|
||||
name: 'Project',
|
||||
iconName: 'folder',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'New Project', iconName: 'folderPlus', shortcut: 'Cmd+Shift+N', action: async () => console.log('New project') },
|
||||
{ name: 'Open Project', iconName: 'folderOpen', shortcut: 'Cmd+Shift+O', action: async () => console.log('Open project') },
|
||||
{ divider: true },
|
||||
{ name: 'Recent Projects', iconName: 'clock', action: async () => {}, submenu: [
|
||||
{ name: 'Project Alpha', action: async () => console.log('Open Alpha') },
|
||||
{ name: 'Project Beta', action: async () => console.log('Open Beta') },
|
||||
{ name: 'Project Gamma', action: async () => console.log('Open Gamma') },
|
||||
]},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
iconName: 'tool',
|
||||
action: async () => {}, // Parent items with submenus still need an action
|
||||
submenu: [
|
||||
{ name: 'Terminal', iconName: 'terminal', shortcut: 'Cmd+T', action: async () => console.log('Terminal') },
|
||||
{ name: 'Console', iconName: 'monitor', shortcut: 'Cmd+K', action: async () => console.log('Console') },
|
||||
{ divider: true },
|
||||
{ name: 'Extensions', iconName: 'package', action: async () => console.log('Extensions') },
|
||||
]
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Preferences',
|
||||
iconName: 'sliders',
|
||||
action: async () => console.log('Preferences'),
|
||||
},
|
||||
]}
|
||||
></dees-contextmenu>
|
||||
</dees-panel>
|
||||
</div>
|
||||
`;
|
||||
456
ts_web/elements/dees-contextmenu.ts
Normal file
456
ts_web/elements/dees-contextmenu.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import { demoFunc } from './dees-contextmenu.demo.js';
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
DeesElement,
|
||||
property,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
type CSSResult,
|
||||
unsafeCSS,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesWindowLayer } from './dees-windowlayer.js';
|
||||
import { zIndexLayers } from './00zindex.js';
|
||||
import './dees-icon.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-contextmenu': DeesContextmenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-contextmenu')
|
||||
export class DeesContextmenu extends DeesElement {
|
||||
// DEMO
|
||||
public static demo = demoFunc
|
||||
|
||||
// STATIC
|
||||
// This will store all the accumulated menu items
|
||||
public static contextMenuDeactivated = false;
|
||||
public static accumulatedMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[] = [];
|
||||
|
||||
// Add a global event listener for the right-click context menu
|
||||
public static initializeGlobalListener() {
|
||||
document.addEventListener('contextmenu', (event: MouseEvent) => {
|
||||
if (this.contextMenuDeactivated) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
// Clear previously accumulated items
|
||||
DeesContextmenu.accumulatedMenuItems = [];
|
||||
|
||||
// Use composedPath to properly traverse shadow DOM boundaries
|
||||
const path = event.composedPath();
|
||||
|
||||
// Traverse the composed path to accumulate menu items
|
||||
for (const element of path) {
|
||||
if ((element as any).getContextMenuItems) {
|
||||
const items = (element as any).getContextMenuItems();
|
||||
if (items && items.length > 0) {
|
||||
if (DeesContextmenu.accumulatedMenuItems.length > 0) {
|
||||
DeesContextmenu.accumulatedMenuItems.push({ divider: true });
|
||||
}
|
||||
DeesContextmenu.accumulatedMenuItems.push(...items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the context menu with the accumulated items
|
||||
DeesContextmenu.openContextMenuWithOptions(event, DeesContextmenu.accumulatedMenuItems);
|
||||
});
|
||||
}
|
||||
|
||||
// allows opening of a contextmenu with options
|
||||
public static async openContextMenuWithOptions(eventArg: MouseEvent, menuItemsArg: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[] } | { divider: true })[]) {
|
||||
if (this.contextMenuDeactivated) {
|
||||
return;
|
||||
}
|
||||
eventArg.preventDefault();
|
||||
eventArg.stopPropagation();
|
||||
const contextMenu = new DeesContextmenu();
|
||||
contextMenu.style.position = 'fixed';
|
||||
contextMenu.style.zIndex = String(zIndexLayers.overlay.contextMenu);
|
||||
contextMenu.style.opacity = '0';
|
||||
contextMenu.style.transform = 'scale(0.95) translateY(-10px)';
|
||||
contextMenu.menuItems = menuItemsArg;
|
||||
contextMenu.windowLayer = await DeesWindowLayer.createAndShow();
|
||||
contextMenu.windowLayer.addEventListener('click', async (event) => {
|
||||
// Check if click is on the context menu or its submenus
|
||||
const clickedElement = event.target as HTMLElement;
|
||||
const isContextMenu = clickedElement.closest('dees-contextmenu');
|
||||
if (!isContextMenu) {
|
||||
await contextMenu.destroy();
|
||||
}
|
||||
})
|
||||
document.body.append(contextMenu);
|
||||
|
||||
// Get dimensions after adding to DOM
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
const rect = contextMenu.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// Calculate position
|
||||
let top = eventArg.clientY;
|
||||
let left = eventArg.clientX;
|
||||
|
||||
// Adjust if menu would go off right edge
|
||||
if (left + rect.width > windowWidth) {
|
||||
left = windowWidth - rect.width - 10;
|
||||
}
|
||||
|
||||
// Adjust if menu would go off bottom edge
|
||||
if (top + rect.height > windowHeight) {
|
||||
top = windowHeight - rect.height - 10;
|
||||
}
|
||||
|
||||
// Ensure menu doesn't go off left or top edge
|
||||
if (left < 10) left = 10;
|
||||
if (top < 10) top = 10;
|
||||
|
||||
contextMenu.style.top = `${top}px`;
|
||||
contextMenu.style.left = `${left}px`;
|
||||
contextMenu.style.transformOrigin = 'top left';
|
||||
|
||||
// Animate in
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
contextMenu.style.opacity = '1';
|
||||
contextMenu.style.transform = 'scale(1) translateY(0)';
|
||||
}
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: (plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean } | { divider: true })[]; divider?: never } | { divider: true })[] = [];
|
||||
windowLayer: DeesWindowLayer;
|
||||
|
||||
private submenu: DeesContextmenu | null = null;
|
||||
private submenuTimeout: any = null;
|
||||
private parentMenu: DeesContextmenu | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.tabIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* STATIC STYLES
|
||||
*/
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mainbox {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#000000')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
border-radius: 4px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
user-select: none;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.menuitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menuitem:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
|
||||
}
|
||||
|
||||
.menuitem.has-submenu::after {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
font-size: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menuitem:active:not(.has-submenu) {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
|
||||
}
|
||||
|
||||
.menuitem.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.menuitem dees-icon {
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menuitem-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.menuitem-shortcut {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
|
||||
margin: 4px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
${this.menuItems.map((menuItemArg) => {
|
||||
if ('divider' in menuItemArg && menuItemArg.divider) {
|
||||
return html`<div class="menu-divider"></div>`;
|
||||
}
|
||||
|
||||
const menuItem = menuItemArg as plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean; submenu?: any };
|
||||
const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
|
||||
return html`
|
||||
<div
|
||||
class="menuitem ${menuItem.disabled ? 'disabled' : ''} ${hasSubmenu ? 'has-submenu' : ''}"
|
||||
@click=${() => !menuItem.disabled && !hasSubmenu && this.handleClick(menuItem)}
|
||||
@mouseenter=${() => this.handleMenuItemHover(menuItem, hasSubmenu)}
|
||||
@mouseleave=${() => this.handleMenuItemLeave()}
|
||||
>
|
||||
${menuItem.iconName ? html`
|
||||
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
|
||||
` : ''}
|
||||
<span class="menuitem-text">${menuItem.name}</span>
|
||||
${menuItem.shortcut && !hasSubmenu ? html`
|
||||
<span class="menuitem-shortcut">${menuItem.shortcut}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${this.menuItems.length === 0 ? html`
|
||||
<div class="menuitem" @click=${() => {
|
||||
DeesContextmenu.contextMenuDeactivated = true;
|
||||
this.destroy();
|
||||
}}>
|
||||
<dees-icon .icon="lucide:x"></dees-icon>
|
||||
<span class="menuitem-text">Allow native context</span>
|
||||
</div>
|
||||
` : html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Focus on the menu for keyboard navigation
|
||||
this.focus();
|
||||
|
||||
// Add keyboard event listeners
|
||||
this.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
private handleKeydown = (event: KeyboardEvent) => {
|
||||
const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem:not(.disabled)'));
|
||||
const currentIndex = menuItems.findIndex(item => item.matches(':hover'));
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
const nextIndex = currentIndex + 1 < menuItems.length ? currentIndex + 1 : 0;
|
||||
(menuItems[nextIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter'));
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
const prevIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : menuItems.length - 1;
|
||||
(menuItems[prevIndex] as HTMLElement).dispatchEvent(new MouseEvent('mouseenter'));
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (currentIndex >= 0) {
|
||||
(menuItems[currentIndex] as HTMLElement).click();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
this.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async handleClick(menuItem: plugins.tsclass.website.IMenuItem & { shortcut?: string; disabled?: boolean }) {
|
||||
menuItem.action();
|
||||
|
||||
// Close all menus in the chain (this menu and all parent menus)
|
||||
await this.destroyAll();
|
||||
}
|
||||
|
||||
private async handleMenuItemHover(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }, hasSubmenu: boolean) {
|
||||
// Clear any existing timeout
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
|
||||
// Hide any existing submenu if hovering a different item
|
||||
if (this.submenu) {
|
||||
await this.hideSubmenu();
|
||||
}
|
||||
|
||||
// Show submenu if this item has one
|
||||
if (hasSubmenu && menuItem.submenu) {
|
||||
this.submenuTimeout = setTimeout(() => {
|
||||
this.showSubmenu(menuItem);
|
||||
}, 200); // Small delay to prevent accidental triggers
|
||||
}
|
||||
}
|
||||
|
||||
private handleMenuItemLeave() {
|
||||
// Add a delay before hiding to allow moving to submenu
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
}
|
||||
|
||||
this.submenuTimeout = setTimeout(() => {
|
||||
if (this.submenu && !this.submenu.matches(':hover')) {
|
||||
this.hideSubmenu();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private async showSubmenu(menuItem: plugins.tsclass.website.IMenuItem & { submenu?: any }) {
|
||||
if (!menuItem.submenu || menuItem.submenu.length === 0) return;
|
||||
|
||||
// Find the menu item element
|
||||
const menuItems = Array.from(this.shadowRoot.querySelectorAll('.menuitem'));
|
||||
const menuItemElement = menuItems.find(el => el.querySelector('.menuitem-text')?.textContent === menuItem.name) as HTMLElement;
|
||||
if (!menuItemElement) return;
|
||||
|
||||
// Create submenu
|
||||
this.submenu = new DeesContextmenu();
|
||||
this.submenu.menuItems = menuItem.submenu;
|
||||
this.submenu.parentMenu = this;
|
||||
this.submenu.style.position = 'fixed';
|
||||
this.submenu.style.zIndex = String(parseInt(this.style.zIndex) + 1);
|
||||
this.submenu.style.opacity = '0';
|
||||
this.submenu.style.transform = 'scale(0.95)';
|
||||
|
||||
// Don't create a window layer for submenus
|
||||
document.body.append(this.submenu);
|
||||
|
||||
// Position submenu
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
const itemRect = menuItemElement.getBoundingClientRect();
|
||||
const menuRect = this.getBoundingClientRect();
|
||||
const submenuRect = this.submenu.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
let left = menuRect.right - 4; // Slight overlap
|
||||
let top = itemRect.top;
|
||||
|
||||
// Check if submenu would go off right edge
|
||||
if (left + submenuRect.width > windowWidth - 10) {
|
||||
// Show on left side instead
|
||||
left = menuRect.left - submenuRect.width + 4;
|
||||
}
|
||||
|
||||
// Adjust vertical position if needed
|
||||
if (top + submenuRect.height > window.innerHeight - 10) {
|
||||
top = window.innerHeight - submenuRect.height - 10;
|
||||
}
|
||||
|
||||
this.submenu.style.left = `${left}px`;
|
||||
this.submenu.style.top = `${top}px`;
|
||||
|
||||
// Animate in
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
this.submenu.style.opacity = '1';
|
||||
this.submenu.style.transform = 'scale(1)';
|
||||
|
||||
// Handle submenu hover
|
||||
this.submenu.addEventListener('mouseenter', () => {
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.submenu.addEventListener('mouseleave', () => {
|
||||
this.handleMenuItemLeave();
|
||||
});
|
||||
}
|
||||
|
||||
private async hideSubmenu() {
|
||||
if (!this.submenu) return;
|
||||
|
||||
await this.submenu.destroy();
|
||||
this.submenu = null;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
// Clear timeout
|
||||
if (this.submenuTimeout) {
|
||||
clearTimeout(this.submenuTimeout);
|
||||
this.submenuTimeout = null;
|
||||
}
|
||||
|
||||
// Destroy submenu first
|
||||
if (this.submenu) {
|
||||
await this.submenu.destroy();
|
||||
this.submenu = null;
|
||||
}
|
||||
|
||||
// Only destroy window layer if this is not a submenu
|
||||
if (this.windowLayer && !this.parentMenu) {
|
||||
this.windowLayer.destroy();
|
||||
}
|
||||
|
||||
this.style.opacity = '0';
|
||||
this.style.transform = 'scale(0.95) translateY(-10px)';
|
||||
await domtools.plugins.smartdelay.delayFor(100);
|
||||
|
||||
if (this.parentElement) {
|
||||
this.parentElement.removeChild(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys this menu and all parent menus in the chain
|
||||
*/
|
||||
public async destroyAll() {
|
||||
// First destroy parent menus if they exist
|
||||
if (this.parentMenu) {
|
||||
await this.parentMenu.destroyAll();
|
||||
} else {
|
||||
// If we're at the top level, just destroy this menu
|
||||
await this.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeesContextmenu.initializeGlobalListener();
|
||||
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# dees-dashboardgrid
|
||||
|
||||
`<dees-dashboardgrid>` renders a configurable dashboard layout with draggable and resizable tiles. The component is now grouped in its own folder alongside supporting utilities and styles.
|
||||
|
||||
## Key Features
|
||||
|
||||
- Pointer-driven drag and resize interactions with keyboard fallbacks (arrow keys to move, `Shift` + arrows to resize).
|
||||
- Collision-aware placement that swaps compatible tiles or displaces blocking tiles into the next free slot.
|
||||
- Context menu (right-click on a tile header) that exposes destructive actions such as tile removal via `dees-contextmenu`.
|
||||
- Layout persistence helpers via `getLayout()`, `setLayout(...)`, and the `layout-change` event.
|
||||
- Responsive presets through the `layouts` map and `applyBreakpointLayout(...)` helper to hydrate per-breakpoint arrangements.
|
||||
|
||||
## Public API Highlights
|
||||
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| `widgets` | Array of tile descriptors (`DashboardWidget`). |
|
||||
| `columns` | Number of grid columns. |
|
||||
| `layouts` | Optional record of named layout definitions. |
|
||||
| `activeBreakpoint` | Name of the currently applied breakpoint layout. |
|
||||
| `editable` | Toggles drag/resize affordances. |
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `addWidget(widget, autoPosition?)` | Adds a tile, optionally auto-placing it into the next free slot. |
|
||||
| `removeWidget(id)` | Removes a tile and emits `widget-remove`. |
|
||||
| `applyBreakpointLayout(name)` | Applies a layout from the `layouts` map. |
|
||||
| `getLayout()` / `setLayout(layout)` | Retrieve or apply persisted layouts. |
|
||||
| `compact(direction?)` | Densifies the grid vertically (default) or horizontally. |
|
||||
|
||||
| Event | Detail payload |
|
||||
| --- | --- |
|
||||
| `widget-move` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-resize` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-remove` | `{ widget }` |
|
||||
| `layout-change` | `{ layout }` |
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- **Right-click** a tile header to open the contextual menu and delete the tile.
|
||||
- When resizing, blocking tiles will automatically reflow into free space once the interaction completes.
|
||||
- Listen to `layout-change` to persist layouts to storage; rehydrate using `setLayout` or the `layouts` map.
|
||||
- For responsive dashboards, populate `grid.layouts = { base: [...], mobile: [...] }` and call `applyBreakpointLayout` based on your own breakpoint logic (see the co-located demo for an example).
|
||||
|
||||
## Demo
|
||||
|
||||
The updated `dees-dashboardgrid.demo.ts` showcases live breakpoint switching, layout persistence, and the context menu. Run the demo gallery to explore the interactions end-to-end.
|
||||
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DashboardWidget } from './types.js';
|
||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
|
||||
export interface WidgetContextMenuOptions {
|
||||
widget: DashboardWidget;
|
||||
host: DeesDashboardgrid;
|
||||
event: MouseEvent;
|
||||
}
|
||||
|
||||
export const openWidgetContextMenu = ({
|
||||
widget,
|
||||
host,
|
||||
event,
|
||||
}: WidgetContextMenuOptions) => {
|
||||
const items: (plugins.tsclass.website.IMenuItem | { divider: true })[] = [
|
||||
{
|
||||
name: 'Delete tile',
|
||||
iconName: 'lucide:trash2' as any,
|
||||
action: async () => {
|
||||
host.removeWidget(widget.id);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, items as any);
|
||||
};
|
||||
405
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
405
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
|
||||
|
||||
const seedWidgets = [
|
||||
{
|
||||
id: 'metrics1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Revenue',
|
||||
icon: 'lucide:dollarSign',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
|
||||
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'metrics2',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Users',
|
||||
icon: 'lucide:users',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
|
||||
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'chart1',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
title: 'Analytics',
|
||||
icon: 'lucide:lineChart',
|
||||
content: html`
|
||||
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="text-align: center; color: #71717a;">
|
||||
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
|
||||
<div>Chart visualization area</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
grid.widgets = seedWidgets.map(widget => ({ ...widget }));
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
const baseLayout = grid.getLayout().map(item => ({ ...item }));
|
||||
const mobileLayout = grid.widgets.map((widget, index) => ({
|
||||
id: widget.id,
|
||||
x: 0,
|
||||
y: index === 0 ? 0 : grid.widgets.slice(0, index).reduce((acc, prev) => acc + prev.h, 0),
|
||||
w: grid.columns,
|
||||
h: widget.h,
|
||||
}));
|
||||
|
||||
grid.layouts = {
|
||||
base: baseLayout,
|
||||
mobile: mobileLayout,
|
||||
};
|
||||
|
||||
const statusEl = elementArg.querySelector('#dashboardLayoutStatus') as HTMLElement;
|
||||
const updateStatus = () => {
|
||||
const layout = grid.getLayout();
|
||||
statusEl.textContent = `Active breakpoint: ${grid.activeBreakpoint} • Tiles: ${layout.length}`;
|
||||
};
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const handleBreakpoint = () => {
|
||||
const target = mediaQuery.matches ? 'mobile' : 'base';
|
||||
grid.applyBreakpointLayout(target);
|
||||
updateStatus();
|
||||
};
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', handleBreakpoint);
|
||||
} else {
|
||||
(mediaQuery as MediaQueryList & {
|
||||
addListener?: (listener: (this: MediaQueryList, ev: MediaQueryListEvent) => void) => void;
|
||||
}).addListener?.(handleBreakpoint);
|
||||
}
|
||||
handleBreakpoint();
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
switch (text) {
|
||||
case 'Toggle Animation':
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
break;
|
||||
case 'Toggle Grid Lines':
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
break;
|
||||
case 'Add Widget':
|
||||
button.addEventListener('click', () => {
|
||||
const newWidget = {
|
||||
id: `widget${widgetCounter++}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
autoPosition: true,
|
||||
title: `Widget ${widgetCounter - 1}`,
|
||||
icon: 'lucide:package',
|
||||
content: html`
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<div style="color: #71717a;">New widget content</div>
|
||||
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(
|
||||
Math.random() * 1000,
|
||||
)}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
grid.addWidget(newWidget, true);
|
||||
});
|
||||
break;
|
||||
case 'Compact Grid':
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
break;
|
||||
case 'Toggle Edit Mode':
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
break;
|
||||
case 'Reset Layout':
|
||||
button.addEventListener('click', () => {
|
||||
grid.applyBreakpointLayout(grid.activeBreakpoint);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced logging for reflow events
|
||||
let lastPlaceholderPosition = null;
|
||||
let moveEventCounter = 0;
|
||||
|
||||
// Helper function to log grid state
|
||||
const logGridState = (eventName: string, details?: any) => {
|
||||
const layout = grid.getLayout();
|
||||
console.group(`🔄 ${eventName} [Event #${++moveEventCounter}]`);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
console.log('Grid Configuration:', {
|
||||
columns: grid.columns,
|
||||
cellHeight: grid.cellHeight,
|
||||
margin: grid.margin,
|
||||
editable: grid.editable,
|
||||
activeBreakpoint: grid.activeBreakpoint
|
||||
});
|
||||
console.log('Current Layout:', layout);
|
||||
console.log('Widget Count:', layout.length);
|
||||
console.log('Grid Bounds:', {
|
||||
totalWidgets: grid.widgets.length,
|
||||
maxY: Math.max(...layout.map(w => w.y + w.h)),
|
||||
occupied: layout.map(w => `${w.id}: (${w.x},${w.y}) ${w.w}x${w.h}`).join(', ')
|
||||
});
|
||||
if (details) {
|
||||
console.log('Event Details:', details);
|
||||
}
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
// Monitor placeholder position changes using MutationObserver
|
||||
const placeholderObserver = new MutationObserver(() => {
|
||||
const placeholder = grid.shadowRoot?.querySelector('.placeholder') as HTMLElement;
|
||||
if (placeholder) {
|
||||
const currentPosition = {
|
||||
left: placeholder.style.left,
|
||||
top: placeholder.style.top,
|
||||
width: placeholder.style.width,
|
||||
height: placeholder.style.height
|
||||
};
|
||||
|
||||
if (JSON.stringify(currentPosition) !== JSON.stringify(lastPlaceholderPosition)) {
|
||||
console.group('📍 Placeholder Position Changed');
|
||||
console.log('Previous:', lastPlaceholderPosition);
|
||||
console.log('Current:', currentPosition);
|
||||
|
||||
// Extract grid coordinates from style
|
||||
const gridInfo = grid.shadowRoot?.querySelector('.grid-container');
|
||||
if (gridInfo) {
|
||||
console.log('Grid Container Dimensions:', {
|
||||
width: gridInfo.clientWidth,
|
||||
height: gridInfo.clientHeight
|
||||
});
|
||||
}
|
||||
console.groupEnd();
|
||||
lastPlaceholderPosition = currentPosition;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the shadow DOM for placeholder changes
|
||||
if (grid.shadowRoot) {
|
||||
placeholderObserver.observe(grid.shadowRoot, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
}
|
||||
|
||||
// Log initial state
|
||||
logGridState('Initial Grid State');
|
||||
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
logGridState('Widget Move', {
|
||||
widget: e.detail.widget,
|
||||
displaced: e.detail.displaced,
|
||||
swappedWith: e.detail.swappedWith
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
logGridState('Widget Resize', {
|
||||
widget: e.detail.widget,
|
||||
displaced: e.detail.displaced,
|
||||
swappedWith: e.detail.swappedWith
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-remove', (e: CustomEvent) => {
|
||||
logGridState('Widget Remove', {
|
||||
removedWidget: e.detail.widget
|
||||
});
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
grid.addEventListener('layout-change', () => {
|
||||
logGridState('Layout Change');
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
// Monitor during drag/resize operations using pointer events
|
||||
grid.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
const isHeader = (e.target as HTMLElement).closest('.widget-header');
|
||||
const isResizeHandle = (e.target as HTMLElement).closest('.resize-handle');
|
||||
|
||||
if (isHeader || isResizeHandle) {
|
||||
console.group(`🎯 Interaction Started: ${isHeader ? 'Drag' : 'Resize'}`);
|
||||
console.log('Target Widget:', (e.target as HTMLElement).closest('.widget')?.getAttribute('data-widget-id'));
|
||||
console.log('Pointer Position:', { x: e.clientX, y: e.clientY });
|
||||
console.groupEnd();
|
||||
|
||||
// Track pointer move during interaction
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const widget = (e.target as HTMLElement).closest('.widget');
|
||||
if (widget) {
|
||||
console.log(`↔️ Pointer Move:`, {
|
||||
widgetId: widget.getAttribute('data-widget-id'),
|
||||
position: { x: moveEvent.clientX, y: moveEvent.clientY },
|
||||
delta: {
|
||||
x: moveEvent.clientX - e.clientX,
|
||||
y: moveEvent.clientY - e.clientY
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
console.group('🏁 Interaction Ended');
|
||||
logGridState('Final State After Interaction');
|
||||
console.groupEnd();
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
});
|
||||
|
||||
// Log when widgets are added
|
||||
const originalAddWidget = grid.addWidget.bind(grid);
|
||||
grid.addWidget = (widget: any, autoPosition?: boolean) => {
|
||||
console.group('➕ Adding Widget');
|
||||
console.log('New Widget:', widget);
|
||||
console.log('Auto Position:', autoPosition);
|
||||
const result = originalAddWidget(widget, autoPosition);
|
||||
logGridState('After Widget Added');
|
||||
console.groupEnd();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Log compact operations
|
||||
const originalCompact = grid.compact.bind(grid);
|
||||
grid.compact = (direction?: string) => {
|
||||
console.group('🗜️ Compacting Grid');
|
||||
console.log('Direction:', direction || 'vertical');
|
||||
logGridState('Before Compact');
|
||||
const result = originalCompact(direction);
|
||||
logGridState('After Compact');
|
||||
console.groupEnd();
|
||||
return result;
|
||||
};
|
||||
|
||||
updateStatus();
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-controls dees-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container-wrapper {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#dashboardLayoutStatus {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-controls">
|
||||
<dees-button-group label="Animation:">
|
||||
<dees-button>Toggle Animation</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Display:">
|
||||
<dees-button>Toggle Grid Lines</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Add Widget</dees-button>
|
||||
<dees-button>Compact Grid</dees-button>
|
||||
<dees-button>Reset Layout</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Mode:">
|
||||
<dees-button>Toggle Edit Mode</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="grid-container-wrapper">
|
||||
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div>Drag to reposition, resize from handles, or right-click a header to delete a tile.</div>
|
||||
<div id="dashboardLayoutStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
796
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
796
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import '../dees-icon.js';
|
||||
import '../dees-contextmenu.js';
|
||||
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
||||
import { dashboardGridStyles } from './styles.js';
|
||||
import {
|
||||
resolveMargins,
|
||||
calculateCellMetrics,
|
||||
calculateGridHeight,
|
||||
findAvailablePosition,
|
||||
compactLayout,
|
||||
applyLayout,
|
||||
resolveWidgetPlacement,
|
||||
type PlacementResult,
|
||||
} from './layout.js';
|
||||
import {
|
||||
computeGridCoordinates,
|
||||
computeResizeDimensions,
|
||||
type PointerPosition,
|
||||
} from './interaction.js';
|
||||
import { openWidgetContextMenu } from './contextmenu.js';
|
||||
import type {
|
||||
DashboardWidget,
|
||||
DashboardMargin,
|
||||
DashboardResolvedMargins,
|
||||
GridCellMetrics,
|
||||
DashboardLayoutItem,
|
||||
LayoutDirection,
|
||||
CellHeightUnit,
|
||||
} from './types.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dashboardgrid': DeesDashboardgrid;
|
||||
}
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
widgetId: string;
|
||||
pointerId: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
start: DashboardLayoutItem;
|
||||
previousPosition: DashboardLayoutItem;
|
||||
currentPointer: PointerPosition;
|
||||
lastPlacement: PlacementResult | null;
|
||||
};
|
||||
|
||||
type ResizeState = {
|
||||
widgetId: string;
|
||||
pointerId: number;
|
||||
handler: 'e' | 's' | 'se';
|
||||
startPointer: PointerPosition;
|
||||
start: DashboardLayoutItem;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
lastPlacement: PlacementResult | null;
|
||||
};
|
||||
|
||||
@customElement('dees-dashboardgrid')
|
||||
export class DeesDashboardgrid extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
public static styles = dashboardGridStyles;
|
||||
|
||||
@property({ type: Array })
|
||||
public widgets: DashboardWidget[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public cellHeight: number = 80;
|
||||
|
||||
@property({ type: Object })
|
||||
public margin: DashboardMargin = 10;
|
||||
|
||||
@property({ type: Number })
|
||||
public columns: number = 12;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public editable: boolean = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public enableAnimation: boolean = true;
|
||||
|
||||
@property({ type: String })
|
||||
public cellHeightUnit: CellHeightUnit = 'px';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rtl: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showGridLines: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public layouts?: Record<string, DashboardLayoutItem[]>;
|
||||
|
||||
@property({ type: String })
|
||||
public activeBreakpoint: string = 'base';
|
||||
|
||||
@state()
|
||||
private placeholderPosition: DashboardLayoutItem | null = null;
|
||||
|
||||
@state()
|
||||
private metrics: GridCellMetrics | null = null;
|
||||
|
||||
@state()
|
||||
private resolvedMargins: DashboardResolvedMargins | null = null;
|
||||
|
||||
@state()
|
||||
private previewWidgets: DashboardWidget[] | null = null;
|
||||
|
||||
private containerBounds: DOMRect | null = null;
|
||||
private dragState: DragState | null = null;
|
||||
private resizeState: ResizeState | null = null;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private interactionActive = false;
|
||||
|
||||
public override async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.computeMetrics();
|
||||
this.observeResize();
|
||||
}
|
||||
|
||||
public override async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.disconnectResizeObserver();
|
||||
this.releasePointerEvents();
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (
|
||||
changed.has('margin') ||
|
||||
changed.has('columns') ||
|
||||
changed.has('cellHeight') ||
|
||||
changed.has('cellHeightUnit')
|
||||
) {
|
||||
this.computeMetrics();
|
||||
}
|
||||
|
||||
if (changed.has('widgets') && !this.interactionActive) {
|
||||
this.notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const baseWidgets = this.widgets;
|
||||
if (baseWidgets.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
|
||||
<div>No widgets configured</div>
|
||||
<div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metrics = this.ensureMetrics();
|
||||
const margins = this.resolvedMargins ?? resolveMargins(this.margin);
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
const layoutForHeight = this.previewWidgets ?? this.widgets;
|
||||
const gridHeight = calculateGridHeight(layoutForHeight, margins, cellHeight);
|
||||
const previewMap = this.previewWidgets ? new Map(this.previewWidgets.map(widget => [widget.id, widget])) : null;
|
||||
|
||||
return html`
|
||||
<div class="grid-container" style="height: ${gridHeight}px;">
|
||||
${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
|
||||
${baseWidgets.map(widget => this.renderWidget(widget, metrics, margins, previewMap))}
|
||||
${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGridLines(metrics: GridCellMetrics, gridHeight: number): TemplateResult {
|
||||
const vertical: TemplateResult[] = [];
|
||||
const horizontal: TemplateResult[] = [];
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
for (let i = 0; i <= this.columns; i++) {
|
||||
const leftPx = i * cellPlusMarginX + metrics.marginHorizontalPx;
|
||||
const leftPercent = this.pxToPercent(leftPx, metrics.containerWidth);
|
||||
vertical.push(html`<div class="grid-line-vertical" style="left: ${leftPercent}%;"></div>`);
|
||||
}
|
||||
|
||||
const rows = Math.ceil(gridHeight / cellPlusMarginY);
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
const top = row * cellPlusMarginY;
|
||||
horizontal.push(html`<div class="grid-line-horizontal" style="top: ${top}px;"></div>`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="grid-lines">
|
||||
${vertical}
|
||||
${horizontal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWidget(
|
||||
widget: DashboardWidget,
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
previewMap: Map<string, DashboardWidget> | null,
|
||||
): TemplateResult {
|
||||
const isDragging = this.dragState?.widgetId === widget.id;
|
||||
const isResizing = this.resizeState?.widgetId === widget.id;
|
||||
const isLocked = widget.locked || !this.editable;
|
||||
const previewWidget = previewMap?.get(widget.id) ?? null;
|
||||
const layoutForRender = isDragging ? widget : previewWidget ?? widget;
|
||||
const rect = this.computeWidgetRect(layoutForRender, metrics, margins);
|
||||
|
||||
const sideProperty = this.rtl ? 'right' : 'left';
|
||||
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
||||
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
||||
|
||||
let transform = '';
|
||||
if (isDragging && this.dragState?.currentPointer) {
|
||||
const pointer = this.dragState.currentPointer;
|
||||
const bounds = this.containerBounds ?? this.getBoundingClientRect();
|
||||
const translateX = pointer.clientX - bounds.left - this.dragState.offsetX - rect.left;
|
||||
const translateY = pointer.clientY - bounds.top - this.dragState.offsetY - rect.top;
|
||||
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
||||
style="
|
||||
${sideProperty}: ${sideValue}%;
|
||||
top: ${rect.top}px;
|
||||
width: ${widthPercent}%;
|
||||
height: ${rect.height}px;
|
||||
${transform}
|
||||
"
|
||||
data-widget-id=${widget.id}
|
||||
>
|
||||
<div class="widget-content">
|
||||
${widget.title
|
||||
? html`
|
||||
<div
|
||||
class="widget-header ${isLocked ? 'locked' : ''}"
|
||||
@pointerdown=${!isLocked && !widget.noMove
|
||||
? (evt: PointerEvent) => this.startDrag(evt, widget)
|
||||
: null}
|
||||
@contextmenu=${(evt: MouseEvent) => this.handleWidgetContextMenu(evt, widget)}
|
||||
tabindex=${!isLocked && !widget.noMove ? 0 : -1}
|
||||
@keydown=${(evt: KeyboardEvent) => this.handleHeaderKeydown(evt, widget)}
|
||||
>
|
||||
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : null}
|
||||
${widget.title}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
||||
${widget.content}
|
||||
</div>
|
||||
${!isLocked && !widget.noResize
|
||||
? html`
|
||||
<div
|
||||
class="resize-handle resize-handle-e"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'e')}
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle resize-handle-s"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 's')}
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle resize-handle-se"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'se')}
|
||||
></div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
): TemplateResult {
|
||||
if (!this.placeholderPosition) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const rect = this.computeWidgetRect(this.placeholderPosition, metrics, margins);
|
||||
const sideProperty = this.rtl ? 'right' : 'left';
|
||||
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
||||
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget placeholder"
|
||||
style="
|
||||
${sideProperty}: ${sideValue}%;
|
||||
top: ${rect.top}px;
|
||||
width: ${widthPercent}%;
|
||||
height: ${rect.height}px;
|
||||
"
|
||||
>
|
||||
<div class="widget-content"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startDrag(event: PointerEvent, widget: DashboardWidget): void {
|
||||
if (!this.editable || widget.noMove || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const widgetElement = (event.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement | null;
|
||||
if (!widgetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetRect = widgetElement.getBoundingClientRect();
|
||||
this.containerBounds = this.getBoundingClientRect();
|
||||
this.ensureMetrics();
|
||||
|
||||
this.dragState = {
|
||||
widgetId: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - widgetRect.left,
|
||||
offsetY: event.clientY - widgetRect.top,
|
||||
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
previousPosition: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
lastPlacement: null,
|
||||
};
|
||||
|
||||
this.interactionActive = true;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
document.addEventListener('pointermove', this.handleDragMove);
|
||||
document.addEventListener('pointerup', this.handleDragEnd);
|
||||
|
||||
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
|
||||
private handleDragMove = (event: PointerEvent): void => {
|
||||
if (!this.dragState) return;
|
||||
const metrics = this.ensureMetrics();
|
||||
const activeWidgets = this.widgets;
|
||||
const widget = activeWidgets.find(item => item.id === this.dragState!.widgetId);
|
||||
if (!widget) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const previousPosition = this.dragState.previousPosition;
|
||||
|
||||
const coords = computeGridCoordinates({
|
||||
pointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
||||
metrics,
|
||||
columns: this.columns,
|
||||
widget,
|
||||
rtl: this.rtl,
|
||||
dragOffsetX: this.dragState.offsetX,
|
||||
dragOffsetY: this.dragState.offsetY,
|
||||
});
|
||||
|
||||
const placement = resolveWidgetPlacement(
|
||||
activeWidgets,
|
||||
widget.id,
|
||||
{ x: coords.x, y: coords.y },
|
||||
this.columns,
|
||||
previousPosition,
|
||||
);
|
||||
if (placement) {
|
||||
const updatedWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
this.dragState = {
|
||||
...this.dragState,
|
||||
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
lastPlacement: placement,
|
||||
previousPosition: updatedWidget
|
||||
? { id: updatedWidget.id, x: updatedWidget.x, y: updatedWidget.y, w: updatedWidget.w, h: updatedWidget.h }
|
||||
: { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h },
|
||||
};
|
||||
this.previewWidgets = placement.widgets;
|
||||
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
if (previewWidget) {
|
||||
this.placeholderPosition = {
|
||||
id: previewWidget.id,
|
||||
x: previewWidget.x,
|
||||
y: previewWidget.y,
|
||||
w: previewWidget.w,
|
||||
h: previewWidget.h,
|
||||
};
|
||||
} else {
|
||||
this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
} else {
|
||||
this.previewWidgets = null;
|
||||
this.placeholderPosition = null;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleDragEnd = (event: PointerEvent): void => {
|
||||
const dragState = this.dragState;
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutSource = this.widgets;
|
||||
this.previewWidgets = null;
|
||||
|
||||
// Always validate the final position, don't rely on lastPlacement from drag
|
||||
const target = this.placeholderPosition ?? dragState.start;
|
||||
const placement = resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
dragState.widgetId,
|
||||
{ x: target.x, y: target.y },
|
||||
this.columns,
|
||||
dragState.previousPosition,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
// Verify that the placement doesn't result in overlapping widgets
|
||||
const finalWidget = placement.widgets.find(w => w.id === dragState.widgetId);
|
||||
if (finalWidget) {
|
||||
const hasOverlap = placement.widgets.some(w => {
|
||||
if (w.id === dragState.widgetId) return false;
|
||||
return (
|
||||
finalWidget.x < w.x + w.w &&
|
||||
finalWidget.x + finalWidget.w > w.x &&
|
||||
finalWidget.y < w.y + w.h &&
|
||||
finalWidget.y + finalWidget.h > w.y
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
this.commitPlacement(placement, dragState.widgetId, 'widget-move');
|
||||
} else {
|
||||
// Return to start position if overlap detected
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Return to start position if no valid placement
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
||||
);
|
||||
}
|
||||
|
||||
this.placeholderPosition = null;
|
||||
this.dragState = null;
|
||||
this.interactionActive = false;
|
||||
this.releasePointerEvents();
|
||||
};
|
||||
|
||||
private startResize(event: PointerEvent, widget: DashboardWidget, handler: 'e' | 's' | 'se'): void {
|
||||
if (!this.editable || widget.noResize || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.ensureMetrics();
|
||||
|
||||
this.resizeState = {
|
||||
widgetId: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
handler,
|
||||
startPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
startWidth: widget.w,
|
||||
startHeight: widget.h,
|
||||
lastPlacement: null,
|
||||
};
|
||||
|
||||
this.interactionActive = true;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
document.addEventListener('pointermove', this.handleResizeMove);
|
||||
document.addEventListener('pointerup', this.handleResizeEnd);
|
||||
|
||||
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
|
||||
private handleResizeMove = (event: PointerEvent): void => {
|
||||
if (!this.resizeState) return;
|
||||
const metrics = this.ensureMetrics();
|
||||
const activeWidgets = this.widgets;
|
||||
const widget = activeWidgets.find(item => item.id === this.resizeState!.widgetId);
|
||||
if (!widget) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const nextSize = computeResizeDimensions({
|
||||
pointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
||||
metrics,
|
||||
startWidth: this.resizeState.startWidth,
|
||||
startHeight: this.resizeState.startHeight,
|
||||
startPointer: this.resizeState.startPointer,
|
||||
handler: this.resizeState.handler,
|
||||
widget,
|
||||
columns: this.columns,
|
||||
});
|
||||
|
||||
const placement = resolveWidgetPlacement(
|
||||
activeWidgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height },
|
||||
this.columns,
|
||||
this.resizeState.start,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.resizeState = { ...this.resizeState, lastPlacement: placement };
|
||||
this.previewWidgets = placement.widgets;
|
||||
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
if (previewWidget) {
|
||||
this.placeholderPosition = {
|
||||
id: previewWidget.id,
|
||||
x: previewWidget.x,
|
||||
y: previewWidget.y,
|
||||
w: previewWidget.w,
|
||||
h: previewWidget.h,
|
||||
};
|
||||
} else {
|
||||
this.placeholderPosition = {
|
||||
id: widget.id,
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: nextSize.width,
|
||||
h: nextSize.height,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this.previewWidgets = null;
|
||||
this.placeholderPosition = null;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleResizeEnd = (event: PointerEvent): void => {
|
||||
const resizeState = this.resizeState;
|
||||
if (!resizeState || event.pointerId !== resizeState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutSource = this.widgets;
|
||||
this.previewWidgets = null;
|
||||
const placement =
|
||||
resizeState.lastPlacement ??
|
||||
resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
resizeState.widgetId,
|
||||
{
|
||||
x: this.placeholderPosition?.x ?? resizeState.start.x,
|
||||
y: this.placeholderPosition?.y ?? resizeState.start.y,
|
||||
w: this.placeholderPosition?.w ?? resizeState.start.w,
|
||||
h: this.placeholderPosition?.h ?? resizeState.start.h,
|
||||
},
|
||||
this.columns,
|
||||
resizeState.start,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, resizeState.widgetId, 'widget-resize');
|
||||
} else {
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === resizeState.widgetId ? { ...widget, w: resizeState.start.w, h: resizeState.start.h } : widget,
|
||||
);
|
||||
}
|
||||
|
||||
this.placeholderPosition = null;
|
||||
this.resizeState = null;
|
||||
this.interactionActive = false;
|
||||
this.releasePointerEvents();
|
||||
};
|
||||
|
||||
private handleHeaderKeydown(event: KeyboardEvent, widget: DashboardWidget): void {
|
||||
if (!this.editable || widget.noMove || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
const isResize = event.shiftKey;
|
||||
let placement: PlacementResult | null = null;
|
||||
|
||||
if (isResize && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) {
|
||||
event.preventDefault();
|
||||
const delta = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
||||
const maxWidth = widget.maxW ?? this.columns - widget.x;
|
||||
const nextWidth = Math.max(widget.minW ?? 1, Math.min(maxWidth, widget.w + delta));
|
||||
placement = resolveWidgetPlacement(
|
||||
this.widgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: nextWidth, h: widget.h },
|
||||
this.columns,
|
||||
);
|
||||
} else {
|
||||
const maxHeight = widget.maxH ?? Number.POSITIVE_INFINITY;
|
||||
const nextHeight = Math.max(widget.minH ?? 1, Math.min(maxHeight, widget.h + delta));
|
||||
placement = resolveWidgetPlacement(
|
||||
this.widgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: widget.w, h: nextHeight },
|
||||
this.columns,
|
||||
);
|
||||
}
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, widget.id, 'widget-resize');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const moveMap: Record<string, { dx: number; dy: number }> = {
|
||||
ArrowLeft: { dx: -1, dy: 0 },
|
||||
ArrowRight: { dx: 1, dy: 0 },
|
||||
ArrowUp: { dx: 0, dy: -1 },
|
||||
ArrowDown: { dx: 0, dy: 1 },
|
||||
};
|
||||
|
||||
const delta = moveMap[key];
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const targetX = Math.max(0, Math.min(this.columns - widget.w, widget.x + delta.dx));
|
||||
const targetY = Math.max(0, widget.y + delta.dy);
|
||||
|
||||
placement = resolveWidgetPlacement(this.widgets, widget.id, { x: targetX, y: targetY }, this.columns);
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, widget.id, 'widget-move');
|
||||
}
|
||||
}
|
||||
|
||||
private handleWidgetContextMenu(event: MouseEvent, widget: DashboardWidget): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openWidgetContextMenu({ widget, host: this, event });
|
||||
}
|
||||
|
||||
private commitPlacement(result: PlacementResult, widgetId: string, type: 'widget-move' | 'widget-resize'): void {
|
||||
this.previewWidgets = null;
|
||||
this.widgets = result.widgets;
|
||||
const subject = this.widgets.find(item => item.id === widgetId);
|
||||
if (subject) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(type, {
|
||||
detail: {
|
||||
widget: subject,
|
||||
displaced: result.movedWidgets.filter(id => id !== widgetId),
|
||||
swappedWith: result.swappedWith,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public removeWidget(widgetId: string): void {
|
||||
const target = this.widgets.find(widget => widget.id === widgetId);
|
||||
if (!target) return;
|
||||
this.widgets = this.widgets.filter(widget => widget.id !== widgetId);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('widget-remove', {
|
||||
detail: { widget: target },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public updateWidget(widgetId: string, updates: Partial<DashboardWidget>): void {
|
||||
this.widgets = this.widgets.map(widget => (widget.id === widgetId ? { ...widget, ...updates } : widget));
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutItem[] {
|
||||
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
||||
}
|
||||
|
||||
public setLayout(layout: DashboardLayoutItem[]): void {
|
||||
this.widgets = applyLayout(this.widgets, layout);
|
||||
}
|
||||
|
||||
public lockGrid(): void {
|
||||
this.editable = false;
|
||||
}
|
||||
|
||||
public unlockGrid(): void {
|
||||
this.editable = true;
|
||||
}
|
||||
|
||||
public addWidget(widget: DashboardWidget, autoPosition = false): void {
|
||||
const nextWidget = { ...widget };
|
||||
if (autoPosition || nextWidget.autoPosition) {
|
||||
const position = findAvailablePosition(this.widgets, nextWidget.w, nextWidget.h, this.columns);
|
||||
nextWidget.x = position.x;
|
||||
nextWidget.y = position.y;
|
||||
}
|
||||
|
||||
this.widgets = [...this.widgets, nextWidget];
|
||||
}
|
||||
|
||||
public compact(direction: LayoutDirection = 'vertical'): void {
|
||||
const nextWidgets = this.widgets.map(widget => ({ ...widget }));
|
||||
compactLayout(nextWidgets, direction);
|
||||
this.widgets = nextWidgets;
|
||||
}
|
||||
|
||||
public applyBreakpointLayout(breakpoint: string): void {
|
||||
this.activeBreakpoint = breakpoint;
|
||||
const layout = this.layouts?.[breakpoint];
|
||||
if (layout) {
|
||||
this.setLayout(layout);
|
||||
}
|
||||
}
|
||||
|
||||
public notifyLayoutChange(): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('layout-change', {
|
||||
detail: { layout: this.getLayout() },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private ensureMetrics(): GridCellMetrics {
|
||||
if (!this.metrics) {
|
||||
this.computeMetrics();
|
||||
}
|
||||
return this.metrics!;
|
||||
}
|
||||
|
||||
private computeMetrics(): void {
|
||||
if (!this.isConnected) return;
|
||||
const bounds = this.getBoundingClientRect();
|
||||
this.containerBounds = bounds;
|
||||
const margins = resolveMargins(this.margin);
|
||||
this.resolvedMargins = margins;
|
||||
this.metrics = calculateCellMetrics(bounds.width, this.columns, margins, this.cellHeight, this.cellHeightUnit);
|
||||
}
|
||||
|
||||
private observeResize(): void {
|
||||
if (this.resizeObserver) return;
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.computeMetrics();
|
||||
});
|
||||
this.resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
private disconnectResizeObserver(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = undefined;
|
||||
}
|
||||
|
||||
private releasePointerEvents(): void {
|
||||
document.removeEventListener('pointermove', this.handleDragMove);
|
||||
document.removeEventListener('pointerup', this.handleDragEnd);
|
||||
document.removeEventListener('pointermove', this.handleResizeMove);
|
||||
document.removeEventListener('pointerup', this.handleResizeEnd);
|
||||
}
|
||||
|
||||
private pxToPercent(value: number, container: number): number {
|
||||
if (!container) return 0;
|
||||
return Number(((value / container) * 100).toFixed(4));
|
||||
}
|
||||
|
||||
private computeWidgetRect(
|
||||
widget: Pick<DashboardWidget, 'x' | 'y' | 'w' | 'h'>,
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
) {
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
const left = widget.x * (cellWidth + margins.horizontal) + margins.horizontal;
|
||||
const top = widget.y * (cellHeight + margins.vertical) + margins.vertical;
|
||||
const width = widget.w * cellWidth + Math.max(0, widget.w - 1) * margins.horizontal;
|
||||
const height = widget.h * cellHeight + Math.max(0, widget.h - 1) * margins.vertical;
|
||||
|
||||
return { left, top, width, height };
|
||||
}
|
||||
}
|
||||
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './types.js';
|
||||
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { DashboardWidget, GridCellMetrics } from './types.js';
|
||||
|
||||
export interface PointerPosition {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface DragComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
columns: number;
|
||||
widget: DashboardWidget;
|
||||
rtl: boolean;
|
||||
dragOffsetX?: number;
|
||||
dragOffsetY?: number;
|
||||
}
|
||||
|
||||
export const computeGridCoordinates = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
columns,
|
||||
widget,
|
||||
rtl,
|
||||
dragOffsetX = 0,
|
||||
dragOffsetY = 0,
|
||||
}: DragComputationArgs): { x: number; y: number } => {
|
||||
const relativeX = pointer.clientX - containerRect.left - dragOffsetX;
|
||||
const relativeY = pointer.clientY - containerRect.top - dragOffsetY;
|
||||
|
||||
const marginX = metrics.marginHorizontalPx;
|
||||
const marginY = metrics.marginVerticalPx;
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const adjustedX = clamp(relativeX - marginX, 0, containerRect.width - marginX);
|
||||
const adjustedY = clamp(relativeY - marginY, 0, Number.POSITIVE_INFINITY);
|
||||
|
||||
const cellPlusMarginX = cellWidth + marginX;
|
||||
const cellPlusMarginY = cellHeight + marginY;
|
||||
|
||||
let gridX = Math.round(adjustedX / cellPlusMarginX);
|
||||
if (rtl) {
|
||||
gridX = columns - widget.w - gridX;
|
||||
}
|
||||
gridX = clamp(gridX, 0, columns - widget.w);
|
||||
|
||||
const gridY = clamp(Math.round(adjustedY / cellPlusMarginY), 0, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
return { x: gridX, y: gridY };
|
||||
};
|
||||
|
||||
export interface ResizeComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
startPointer: PointerPosition;
|
||||
handler: 'e' | 's' | 'se';
|
||||
widget: DashboardWidget;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
export const computeResizeDimensions = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
startWidth,
|
||||
startHeight,
|
||||
startPointer,
|
||||
handler,
|
||||
widget,
|
||||
columns,
|
||||
}: ResizeComputationArgs): { width: number; height: number } => {
|
||||
const deltaX = pointer.clientX - startPointer.clientX;
|
||||
const deltaY = pointer.clientY - startPointer.clientY;
|
||||
|
||||
let width = startWidth;
|
||||
let height = startHeight;
|
||||
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
if (handler.includes('e')) {
|
||||
const deltaCols = Math.round(deltaX / cellPlusMarginX);
|
||||
width = startWidth + deltaCols;
|
||||
}
|
||||
|
||||
if (handler.includes('s')) {
|
||||
const deltaRows = Math.round(deltaY / cellPlusMarginY);
|
||||
height = startHeight + deltaRows;
|
||||
}
|
||||
|
||||
const clampedWidth = Math.max(widget.minW || 1, Math.min(width, widget.maxW || columns - widget.x));
|
||||
const clampedHeight = Math.max(widget.minH || 1, Math.min(height, widget.maxH || Number.MAX_SAFE_INTEGER));
|
||||
|
||||
return {
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
};
|
||||
};
|
||||
246
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
246
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
DashboardResolvedMargins,
|
||||
DashboardMargin,
|
||||
DashboardWidget,
|
||||
DashboardLayoutItem,
|
||||
GridCellMetrics,
|
||||
LayoutDirection,
|
||||
} from './types.js';
|
||||
|
||||
export const DEFAULT_MARGIN = 10;
|
||||
|
||||
export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => {
|
||||
if (typeof margin === 'number') {
|
||||
return {
|
||||
horizontal: margin,
|
||||
vertical: margin,
|
||||
top: margin,
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
left: margin,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
top: margin.top ?? DEFAULT_MARGIN,
|
||||
right: margin.right ?? DEFAULT_MARGIN,
|
||||
bottom: margin.bottom ?? DEFAULT_MARGIN,
|
||||
left: margin.left ?? DEFAULT_MARGIN,
|
||||
};
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
horizontal: (resolved.left + resolved.right) / 2,
|
||||
vertical: (resolved.top + resolved.bottom) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateCellMetrics = (
|
||||
containerWidth: number,
|
||||
columns: number,
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
cellHeightUnit: string,
|
||||
): GridCellMetrics => {
|
||||
const totalMarginWidth = margins.horizontal * (columns + 1);
|
||||
const availableWidth = Math.max(containerWidth - totalMarginWidth, 0);
|
||||
const cellWidthPx = columns > 0 ? availableWidth / columns : 0;
|
||||
const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight;
|
||||
|
||||
return {
|
||||
containerWidth,
|
||||
cellWidthPx,
|
||||
marginHorizontalPx: margins.horizontal,
|
||||
cellHeightPx,
|
||||
marginVerticalPx: margins.vertical,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateGridHeight = (
|
||||
widgets: DashboardWidget[],
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
): number => {
|
||||
if (widgets.length === 0) return 0;
|
||||
const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0);
|
||||
return maxY * cellHeight + (maxY + 1) * margins.vertical;
|
||||
};
|
||||
|
||||
const overlaps = (
|
||||
widget: DashboardWidget,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y;
|
||||
|
||||
export const collectCollisions = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
nextW: number = target.w,
|
||||
nextH: number = target.h,
|
||||
): DashboardWidget[] => {
|
||||
return widgets.filter(widget => {
|
||||
if (widget.id === target.id) return false;
|
||||
return overlaps(widget, nextX, nextY, nextW, nextH);
|
||||
});
|
||||
};
|
||||
|
||||
export const checkCollision = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0;
|
||||
|
||||
export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget });
|
||||
|
||||
export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget);
|
||||
|
||||
export const findAvailablePosition = (
|
||||
widgets: DashboardWidget[],
|
||||
width: number,
|
||||
height: number,
|
||||
columns: number,
|
||||
): { x: number; y: number } => {
|
||||
for (let y = 0; y < 200; y++) {
|
||||
for (let x = 0; x <= columns - width; x++) {
|
||||
const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height));
|
||||
if (isFree) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
};
|
||||
|
||||
export interface PlacementResult {
|
||||
widgets: DashboardWidget[];
|
||||
movedWidgets: string[];
|
||||
swappedWith?: string;
|
||||
}
|
||||
|
||||
export const resolveWidgetPlacement = (
|
||||
widgets: DashboardWidget[],
|
||||
widgetId: string,
|
||||
next: { x: number; y: number; w?: number; h?: number },
|
||||
columns: number,
|
||||
previousPosition?: DashboardLayoutItem,
|
||||
): PlacementResult | null => {
|
||||
const sourceWidgets = cloneWidgets(widgets);
|
||||
const moving = sourceWidgets.find(widget => widget.id === widgetId);
|
||||
const original = widgets.find(widget => widget.id === widgetId);
|
||||
if (!moving || !original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
w: next.w ?? moving.w,
|
||||
h: next.h ?? moving.h,
|
||||
};
|
||||
|
||||
moving.x = target.x;
|
||||
moving.y = target.y;
|
||||
moving.w = target.w;
|
||||
moving.h = target.h;
|
||||
|
||||
const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h);
|
||||
|
||||
if (collisions.length === 0) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id] };
|
||||
}
|
||||
|
||||
if (collisions.length === 1) {
|
||||
const other = collisions[0];
|
||||
if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) {
|
||||
const otherClone = sourceWidgets.find(widget => widget.id === other.id);
|
||||
if (otherClone) {
|
||||
// Use the original position of the moving widget for a clean swap
|
||||
// This prevents the "snapping together" issue where both widgets end up at the same position
|
||||
const swapTarget = original;
|
||||
const previousOtherPosition = { x: otherClone.x, y: otherClone.y };
|
||||
otherClone.x = swapTarget.x;
|
||||
otherClone.y = swapTarget.y;
|
||||
|
||||
const swapValid =
|
||||
collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h).length === 0 &&
|
||||
collectCollisions(sourceWidgets, otherClone, otherClone.x, otherClone.y, otherClone.w, otherClone.h).length === 0;
|
||||
|
||||
if (swapValid) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id };
|
||||
}
|
||||
|
||||
otherClone.x = previousOtherPosition.x;
|
||||
otherClone.y = previousOtherPosition.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attempt displacement cascade
|
||||
const movedIds = new Set<string>([moving.id]);
|
||||
for (const offending of collisions) {
|
||||
if (offending.locked || offending.noMove) {
|
||||
return null;
|
||||
}
|
||||
const clone = sourceWidgets.find(widget => widget.id === offending.id);
|
||||
if (!clone) continue;
|
||||
const remaining = sourceWidgets.filter(widget => widget.id !== offending.id);
|
||||
const position = findAvailablePosition(remaining, clone.w, clone.h, columns);
|
||||
clone.x = position.x;
|
||||
clone.y = position.y;
|
||||
movedIds.add(clone.id);
|
||||
}
|
||||
|
||||
// verify no overlaps remain
|
||||
const verify = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h);
|
||||
if (verify.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { widgets: sourceWidgets, movedWidgets: Array.from(movedIds) };
|
||||
};
|
||||
|
||||
export const compactLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
direction: LayoutDirection = 'vertical',
|
||||
) => {
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
}
|
||||
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
for (const widget of sorted) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
while (widget.y > 0 && !checkCollision(widgets, widget, widget.x, widget.y - 1)) {
|
||||
widget.y -= 1;
|
||||
}
|
||||
} else {
|
||||
while (widget.x > 0 && !checkCollision(widgets, widget, widget.x - 1, widget.y)) {
|
||||
widget.x -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const applyLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
layout: DashboardLayoutItem[],
|
||||
): DashboardWidget[] => {
|
||||
return widgets.map(widget => {
|
||||
const layoutItem = layout.find(item => item.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
};
|
||||
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const dashboardGridStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-widget {
|
||||
position: absolute;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
:host([enableanimation]) .grid-widget {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.grid-widget.dragging {
|
||||
z-index: 1000;
|
||||
transition: none !important;
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder .widget-content {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid-widget.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'0 1px 3px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-widget:hover .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
}
|
||||
|
||||
.grid-widget.dragging .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 16px 48px rgba(0, 0, 0, 0.25)',
|
||||
'0 16px 48px rgba(0, 0, 0, 0.6)'
|
||||
)};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-header:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget-header.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-header.locked:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.widget-header dees-icon {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.widget-body.has-header {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.resize-handle-e {
|
||||
cursor: ew-resize;
|
||||
width: 12px;
|
||||
right: -6px;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.resize-handle-s {
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 80%;
|
||||
bottom: -6px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.resize-handle-se {
|
||||
cursor: se-resize;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.grid-widget:hover .resize-handle-se {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover::after {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.grid-placeholder {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.1;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-line-vertical {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.grid-line-horizontal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
];
|
||||
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { TemplateResult } from '@design.estate/dees-element';
|
||||
|
||||
export type CellHeightUnit = 'px' | 'em' | 'rem' | 'auto';
|
||||
|
||||
export interface DashboardMarginObject {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export type DashboardMargin = number | DashboardMarginObject;
|
||||
|
||||
export interface DashboardResolvedMargins {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface DashboardWidget extends DashboardLayoutItem {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean;
|
||||
}
|
||||
|
||||
export type LayoutDirection = 'vertical' | 'horizontal';
|
||||
|
||||
export interface GridCellMetrics {
|
||||
containerWidth: number;
|
||||
cellWidthPx: number;
|
||||
marginHorizontalPx: number;
|
||||
cellHeightPx: number;
|
||||
marginVerticalPx: number;
|
||||
}
|
||||
199
ts_web/elements/dees-dataview-codebox.demo.ts
Normal file
199
ts_web/elements/dees-dataview-codebox.demo.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { html, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
.demoWrapper {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 48px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
<div class="demoWrapper">
|
||||
<div class="section">
|
||||
<div class="section-title">TypeScript Code Example</div>
|
||||
<div class="section-description">A comprehensive TypeScript code example with various syntax highlighting.</div>
|
||||
<dees-dataview-codebox proglang="typescript">
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
class UserService {
|
||||
private users: User[] = [];
|
||||
|
||||
constructor(private apiUrl: string) {
|
||||
console.log('UserService initialized');
|
||||
}
|
||||
|
||||
async getUsers(): Promise<User[]> {
|
||||
try {
|
||||
const response = await fetch(this.apiUrl);
|
||||
const data = await response.json();
|
||||
return data.users;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
addUser(user: User): void {
|
||||
this.users.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example
|
||||
const service = new UserService('https://api.example.com/users');
|
||||
const users = await service.getUsers();
|
||||
console.log('Found users:', users.length);
|
||||
</dees-dataview-codebox>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">JavaScript Example</div>
|
||||
<div class="section-description">Modern JavaScript with ES6+ features.</div>
|
||||
<dees-dataview-codebox proglang="javascript">
|
||||
// Array manipulation examples
|
||||
const numbers = [1, 2, 3, 4, 5];
|
||||
const doubled = numbers.map(n => n * 2);
|
||||
const filtered = numbers.filter(n => n > 3);
|
||||
|
||||
// Object destructuring
|
||||
const user = { name: 'John', age: 30, city: 'New York' };
|
||||
const { name, age } = user;
|
||||
|
||||
// Promise handling
|
||||
const fetchData = async (url) => {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Modern syntax
|
||||
const greet = (name = 'World') => \`Hello, \${name}!\`;
|
||||
console.log(greet('ShadCN'));
|
||||
</dees-dataview-codebox>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Python Example</div>
|
||||
<div class="section-description">Python code with classes and type hints.</div>
|
||||
<dees-dataview-codebox proglang="python">
|
||||
from typing import List, Optional
|
||||
import asyncio
|
||||
|
||||
class DataProcessor:
|
||||
"""A simple data processor class"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.data: List[dict] = []
|
||||
|
||||
async def process_data(self, items: List[dict]) -> List[dict]:
|
||||
"""Process data items asynchronously"""
|
||||
results = []
|
||||
for item in items:
|
||||
# Simulate async processing
|
||||
await asyncio.sleep(0.1)
|
||||
results.append({
|
||||
'id': item.get('id'),
|
||||
'processed': True,
|
||||
'processor': self.name
|
||||
})
|
||||
return results
|
||||
|
||||
def get_summary(self) -> dict:
|
||||
return {
|
||||
'processor': self.name,
|
||||
'items_processed': len(self.data)
|
||||
}
|
||||
|
||||
# Usage
|
||||
processor = DataProcessor("Main")
|
||||
data = await processor.process_data([{'id': 1}, {'id': 2}])
|
||||
</dees-dataview-codebox>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">CSS Example</div>
|
||||
<div class="section-description">Modern CSS with custom properties and animations. Note the shorter language label.</div>
|
||||
<dees-dataview-codebox proglang="css">
|
||||
/* Modern CSS with custom properties */
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
--secondary-color: #10b981;
|
||||
--background: #ffffff;
|
||||
--text-color: #09090b;
|
||||
--border-radius: 6px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--background);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</dees-dataview-codebox>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">JSON Example</div>
|
||||
<div class="section-description">JSON configuration with proper formatting.</div>
|
||||
<dees-dataview-codebox proglang="json">
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.10.7",
|
||||
"description": "A comprehensive catalog of web components",
|
||||
"main": "dist_ts_web/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
||||
"watch": "tswatch element",
|
||||
"test": "tstest test/ --web --verbose"
|
||||
},
|
||||
"dependencies": {
|
||||
"@design.estate/dees-element": "^2.0.45",
|
||||
"highlight.js": "^11.9.0"
|
||||
}
|
||||
}
|
||||
</dees-dataview-codebox>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
261
ts_web/elements/dees-dataview-codebox.ts
Normal file
261
ts_web/elements/dees-dataview-codebox.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { demoFunc } from './dees-dataview-codebox.demo.js';
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { cssGeistFontFamily, cssMonoFontFamily } from './00fonts.js';
|
||||
|
||||
import hlight from 'highlight.js';
|
||||
|
||||
import * as smartstring from '@push.rocks/smartstring';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dataview-codebox': DeesDataviewCodebox;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-dataview-codebox')
|
||||
export class DeesDataviewCodebox extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property()
|
||||
public progLang: string = 'typescript';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
reflect: true,
|
||||
})
|
||||
public codeToDisplay: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
${domtools.elementBasic.styles}
|
||||
<style>
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
.mainbox {
|
||||
position: relative;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appbar {
|
||||
position: relative;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
height: 32px;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.appbar .fileName {
|
||||
line-height: inherit;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
position: relative;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spacesLabel {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.languageLabel {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
padding: 0px 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hljs-keyword {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||
}
|
||||
|
||||
.codegrid {
|
||||
display: grid;
|
||||
grid-template-columns: 50px auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lineNumbers {
|
||||
color: ${cssManager.bdTheme('#71717a', '#52525b')};
|
||||
padding: 24px 16px 0px 0px;
|
||||
text-align: right;
|
||||
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
}
|
||||
|
||||
.lineCounter:last-child {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
margin: 0px;
|
||||
padding: 24px 24px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-weight: 400;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
code,
|
||||
code *,
|
||||
.lineNumbers {
|
||||
line-height: 1.4em;
|
||||
font-weight: 200;
|
||||
font-family: ${cssMonoFontFamily};
|
||||
}
|
||||
|
||||
.hljs-string {
|
||||
color: ${cssManager.bdTheme('#059669', '#10b981')};
|
||||
}
|
||||
|
||||
.hljs-built_in {
|
||||
color: ${cssManager.bdTheme('#8b5cf6', '#a78bfa')};
|
||||
}
|
||||
|
||||
.hljs-function {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.hljs-params {
|
||||
color: ${cssManager.bdTheme('#0891b2', '#06b6d4')};
|
||||
}
|
||||
|
||||
.hljs-comment {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.hljs-number {
|
||||
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||
}
|
||||
|
||||
.hljs-attr {
|
||||
color: ${cssManager.bdTheme('#8b5cf6', '#a78bfa')};
|
||||
}
|
||||
|
||||
.hljs-variable {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="mainbox"
|
||||
@contextmenu="${(eventArg) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(eventArg, [
|
||||
{
|
||||
name: 'About',
|
||||
iconName: 'circleInfo',
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}"
|
||||
>
|
||||
<div class="appbar">
|
||||
<dees-windowcontrols type="mac" position="left"></dees-windowcontrols>
|
||||
<div class="fileName">index.ts</div>
|
||||
<dees-windowcontrols type="mac" position="right"></dees-windowcontrols>
|
||||
</div>
|
||||
<div class="codegrid">
|
||||
<div class="lineNumbers">
|
||||
${(() => {
|
||||
let lineCounter = 0;
|
||||
return this.codeToDisplay.split('\n').map((lineArg) => {
|
||||
lineCounter++;
|
||||
return html`<div class="lineCounter">${lineCounter}</div>`;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<pre><code></code></pre>
|
||||
</div>
|
||||
<div class="bottomBar">
|
||||
<div class="spacesLabel">Spaces: 2</div>
|
||||
<div class="languageLabel">${this.progLang}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private codeToDisplayStore = '';
|
||||
|
||||
public async updated(_changedProperties) {
|
||||
super.updated(_changedProperties);
|
||||
console.log('highlighting now');
|
||||
console.log(this.childNodes);
|
||||
const slottedCodeNodes: Text[] = [];
|
||||
this.childNodes.forEach((childNode) => {
|
||||
if (childNode.nodeName === '#text') {
|
||||
slottedCodeNodes.push(childNode as Text);
|
||||
}
|
||||
});
|
||||
if (this.codeToDisplay && this.codeToDisplay !== this.codeToDisplayStore) {
|
||||
this.codeToDisplayStore = smartstring.indent.normalize(this.codeToDisplay).trimStart();
|
||||
}
|
||||
if (slottedCodeNodes[0] && slottedCodeNodes[0].wholeText && !this.codeToDisplay) {
|
||||
this.codeToDisplayStore = smartstring.indent
|
||||
.normalize(slottedCodeNodes[0].wholeText)
|
||||
.trimStart();
|
||||
this.codeToDisplay = this.codeToDisplayStore;
|
||||
}
|
||||
await domtools.plugins.smartdelay.delayFor(0);
|
||||
const localCodeNode = this.shadowRoot.querySelector('code');
|
||||
const html = hlight.highlight(this.codeToDisplayStore, {
|
||||
language: this.progLang,
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
localCodeNode.innerHTML = html.value;
|
||||
}
|
||||
}
|
||||
164
ts_web/elements/dees-dataview-statusobject.demo.ts
Normal file
164
ts_web/elements/dees-dataview-statusobject.demo.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { html, cssManager } from '@design.estate/dees-element';
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
export const demoFunc = () => html` <style>
|
||||
.demo {
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
|
||||
display: block;
|
||||
content: '';
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
margin-bottom: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
</style>
|
||||
<div class="demo">
|
||||
<div class="demo-note">
|
||||
Right-click on any detail row to copy the value, key, or key:value combination
|
||||
</div>
|
||||
<div class="demo-grid">
|
||||
<div class="demo-section">
|
||||
<div class="demo-title">Service Health Status</div>
|
||||
<dees-dataview-statusobject
|
||||
.statusObject=${{
|
||||
id: '1',
|
||||
name: 'API Gateway Service',
|
||||
combinedStatus: 'ok',
|
||||
combinedStatusText: 'All systems operational',
|
||||
details: [
|
||||
{
|
||||
name: 'Response Time',
|
||||
value: '45ms (avg)',
|
||||
status: 'ok',
|
||||
statusText: 'Within normal range',
|
||||
},
|
||||
{
|
||||
name: 'Uptime',
|
||||
value: '99.99% (30 days)',
|
||||
status: 'ok',
|
||||
statusText: 'Excellent uptime',
|
||||
},
|
||||
{
|
||||
name: 'Active Connections',
|
||||
value: '1,234 / 10,000',
|
||||
status: 'ok',
|
||||
statusText: 'Normal load',
|
||||
},
|
||||
{
|
||||
name: 'SSL Certificate',
|
||||
value: 'Valid until 2024-12-31',
|
||||
status: 'ok',
|
||||
statusText: 'Certificate valid',
|
||||
},
|
||||
],
|
||||
} as tsclass.code.IStatusObject}
|
||||
>
|
||||
</dees-dataview-statusobject>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="demo-title">Database Cluster Status</div>
|
||||
<dees-dataview-statusobject
|
||||
.statusObject=${{
|
||||
id: '2',
|
||||
name: 'PostgreSQL Cluster',
|
||||
combinedStatus: 'partly_ok',
|
||||
combinedStatusText: 'Minor issues detected',
|
||||
details: [
|
||||
{
|
||||
name: 'Primary Node',
|
||||
value: 'db-primary-01 (healthy)',
|
||||
status: 'ok',
|
||||
statusText: 'Operating normally',
|
||||
},
|
||||
{
|
||||
name: 'Replica Lag',
|
||||
value: '2.5 seconds',
|
||||
status: 'partly_ok',
|
||||
statusText: 'Slightly elevated',
|
||||
},
|
||||
{
|
||||
name: 'Disk Usage',
|
||||
value: '78% (312GB / 400GB)',
|
||||
status: 'partly_ok',
|
||||
statusText: 'Approaching threshold',
|
||||
},
|
||||
{
|
||||
name: 'Connection Pool',
|
||||
value: '89 / 100 connections',
|
||||
status: 'ok',
|
||||
statusText: 'Within limits',
|
||||
},
|
||||
],
|
||||
} as tsclass.code.IStatusObject}
|
||||
>
|
||||
</dees-dataview-statusobject>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<div class="demo-title">Build Pipeline Status</div>
|
||||
<dees-dataview-statusobject
|
||||
.statusObject=${{
|
||||
id: '3',
|
||||
name: 'CI/CD Pipeline',
|
||||
combinedStatus: 'not_ok',
|
||||
combinedStatusText: 'Build failure',
|
||||
details: [
|
||||
{
|
||||
name: 'Last Build',
|
||||
value: 'Build #1234 - Failed',
|
||||
status: 'not_ok',
|
||||
statusText: 'Test failures',
|
||||
},
|
||||
{
|
||||
name: 'Failed Tests',
|
||||
value: '3 tests failed: auth.spec.ts, user.spec.ts, api.spec.ts',
|
||||
status: 'not_ok',
|
||||
statusText: 'Unit test failures',
|
||||
},
|
||||
{
|
||||
name: 'Code Coverage',
|
||||
value: '82.5% (target: 85%)',
|
||||
status: 'partly_ok',
|
||||
statusText: 'Below target',
|
||||
},
|
||||
{
|
||||
name: 'Build Duration',
|
||||
value: '12m 34s',
|
||||
status: 'ok',
|
||||
statusText: 'Normal duration',
|
||||
},
|
||||
],
|
||||
} as tsclass.code.IStatusObject}
|
||||
>
|
||||
</dees-dataview-statusobject>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user