diff options
author | BlackDex <[email protected]> | 2022-02-22 20:48:00 +0100 |
---|---|---|
committer | BlackDex <[email protected]> | 2022-02-26 13:56:42 +0100 |
commit | 42136a70973f60086749c62439c6a965d4589c02 (patch) | |
tree | 35979fc1ad0c6e6649271fa24ed0ba24b3135ef9 | |
parent | 5f01db69ffdb3d37e24e30a7003792ed72882973 (diff) | |
download | vaultwarden-42136a70973f60086749c62439c6a965d4589c02.tar.gz vaultwarden-42136a70973f60086749c62439c6a965d4589c02.zip |
Favicon, SMTP and misc updates
Favicon:
- Replaced HTML tokenizer, much faster now.
- Caching the domain blacklist function.
- Almost all functions are async now.
- Fixed bug on minimizing data to parse
- Changed maximum icon download size to 5MB to match Bitwarden
- Added `apple-touch-icon.png` as a second fallback besides `favicon.ico`
SMTP:
- Deprecated SMTP_SSL and SMTP_EXPLICIT_TLS, replaced with SMTP_SECURITY
Misc:
- Fixed issue when `resolv.conf` contains errors and trust-dns panics (Fixes #2283)
- Updated Javscript and CSS files for admin interface
- Fixed an issue with the /admin interface which did not cleared the login cookie correctly
- Prevent websocket notifications during org import, this caused a lot of traffic, and slowed down the import.
This is also the same as Bitwarden which does not trigger this refresh via websockets.
Rust:
- Updated to use v1.59
- Use the new `strip` option and enabled to strip `debuginfo`
- Enabled `lto` with `thin`
- Removed the strip RUN from the alpine armv7, this is now done automatically
-rw-r--r-- | .env.template | 3 | ||||
-rw-r--r-- | .pre-commit-config.yaml | 2 | ||||
-rw-r--r-- | Cargo.lock | 356 | ||||
-rw-r--r-- | Cargo.toml | 14 | ||||
-rw-r--r-- | docker/Dockerfile.j2 | 6 | ||||
-rw-r--r-- | docker/armv7/Dockerfile.alpine | 2 | ||||
-rw-r--r-- | docker/armv7/Dockerfile.buildx.alpine | 2 | ||||
-rw-r--r-- | src/api/admin.rs | 4 | ||||
-rw-r--r-- | src/api/core/organizations.rs | 4 | ||||
-rw-r--r-- | src/api/icons.rs | 530 | ||||
-rw-r--r-- | src/config.rs | 33 | ||||
-rw-r--r-- | src/mail.rs | 4 | ||||
-rw-r--r-- | src/static/scripts/bootstrap-native.js | 5489 | ||||
-rw-r--r-- | src/static/scripts/datatables.css | 38 | ||||
-rw-r--r-- | src/static/scripts/datatables.js | 69 | ||||
-rw-r--r-- | src/util.rs | 8 |
16 files changed, 4387 insertions, 2177 deletions
diff --git a/.env.template b/.env.template index 8da88cdc..2d0ea32b 100644 --- a/.env.template +++ b/.env.template @@ -331,9 +331,8 @@ # SMTP_HOST=smtp.domain.tld # SMTP_FROM_NAME=Vaultwarden +# SMTP_SECURITY=starttls # ("starttls", "force_tls", "off") Enable a secure connection. Default is "starttls" (Explicit - ports 587 or 25), "force_tls" (Implicit - port 465) or "off", no encryption (port 25) # SMTP_PORT=587 # Ports 587 (submission) and 25 (smtp) are standard without encryption and with encryption via STARTTLS (Explicit TLS). Port 465 is outdated and used with Implicit TLS. -# SMTP_SSL=true # (Explicit) - This variable by default configures Explicit STARTTLS, it will upgrade an insecure connection to a secure one. Unless SMTP_EXPLICIT_TLS is set to true. Either port 587 or 25 are default. -# SMTP_EXPLICIT_TLS=true # (Implicit) - N.B. This variable configures Implicit TLS. It's currently mislabelled (see bug #851) - SMTP_SSL Needs to be set to true for this option to work. Usually port 465 is used here. # SMTP_USERNAME=username # SMTP_PASSWORD=password # SMTP_TIMEOUT=15 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b26d8445..f18ddbf1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-yaml - id: check-json @@ -100,6 +100,25 @@ dependencies = [ ] [[package]] +name = "async-mutex" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479db852db25d9dbf6204e6cb6253698f175c15726470f78af0d918e99d6156e" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-rwlock" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261803dcc39ba9e72760ba6e16d0199b1eef9fc44e81bffabbebb9f5aea3906c" +dependencies = [ + "async-mutex", + "event-listener", +] + +[[package]] name = "async-stream" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -303,6 +322,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" [[package]] +name = "cached" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4dfac631a8e77b2f327f7852bb6172771f5279c4512efe79fad6067b37be3d" +dependencies = [ + "async-mutex", + "async-rwlock", + "async-trait", + "cached_proc_macro", + "cached_proc_macro_types", + "futures", + "hashbrown", + "once_cell", +] + +[[package]] +name = "cached_proc_macro" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "725f434d6da2814b989bd905c62ca28a9383feff7440210dc279665fbbbc9511" +dependencies = [ + "cached_proc_macro_types", + "darling", + "quote", + "syn", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4f925191b4367301851c6d99b09890311d74b0d43f274c0b34c86d308a3663" + +[[package]] name = "cc" version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -352,7 +405,7 @@ checksum = "58549f1842da3080ce63002102d5bc954c7bc843d4f47818e642abdc36253552" dependencies = [ "chrono", "chrono-tz-build", - "phf 0.10.1", + "phf", ] [[package]] @@ -362,8 +415,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db058d493fb2f65f41861bfed7e3fe6335264a9f0f92710cab5bdf01fef09069" dependencies = [ "parse-zoneinfo", - "phf 0.10.1", - "phf_codegen 0.10.0", + "phf", + "phf_codegen", ] [[package]] @@ -531,6 +584,41 @@ dependencies = [ ] [[package]] +name = "darling" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d720b8683f8dd83c65155f0530560cba68cd2bf395f6513a483caee57ff7f4" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a340f241d2ceed1deb47ae36c4144b2707ec7dd0b649f894cb39bb595986324" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c41b3b7352feb3211a0d743dc5700a4e3b60f51bd2b368892d1e0f9a95f44b" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] name = "dashmap" version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -685,9 +773,9 @@ dependencies = [ [[package]] name = "enum-as-inner" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595" +checksum = "570d109b813e904becc80d8d5da38376818a143348413f7149f1340fe04754d4" dependencies = [ "heck", "proc-macro2", @@ -705,6 +793,12 @@ dependencies = [ ] [[package]] +name = "event-listener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" + +[[package]] name = "fake-simd" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -809,16 +903,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" [[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] name = "futures" version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -958,9 +1042,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" dependencies = [ "cfg-if 1.0.0", "libc", @@ -1054,12 +1138,9 @@ checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" [[package]] name = "heck" -version = "0.3.3" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" -dependencies = [ - "unicode-segmentation", -] +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" [[package]] name = "hermit-abi" @@ -1120,17 +1201,12 @@ dependencies = [ ] [[package]] -name = "html5ever" -version = "0.25.1" +name = "html5gum" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafcf38a1a36118242d29b92e1b08ef84e67e4a5ed06e0a80be20e6a32bfed6b" +checksum = "2dad48b66db55322add2819ae1d7bda0c32f3415269a08330679dbc8b0afeb30" dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn", + "jetscii", ] [[package]] @@ -1205,6 +1281,12 @@ dependencies = [ ] [[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] name = "idna" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1286,6 +1368,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" [[package]] +name = "jetscii" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9447923c57a8a2d5c1b0875cdf96a6324275df728b498f2ede0e5cbde088a15" + +[[package]] name = "job_scheduler" version = "1.2.1" source = "git+https://github.com/jjlin/job_scheduler?rev=ee023418dbba2bfe1e30a5fd7d937f9e33739806#ee023418dbba2bfe1e30a5fd7d937f9e33739806" @@ -1363,9 +1451,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.118" +version = "0.2.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e509672465a0504304aa87f9f176f2b2b716ed8fb105ebe5c02dc6dce96a94" +checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4" [[package]] name = "libsqlite3-sys" @@ -1427,12 +1515,6 @@ dependencies = [ ] [[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] name = "mach" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1448,32 +1530,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] -name = "markup5ever" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" -dependencies = [ - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f015da43bcd8d4f144559a3423f4591d69b8ce0652c905374da7205df336ae2b" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] - -[[package]] name = "match_cfg" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1683,12 +1739,6 @@ dependencies = [ ] [[package]] -name = "new_debug_unreachable" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" - -[[package]] name = "nix" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2075,30 +2125,11 @@ dependencies = [ [[package]] name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ - "phf_shared 0.10.0", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_shared", ] [[package]] @@ -2107,18 +2138,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", + "phf_generator", + "phf_shared", ] [[package]] @@ -2127,21 +2148,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ - "phf_shared 0.10.0", + "phf_shared", "rand 0.8.5", ] [[package]] name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" @@ -2202,12 +2214,6 @@ dependencies = [ ] [[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] name = "proc-macro-hack" version = "0.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2331,7 +2337,6 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", ] [[package]] @@ -2395,7 +2400,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom 0.2.4", + "getrandom 0.2.5", ] [[package]] @@ -2408,15 +2413,6 @@ dependencies = [ ] [[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] name = "raw-cpuid" version = "10.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2589,7 +2585,7 @@ dependencies = [ [[package]] name = "rocket" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?rev=66d18bf66517e2765494d082629e9b9748ff8ad6#66d18bf66517e2765494d082629e9b9748ff8ad6" +source = "git+https://github.com/SergioBenitez/Rocket?rev=91e3b4397a1637d0f55f23db712cf7bda0c7f891#91e3b4397a1637d0f55f23db712cf7bda0c7f891" dependencies = [ "async-stream", "async-trait", @@ -2627,7 +2623,7 @@ dependencies = [ [[package]] name = "rocket_codegen" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?rev=66d18bf66517e2765494d082629e9b9748ff8ad6#66d18bf66517e2765494d082629e9b9748ff8ad6" +source = "git+https://github.com/SergioBenitez/Rocket?rev=91e3b4397a1637d0f55f23db712cf7bda0c7f891#91e3b4397a1637d0f55f23db712cf7bda0c7f891" dependencies = [ "devise", "glob", @@ -2642,7 +2638,7 @@ dependencies = [ [[package]] name = "rocket_http" version = "0.5.0-rc.1" -source = "git+https://github.com/SergioBenitez/Rocket?rev=66d18bf66517e2765494d082629e9b9748ff8ad6#66d18bf66517e2765494d082629e9b9748ff8ad6" +source = "git+https://github.com/SergioBenitez/Rocket?rev=91e3b4397a1637d0f55f23db712cf7bda0c7f891#91e3b4397a1637d0f55f23db712cf7bda0c7f891" dependencies = [ "cookie 0.16.0", "either", @@ -2656,6 +2652,7 @@ dependencies = [ "pin-project-lite", "ref-cast", "rustls", + "rustls-pemfile", "serde", "smallvec 1.8.0", "stable-pattern", @@ -2683,11 +2680,10 @@ dependencies = [ [[package]] name = "rustls" -version = "0.19.1" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +checksum = "4fbfeb8d0ddb84706bc597a5574ab8912817c52a397f819e5b614e2265206921" dependencies = [ - "base64 0.13.0", "log", "ring", "sct", @@ -2695,6 +2691,15 @@ dependencies = [ ] [[package]] +name = "rustls-pemfile" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360" +dependencies = [ + "base64 0.13.0", +] + +[[package]] name = "rustversion" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2748,9 +2753,9 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "sct" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" dependencies = [ "ring", "untrusted", @@ -3083,30 +3088,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" [[package]] -name = "string_cache" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33994d0838dc2d152d17a62adf608a869b5e846b65b389af7f3dbc1de45c5b26" -dependencies = [ - "lazy_static", - "new_debug_unreachable", - "parking_lot 0.11.2", - "phf_shared 0.10.0", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.1" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", - "proc-macro2", - "quote", -] +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" @@ -3152,17 +3137,6 @@ dependencies = [ ] [[package]] -name = "tendril" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ef557cb397a4f0a5a3a628f06515f78563f2209e64d47055d9dc6052bf5e33" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] name = "thiserror" version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3324,9 +3298,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.22.0" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +checksum = "a27d5f2b839802bd8267fa19b0530f5a08b9c08cd417976be2a65d130fe1c11b" dependencies = [ "rustls", "tokio", @@ -3589,12 +3563,6 @@ dependencies = [ ] [[package]] -name = "unicode-segmentation" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" - -[[package]] name = "unicode-xid" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3641,18 +3609,12 @@ dependencies = [ ] [[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom 0.2.4", + "getrandom 0.2.5", ] [[package]] @@ -3667,6 +3629,7 @@ version = "1.0.0" dependencies = [ "backtrace", "bytes 1.1.0", + "cached", "chashmap", "chrono", "chrono-tz", @@ -3682,14 +3645,13 @@ dependencies = [ "futures", "governor", "handlebars", - "html5ever", + "html5gum", "idna 0.2.3", "job_scheduler", "jsonwebtoken", "lettre", "libsqlite3-sys", "log", - "markup5ever_rcdom", "num-derive", "num-traits", "once_cell", @@ -3860,9 +3822,9 @@ dependencies = [ [[package]] name = "webpki" -version = "0.21.4" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" dependencies = [ "ring", "untrusted", @@ -3989,18 +3951,6 @@ dependencies = [ ] [[package]] -name = "xml5ever" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9234163818fd8e2418fcde330655e757900d4236acd8cc70fef345ef91f6d865" -dependencies = [ - "log", - "mac", - "markup5ever", - "time 0.1.43", -] - -[[package]] name = "yansi" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3,7 +3,7 @@ name = "vaultwarden" version = "1.0.0" authors = ["Daniel García <[email protected]>"] edition = "2021" -rust-version = "1.58.1" +rust-version = "1.59" resolver = "2" repository = "https://github.com/dani-garcia/vaultwarden" @@ -116,11 +116,11 @@ handlebars = { version = "4.2.1", features = ["dir_source"] } reqwest = { version = "0.11.9", features = ["stream", "json", "gzip", "brotli", "socks", "cookies", "trust-dns"] } # For favicon extraction from main website -html5ever = "0.25.1" -markup5ever_rcdom = "0.1.0" +html5gum = "0.4.0" regex = { version = "1.5.4", features = ["std", "perf", "unicode-perl"], default-features = false } data-url = "0.1.1" bytes = "1.1.0" +cached = "0.30.0" # Used for custom short lived cookie jar during favicon extraction cookie = "0.15.1" @@ -140,7 +140,7 @@ governor = "0.4.2" ctrlc = { version = "3.2.1", features = ["termination"] } [patch.crates-io] -rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '66d18bf66517e2765494d082629e9b9748ff8ad6' } +rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '91e3b4397a1637d0f55f23db712cf7bda0c7f891' } # The maintainer of the `job_scheduler` crate doesn't seem to have responded # to any issues or PRs for almost a year (as of April 2021). This hopefully @@ -148,3 +148,9 @@ rocket = { git = 'https://github.com/SergioBenitez/Rocket', rev = '66d18bf66517e # In particular, `cron` has since implemented parsing of some common syntax # that wasn't previously supported (https://github.com/zslayton/cron/pull/64). job_scheduler = { git = 'https://github.com/jjlin/job_scheduler', rev = 'ee023418dbba2bfe1e30a5fd7d937f9e33739806' } + +# Strip debuginfo from the release builds +# Also enable thin LTO for some optimizations +[profile.release] +strip = "debuginfo" +lto = "thin" diff --git a/docker/Dockerfile.j2 b/docker/Dockerfile.j2 index 196af08d..a5194254 100644 --- a/docker/Dockerfile.j2 +++ b/docker/Dockerfile.j2 @@ -182,12 +182,6 @@ RUN touch src/main.rs # your actual source files being built # hadolint ignore=DL3059 RUN {{ mount_rust_cache -}} cargo build --features ${DB} --release{{ package_arch_target_param }} -{% if "alpine" in target_file %} -{% if "armv7" in target_file %} -# hadolint ignore=DL3059 -RUN musl-strip target/{{ package_arch_target }}/release/vaultwarden -{% endif %} -{% endif %} ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image diff --git a/docker/armv7/Dockerfile.alpine b/docker/armv7/Dockerfile.alpine index d00017bd..e05965bc 100644 --- a/docker/armv7/Dockerfile.alpine +++ b/docker/armv7/Dockerfile.alpine @@ -78,8 +78,6 @@ RUN touch src/main.rs # your actual source files being built # hadolint ignore=DL3059 RUN cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf -# hadolint ignore=DL3059 -RUN musl-strip target/armv7-unknown-linux-musleabihf/release/vaultwarden ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image diff --git a/docker/armv7/Dockerfile.buildx.alpine b/docker/armv7/Dockerfile.buildx.alpine index a80405d0..431e0ff9 100644 --- a/docker/armv7/Dockerfile.buildx.alpine +++ b/docker/armv7/Dockerfile.buildx.alpine @@ -78,8 +78,6 @@ RUN touch src/main.rs # your actual source files being built # hadolint ignore=DL3059 RUN --mount=type=cache,target=/root/.cargo/git --mount=type=cache,target=/root/.cargo/registry cargo build --features ${DB} --release --target=armv7-unknown-linux-musleabihf -# hadolint ignore=DL3059 -RUN musl-strip target/armv7-unknown-linux-musleabihf/release/vaultwarden ######################## RUNTIME IMAGE ######################## # Create a new stage with a minimal image diff --git a/src/api/admin.rs b/src/api/admin.rs index 015ec7c7..6fbf30e9 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -301,7 +301,7 @@ fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult { #[get("/logout")] fn logout(cookies: &CookieJar<'_>, referer: Referer) -> Redirect { - cookies.remove(Cookie::named(COOKIE_NAME)); + cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); Redirect::to(admin_url(referer)) } @@ -638,7 +638,7 @@ impl<'r> FromRequest<'r> for AdminToken { if decode_admin(access_token).is_err() { // Remove admin cookie - cookies.remove(Cookie::named(COOKIE_NAME)); + cookies.remove(Cookie::build(COOKIE_NAME, "").path(admin_path()).finish()); error!("Invalid or expired admin JWT. IP: {}.", ip); return Outcome::Forward(()); } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index bb6c6634..13012e96 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1182,9 +1182,7 @@ async fn post_org_import( let ciphers = stream::iter(data.Ciphers) .then(|cipher_data| async { let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone()); - update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::CipherCreate) - .await - .ok(); + update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::None).await.ok(); cipher }) .collect::<Vec<Cipher>>() diff --git a/src/api/icons.rs b/src/api/icons.rs index 6af10a35..71c4899d 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -1,21 +1,28 @@ use std::{ collections::HashMap, - net::{IpAddr, ToSocketAddrs}, - sync::{Arc, RwLock}, + net::IpAddr, + sync::Arc, time::{Duration, SystemTime}, }; -use bytes::{Buf, Bytes, BytesMut}; +use bytes::{Bytes, BytesMut}; use futures::{stream::StreamExt, TryFutureExt}; use once_cell::sync::Lazy; use regex::Regex; -use reqwest::{header, Client, Response}; +use reqwest::{ + header::{self, HeaderMap, HeaderValue}, + Client, Response, +}; use rocket::{http::ContentType, response::Redirect, Route}; use tokio::{ fs::{create_dir_all, remove_file, symlink_metadata, File}, io::{AsyncReadExt, AsyncWriteExt}, + net::lookup_host, + sync::RwLock, }; +use html5gum::{Emitter, EndTag, InfallibleTokenizer, Readable, StartTag, StringReader, Tokenizer}; + use crate::{ error::Error, util::{get_reqwest_client_builder, Cached}, @@ -34,39 +41,50 @@ pub fn routes() -> Vec<Route> { static CLIENT: Lazy<Client> = Lazy::new(|| { // Generate the default headers - let mut default_headers = header::HeaderMap::new(); - default_headers - .insert(header::USER_AGENT, header::HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)")); - default_headers - .insert(header::ACCEPT, header::HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1")); - default_headers.insert(header::ACCEPT_LANGUAGE, header::HeaderValue::from_static("en,*;q=0.1")); - default_headers.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("no-cache")); - default_headers.insert(header::PRAGMA, header::HeaderValue::from_static("no-cache")); + let mut default_headers = HeaderMap::new(); + default_headers.insert(header::USER_AGENT, HeaderValue::from_static("Links (2.22; Linux X86_64; GNU C; text)")); + default_headers.insert(header::ACCEPT, HeaderValue::from_static("text/html, text/*;q=0.5, image/*, */*;q=0.1")); + default_headers.insert(header::ACCEPT_LANGUAGE, HeaderValue::from_static("en,*;q=0.1")); + default_headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + default_headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache")); + + // Generate the cookie store + let cookie_store = Arc::new(Jar::default()); // Reuse the client between requests - get_reqwest_client_builder() - .cookie_provider(Arc::new(Jar::default())) + let client = get_reqwest_client_builder() + .cookie_provider(cookie_store.clone()) .timeout(Duration::from_secs(CONFIG.icon_download_timeout())) - .default_headers(default_headers) - .build() - .expect("Failed to build icon client") + .default_headers(default_headers.clone()); + + match client.build() { + Ok(client) => client, + Err(e) => { + error!("Possible trust-dns error, trying with trust-dns disabled: '{e}'"); + get_reqwest_client_builder() + .cookie_provider(cookie_store) + .timeout(Duration::from_secs(CONFIG.icon_download_timeout())) + .default_headers(default_headers) + .trust_dns(false) + .build() + .expect("Failed to build client") + } + } }); // Build Regex only once since this takes a lot of time. -static ICON_REL_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)icon$|apple.*icon").unwrap()); -static ICON_REL_BLACKLIST: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)mask-icon").unwrap()); static ICON_SIZE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap()); // Special HashMap which holds the user defined Regex to speedup matching the regex. static ICON_BLACKLIST_REGEX: Lazy<RwLock<HashMap<String, Regex>>> = Lazy::new(|| RwLock::new(HashMap::new())); -fn icon_redirect(domain: &str, template: &str) -> Option<Redirect> { - if !is_valid_domain(domain) { +async fn icon_redirect(domain: &str, template: &str) -> Option<Redirect> { + if !is_valid_domain(domain).await { warn!("Invalid domain: {}", domain); return None; } - if is_domain_blacklisted(domain) { + if is_domain_blacklisted(domain).await { return None; } @@ -84,30 +102,30 @@ fn icon_redirect(domain: &str, template: &str) -> Option<Redirect> { } #[get("/<domain>/icon.png")] -fn icon_custom(domain: String) -> Option<Redirect> { - icon_redirect(&domain, &CONFIG.icon_service()) +async fn icon_custom(domain: String) -> Option<Redirect> { + icon_redirect(&domain, &CONFIG.icon_service()).await } #[get("/<domain>/icon.png")] -fn icon_bitwarden(domain: String) -> Option<Redirect> { - icon_redirect(&domain, "https://icons.bitwarden.net/{}/icon.png") +async fn icon_bitwarden(domain: String) -> Option<Redirect> { + icon_redirect(&domain, "https://icons.bitwarden.net/{}/icon.png").await } #[get("/<domain>/icon.png")] -fn icon_duckduckgo(domain: String) -> Option<Redirect> { - icon_redirect(&domain, "https://icons.duckduckgo.com/ip3/{}.ico") +async fn icon_duckduckgo(domain: String) -> Option<Redirect> { + icon_redirect(&domain, "https://icons.duckduckgo.com/ip3/{}.ico").await } #[get("/<domain>/icon.png")] -fn icon_google(domain: String) -> Option<Redirect> { - icon_redirect(&domain, "https://www.google.com/s2/favicons?domain={}&sz=32") +async fn icon_google(domain: String) -> Option<Redirect> { + icon_redirect(&domain, "https://www.google.com/s2/favicons?domain={}&sz=32").await } #[get("/<domain>/icon.png")] async fn icon_internal(domain: String) -> Cached<(ContentType, Vec<u8>)> { const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); - if !is_valid_domain(&domain) { + if !is_valid_domain(&domain).await { warn!("Invalid domain: {}", domain); return Cached::ttl( (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), @@ -128,7 +146,7 @@ async fn icon_internal(domain: String) -> Cached<(ContentType, Vec<u8>)> { /// /// This does some manual checks and makes use of Url to do some basic checking. /// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255. -fn is_valid_domain(domain: &str) -> bool { +async fn is_valid_domain(domain: &str) -> bool { const ALLOWED_CHARS: &str = "_-."; // If parsing the domain fails using Url, it will not work with reqwest. @@ -260,57 +278,52 @@ mod tests { } } -fn is_domain_blacklisted(domain: &str) -> bool { - let mut is_blacklisted = CONFIG.icon_blacklist_non_global_ips() - && (domain, 0) - .to_socket_addrs() - .map(|x| { - for ip_port in x { - if !is_global(ip_port.ip()) { - warn!("IP {} for domain '{}' is not a global IP!", ip_port.ip(), domain); - return true; - } - } - false - }) - .unwrap_or(false); - - // Skip the regex check if the previous one is true already - if !is_blacklisted { - if let Some(blacklist) = CONFIG.icon_blacklist_regex() { - let mut regex_hashmap = ICON_BLACKLIST_REGEX.read().unwrap(); - - // Use the pre-generate Regex stored in a Lazy HashMap if there's one, else generate it. - let regex = if let Some(regex) = regex_hashmap.get(&blacklist) { - regex - } else { - drop(regex_hashmap); - - let mut regex_hashmap_write = ICON_BLACKLIST_REGEX.write().unwrap(); - // Clear the current list if the previous key doesn't exists. - // To prevent growing of the HashMap after someone has changed it via the admin interface. - if regex_hashmap_write.len() >= 1 { - regex_hashmap_write.clear(); +use cached::proc_macro::cached; +#[cached(key = "String", convert = r#"{ domain.to_string() }"#, size = 16, time = 60)] +async fn is_domain_blacklisted(domain: &str) -> bool { + if CONFIG.icon_blacklist_non_global_ips() { + if let Ok(s) = lookup_host((domain, 0)).await { + for addr in s { + if !is_global(addr.ip()) { + debug!("IP {} for domain '{}' is not a global IP!", addr.ip(), domain); + return true; } + } + } + } - // Generate the regex to store in too the Lazy Static HashMap. - let blacklist_regex = Regex::new(&blacklist).unwrap(); - regex_hashmap_write.insert(blacklist.to_string(), blacklist_regex); - drop(regex_hashmap_write); + if let Some(blacklist) = CONFIG.icon_blacklist_regex() { + let mut regex_hashmap = ICON_BLACKLIST_REGEX.read().await; - regex_hashmap = ICON_BLACKLIST_REGEX.read().unwrap(); - regex_hashmap.get(&blacklist).unwrap() - }; + // Use the pre-generate Regex stored in a Lazy HashMap if there's one, else generate it. + let regex = if let Some(regex) = regex_hashmap.get(&blacklist) { + regex + } else { + drop(regex_hashmap); - // Use the pre-generate Regex stored in a Lazy HashMap. - if regex.is_match(domain) { - debug!("Blacklisted domain: {} matched ICON_BLACKLIST_REGEX", domain); - is_blacklisted = true; + let mut regex_hashmap_write = ICON_BLACKLIST_REGEX.write().await; + // Clear the current list if the previous key doesn't exists. + // To prevent growing of the HashMap after someone has changed it via the admin interface. + if regex_hashmap_write.len() >= 1 { + regex_hashmap_write.clear(); } + + // Generate the regex to store in too the Lazy Static HashMap. + let blacklist_regex = Regex::new(&blacklist); + regex_hashmap_write.insert(blacklist.to_string(), blacklist_regex.unwrap()); + drop(regex_hashmap_write); + + regex_hashmap = ICON_BLACKLIST_REGEX.read().await; + regex_hashmap.get(&blacklist).unwrap() + }; + + // Use the pre-generate Regex stored in a Lazy HashMap. + if regex.is_match(domain) { + debug!("Blacklisted domain: {} matched ICON_BLACKLIST_REGEX", domain); + return true; } } - - is_blacklisted + false } async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { @@ -322,7 +335,7 @@ async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { } if let Some(icon) = get_cached_icon(&path).await { - let icon_type = match get_icon_type(&icon) { + let icon_type = match get_icon_type(&icon).await { Some(x) => x, _ => "x-icon", }; @@ -412,91 +425,62 @@ impl Icon { } } -/// Iterates over the HTML document to find <base href="http://domain.tld"> -/// When found it will stop the iteration and the found base href will be shared deref via `base_href`. -/// -/// # Arguments -/// * `node` - A Parsed HTML document via html5ever::parse_document() -/// * `base_href` - a mutable url::Url which will be overwritten when a base href tag has been found. -/// -fn get_base_href(node: &std::rc::Rc<markup5ever_rcdom::Node>, base_href: &mut url::Url) -> bool { - if let markup5ever_rcdom::NodeData::Element { - name, - attrs, - .. - } = &node.data - { - if name.local.as_ref() == "base" { - let attrs = attrs.borrow(); - for attr in attrs.iter() { - let attr_name = attr.name.local.as_ref(); - let attr_value = attr.value.as_ref(); - - if attr_name == "href" { - debug!("Found base href: {}", attr_value); - *base_href = match base_href.join(attr_value) { - Ok(href) => href, - _ => base_href.clone(), - }; - return true; - } - } - return true; - } - } - - // TODO: Might want to limit the recursion depth? - for child in node.children.borrow().iter() { - // Check if we got a true back and stop the iter. - // This means we found a <base> tag and can stop processing the html. - if get_base_href(child, base_href) { - return true; - } - } - false -} - -fn get_favicons_node(node: &std::rc::Rc<markup5ever_rcdom::Node>, icons: &mut Vec<Icon>, url: &url::Url) { - if let markup5ever_rcdom::NodeData::Element { - name, - attrs, - .. - } = &node.data - { - if name.local.as_ref() == "link" { - let mut has_rel = false; - let mut href = None; - let mut sizes = None; - - let attrs = attrs.borrow(); - for attr in attrs.iter() { - let attr_name = attr.name.local.as_ref(); - let attr_value = attr.value.as_ref(); - - if attr_name == "rel" && ICON_REL_REGEX.is_match(attr_value) && !ICON_REL_BLACKLIST.is_match(attr_value) +async fn get_favicons_node( + dom: InfallibleTokenizer<StringReader<'_>, FaviconEmitter>, + icons: &mut Vec<Icon>, + url: &url::Url, +) { + const TAG_LINK: &[u8] = b"link"; + const TAG_BASE: &[u8] = b"base"; + const TAG_HEAD: &[u8] = b"head"; + const ATTR_REL: &[u8] = b"rel"; + const ATTR_HREF: &[u8] = b"href"; + const ATTR_SIZES: &[u8] = b"sizes"; + + let mut base_url = url.clone(); + let mut icon_tags: Vec<StartTag> = Vec::new(); + for token in dom { + match token { + FaviconToken::StartTag(tag) => { + if tag.name == TAG_LINK + && tag.attributes.contains_key(ATTR_REL) + && tag.attributes.contains_key(ATTR_HREF) { - has_rel = true; - } else if attr_name == "href" { - href = Some(attr_value); - } else if attr_name == "sizes" { - sizes = Some(attr_value); + let rel_value = std::str::from_utf8(tag.attributes.get(ATTR_REL).unwrap()) + .unwrap_or_default() + .to_ascii_lowercase(); + if rel_value.contains("icon") && !rel_value.contains("mask-icon") { + icon_tags.push(tag); + } + } else if tag.name == TAG_BASE && tag.attributes.contains_key(ATTR_HREF) { + let href = std::str::from_utf8(tag.attributes.get(ATTR_HREF).unwrap()).unwrap_or_default(); + debug!("Found base href: {href}"); + base_url = match base_url.join(href) { + Ok(inner_url) => inner_url, + _ => url.clone(), + }; } } - - if has_rel { - if let Some(inner_href) = href { - if let Ok(full_href) = url.join(inner_href).map(String::from) { - let priority = get_icon_priority(&full_href, sizes); - icons.push(Icon::new(priority, full_href)); - } + FaviconToken::EndTag(tag) => { + if tag.name == TAG_HEAD { + break; } } } } - // TODO: Might want to limit the recursion depth? - for child in node.children.borrow().iter() { - get_favicons_node(child, icons, url); + for icon_tag in icon_tags { + if let Some(icon_href) = icon_tag.attributes.get(ATTR_HREF) { + if let Ok(full_href) = base_url.join(std::str::from_utf8(icon_href).unwrap_or_default()) { + let sizes = if let Some(v) = icon_tag.attributes.get(ATTR_SIZES) { + std::str::from_utf8(v).unwrap_or_default() + } else { + "" + }; + let priority = get_icon_priority(full_href.as_str(), sizes).await; + icons.push(Icon::new(priority, full_href.to_string())); + } + }; } } @@ -514,13 +498,13 @@ struct IconUrlResult { /// /// # Example /// ``` -/// let icon_result = get_icon_url("github.com")?; -/// let icon_result = get_icon_url("vaultwarden.discourse.group")?; +/// let icon_result = get_icon_url("github.com").await?; +/// let icon_result = get_icon_url("vaultwarden.discourse.group").await?; /// ``` async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> { // Default URL with secure and insecure schemes - let ssldomain = format!("https://{}", domain); - let httpdomain = format!("http://{}", domain); + let ssldomain = format!("https://{domain}"); + let httpdomain = format!("http://{domain}"); // First check the domain as given during the request for both HTTPS and HTTP. let resp = match get_page(&ssldomain).or_else(|_| get_page(&httpdomain)).await { @@ -537,26 +521,25 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> { tld = domain_parts.next_back().unwrap(), base = domain_parts.next_back().unwrap() ); - if is_valid_domain(&base_domain) { - let sslbase = format!("https://{}", base_domain); - let httpbase = format!("http://{}", base_domain); - debug!("[get_icon_url]: Trying without subdomains '{}'", base_domain); + if is_valid_domain(&base_domain).await { + let sslbase = format!("https://{base_domain}"); + let httpbase = format!("http://{base_domain}"); + debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); sub_resp = get_page(&sslbase).or_else(|_| get_page(&httpbase)).await; } // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. } else if is_ip.is_err() && domain.matches('.').count() < 2 { - let www_domain = format!("www.{}", domain); - if is_valid_domain(&www_domain) { - let sslwww = format!("https://{}", www_domain); - let httpwww = format!("http://{}", www_domain); - debug!("[get_icon_url]: Trying with www. prefix '{}'", www_domain); + let www_domain = format!("www.{domain}"); + if is_valid_domain(&www_domain).await { + let sslwww = format!("https://{www_domain}"); + let httpwww = format!("http://{www_domain}"); + debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); sub_resp = get_page(&sslwww).or_else(|_| get_page(&httpwww)).await; } } - sub_resp } }; @@ -571,26 +554,23 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> { // Set the referer to be used on the final request, some sites check this. // Mostly used to prevent direct linking and other security resons. - referer = url.as_str().to_string(); + referer = url.to_string(); - // Add the default favicon.ico to the list with the domain the content responded from. + // Add the fallback favicon.ico and apple-touch-icon.png to the list with the domain the content responded from. iconlist.push(Icon::new(35, String::from(url.join("/favicon.ico").unwrap()))); + iconlist.push(Icon::new(40, String::from(url.join("/apple-touch-icon.png").unwrap()))); // 384KB should be more than enough for the HTML, though as we only really need the HTML header. - let mut limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.reader(); + let limited_reader = stream_to_bytes_limit(content, 384 * 1024).await?.to_vec(); - use html5ever::tendril::TendrilSink; - let dom = html5ever::parse_document(markup5ever_rcdom::RcDom::default(), Default::default()) - .from_utf8() - .read_from(&mut limited_reader)?; - - let mut base_url: url::Url = url; - get_base_href(&dom.document, &mut base_url); - get_favicons_node(&dom.document, &mut iconlist, &base_url); + let dom = Tokenizer::new_with_emitter(limited_reader.to_reader(), FaviconEmitter::default()).infallible(); + get_favicons_node(dom, &mut iconlist, &url).await; } else { // Add the default favicon.ico to the list with just the given domain - iconlist.push(Icon::new(35, format!("{}/favicon.ico", ssldomain))); - iconlist.push(Icon::new(35, format!("{}/favicon.ico", httpdomain))); + iconlist.push(Icon::new(35, format!("{ssldomain}/favicon.ico"))); + iconlist.push(Icon::new(40, format!("{ssldomain}/apple-touch-icon.png"))); + iconlist.push(Icon::new(35, format!("{httpdomain}/favicon.ico"))); + iconlist.push(Icon::new(40, format!("{httpdomain}/apple-touch-icon.png"))); } // Sort the iconlist by priority @@ -608,7 +588,7 @@ async fn get_page(url: &str) -> Result<Response, Error> { } async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> { - if is_domain_blacklisted(url::Url::parse(url).unwrap().host_str().unwrap_or_default()) { + if is_domain_blacklisted(url::Url::parse(url).unwrap().host_str().unwrap_or_default()).await { warn!("Favicon '{}' resolves to a blacklisted domain or IP!", url); } @@ -632,12 +612,12 @@ async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Err /// /// # Example /// ``` -/// priority1 = get_icon_priority("http://example.com/path/to/a/favicon.png", "32x32"); -/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", ""); +/// priority1 = get_icon_priority("http://example.com/path/to/a/favicon.png", "32x32").await; +/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "").await; /// ``` -fn get_icon_priority(href: &str, sizes: Option<&str>) -> u8 { +async fn get_icon_priority(href: &str, sizes: &str) -> u8 { // Check if there is a dimension set - let (width, height) = parse_sizes(sizes); + let (width, height) = parse_sizes(sizes).await; // Check if there is a size given if width != 0 && height != 0 { @@ -679,15 +659,15 @@ fn get_icon_priority(href: &str, sizes: Option<&str>) -> u8 { /// /// # Example /// ``` -/// let (width, height) = parse_sizes("64x64"); // (64, 64) -/// let (width, height) = parse_sizes("x128x128"); // (128, 128) -/// let (width, height) = parse_sizes("32"); // (0, 0) +/// let (width, height) = parse_sizes("64x64").await; // (64, 64) +/// let (width, height) = parse_sizes("x128x128").await; // (128, 128) +/// let (width, height) = parse_sizes("32").await; // (0, 0) /// ``` -fn parse_sizes(sizes: Option<&str>) -> (u16, u16) { +async fn parse_sizes(sizes: &str) -> (u16, u16) { let mut width: u16 = 0; let mut height: u16 = 0; - if let Some(sizes) = sizes { + if !sizes.is_empty() { match ICON_SIZE_REGEX.captures(sizes.trim()) { None => {} Some(dimensions) => { @@ -703,7 +683,7 @@ fn parse_sizes(sizes: Option<&str>) -> (u16, u16) { } async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { - if is_domain_blacklisted(domain) { + if is_domain_blacklisted(domain).await { err_silent!("Domain is blacklisted", domain) } @@ -727,7 +707,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create if body.len() >= 67 { // Check if the icon type is allowed, else try an icon from the list. - icon_type = get_icon_type(&body); + icon_type = get_icon_type(&body).await; if icon_type.is_none() { debug!("Icon from {} data:image uri, is not a valid image type", domain); continue; @@ -742,10 +722,10 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { } else { match get_page_with_referer(&icon.href, &icon_result.referer).await { Ok(res) => { - buffer = stream_to_bytes_limit(res, 512 * 1024).await?; // 512 KB for each icon max - // Check if the icon type is allowed, else try an icon from the list. - icon_type = get_icon_type(&buffer); + buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) + // Check if the icon type is allowed, else try an icon from the list. + icon_type = get_icon_type(&buffer).await; if icon_type.is_none() { buffer.clear(); debug!("Icon from {}, is not a valid image type", icon.href); @@ -780,7 +760,7 @@ async fn save_icon(path: &str, icon: &[u8]) { } } -fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { +async fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { match bytes { [137, 80, 78, 71, ..] => Some("png"), [0, 0, 1, 0, ..] => Some("x-icon"), @@ -792,13 +772,30 @@ fn get_icon_type(bytes: &[u8]) -> Option<&'static str> { } } +/// Minimize the amount of bytes to be parsed from a reqwest result. +/// This prevents very long parsing and memory usage. +async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result<Bytes, reqwest::Error> { + let mut stream = res.bytes_stream().take(max_size); + let mut buf = BytesMut::new(); + let mut size = 0; + while let Some(chunk) = stream.next().await { + let chunk = &chunk?; + size += chunk.len(); + buf.extend(chunk); + if size >= max_size { + break; + } + } + Ok(buf.freeze()) +} + /// This is an implementation of the default Cookie Jar from Reqwest and reqwest_cookie_store build by pfernie. /// The default cookie jar used by Reqwest keeps all the cookies based upon the Max-Age or Expires which could be a long time. /// That could be used for tracking, to prevent this we force the lifespan of the cookies to always be max two minutes. /// A Cookie Jar is needed because some sites force a redirect with cookies to verify if a request uses cookies or not. use cookie_store::CookieStore; #[derive(Default)] -pub struct Jar(RwLock<CookieStore>); +pub struct Jar(std::sync::RwLock<CookieStore>); impl reqwest::cookie::CookieStore for Jar { fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &header::HeaderValue>, url: &url::Url) { @@ -836,11 +833,136 @@ impl reqwest::cookie::CookieStore for Jar { } } -async fn stream_to_bytes_limit(res: Response, max_size: usize) -> Result<Bytes, reqwest::Error> { - let mut stream = res.bytes_stream().take(max_size); - let mut buf = BytesMut::new(); - while let Some(chunk) = stream.next().await { - buf.extend(chunk?); +/// Custom FaviconEmitter for the html5gum parser. +/// The FaviconEmitter is using an almost 1:1 copy of the DefaultEmitter with some small changes. +/// This prevents emitting tags like comments, doctype and also strings between the tags. +/// Therefor parsing the HTML content is faster. +use std::collections::{BTreeSet, VecDeque}; + +enum FaviconToken { + StartTag(StartTag), + EndTag(EndTag), +} + +#[derive(Default)] +struct FaviconEmitter { + current_token: Option<FaviconToken>, + last_start_tag: Vec<u8>, + current_attribute: Option<(Vec<u8>, Vec<u8>)>, + seen_attributes: BTreeSet<Vec<u8>>, + emitted_tokens: VecDeque<FaviconToken>, +} + +impl FaviconEmitter { + fn emit_token(&mut self, token: FaviconToken) { + self.emitted_tokens.push_front(token); } - Ok(buf.freeze()) + + fn flush_current_attribute(&mut self) { + if let Some((k, v)) = self.current_attribute.take() { + match self.current_token { + Some(FaviconToken::StartTag(ref mut tag)) => { + tag.attributes.entry(k).and_modify(|_| {}).or_insert(v); + } + Some(FaviconToken::EndTag(_)) => { + self.seen_attributes.insert(k); + } + _ => { + debug_assert!(false); + } + } + } + } +} + +impl Emitter for FaviconEmitter { + type Token = FaviconToken; + + fn set_last_start_tag(&mut self, last_start_tag: Option<&[u8]>) { + self.last_start_tag.clear(); + self.last_start_tag.extend(last_start_tag.unwrap_or_default()); + } + + fn pop_token(&mut self) -> Option<Self::Token> { + self.emitted_tokens.pop_back() + } + + fn init_start_tag(&mut self) { + self.current_token = Some(FaviconToken::StartTag(StartTag::default())); + } + + fn init_end_tag(&mut self) { + self.current_token = Some(FaviconToken::EndTag(EndTag::default())); + self.seen_attributes.clear(); + } + + fn emit_current_tag(&mut self) { + self.flush_current_attribute(); + let mut token = self.current_token.take().unwrap(); + match token { + FaviconToken::EndTag(_) => { + self.seen_attributes.clear(); + } + FaviconToken::StartTag(ref mut tag) => { + self.set_last_start_tag(Some(&tag.name)); + } + } + self.emit_token(token); + } + + fn push_tag_name(&mut self, s: &[u8]) { + match self.current_token { + Some( + FaviconToken::StartTag(StartTag { + ref mut name, + .. + }) + | FaviconToken::EndTag(EndTag { + ref mut name, + .. + }), + ) => { + name.extend(s); + } + _ => debug_assert!(false), + } + } + + fn init_attribute(&mut self) { + self.flush_current_attribute(); + self.current_attribute = Some((Vec::new(), Vec::new())); + } + + fn push_attribute_name(&mut self, s: &[u8]) { + self.current_attribute.as_mut().unwrap().0.extend(s); + } + + fn push_attribute_value(&mut self, s: &[u8]) { + self.current_attribute.as_mut().unwrap().1.extend(s); + } + + fn current_is_appropriate_end_tag_token(&mut self) -> bool { + match self.current_token { + Some(FaviconToken::EndTag(ref tag)) => !self.last_start_tag.is_empty() && self.last_start_tag == tag.name, + _ => false, + } + } + + // We do not want and need these parts of the HTML document + // These will be skipped and ignored during the tokenization and iteration. + fn emit_current_comment(&mut self) {} + fn emit_current_doctype(&mut self) {} + fn emit_eof(&mut self) {} + fn emit_error(&mut self, _: html5gum::Error) {} + fn emit_string(&mut self, _: &[u8]) {} + fn init_comment(&mut self) {} + fn init_doctype(&mut self) {} + fn push_comment(&mut self, _: &[u8]) {} + fn push_doctype_name(&mut self, _: &[u8]) {} + fn push_doctype_public_identifier(&mut self, _: &[u8]) {} + fn push_doctype_system_identifier(&mut self, _: &[u8]) {} + fn set_doctype_public_identifier(&mut self, _: &[u8]) {} + fn set_doctype_system_identifier(&mut self, _: &[u8]) {} + fn set_force_quirks(&mut self) {} + fn set_self_closing(&mut self) {} } diff --git a/src/config.rs b/src/config.rs index d2a52ef9..f00ea50d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -569,12 +569,14 @@ make_config! { _enable_smtp: bool, true, def, true; /// Host smtp_host: String, true, option; - /// Enable Secure SMTP |> (Explicit) - Enabling this by default would use STARTTLS (Standard ports 587 or 25) - smtp_ssl: bool, true, def, true; - /// Force TLS |> (Implicit) - Enabling this would force the use of an SSL/TLS connection, instead of upgrading an insecure one with STARTTLS (Standard port 465) - smtp_explicit_tls: bool, true, def, false; + /// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY + smtp_ssl: bool, false, option; + /// DEPRECATED smtp_explicit_tls |> DEPRECATED - Please use SMTP_SECURITY + smtp_explicit_tls: bool, false, option; + /// Secure SMTP |> ("starttls", "force_tls", "off") Enable a secure connection. Default is "starttls" (Explicit - ports 587 or 25), "force_tls" (Implicit - port 465) or "off", no encryption + smtp_security: String, true, auto, |c| smtp_convert_deprecated_ssl_options(c.smtp_ssl, c.smtp_explicit_tls); // TODO: After deprecation make it `def, "starttls".to_string()` /// Port - smtp_port: u16, true, auto, |c| if c.smtp_explicit_tls {465} else if c.smtp_ssl {587} else {25}; + smtp_port: u16, true, auto, |c| if c.smtp_security == *"force_tls" {465} else if c.smtp_security == *"starttls" {587} else {25}; /// From Address smtp_from: String, true, def, String::new(); /// From Name @@ -657,6 +659,13 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { } if cfg._enable_smtp { + match cfg.smtp_security.as_str() { + "off" | "starttls" | "force_tls" => (), + _ => err!( + "`SMTP_SECURITY` is invalid. It needs to be one of the following options: starttls, force_tls or off" + ), + } + if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support") } @@ -735,6 +744,20 @@ fn extract_url_path(url: &str) -> String { } } +/// Convert the old SMTP_SSL and SMTP_EXPLICIT_TLS options +fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls: Option<bool>) -> String { + if smtp_explicit_tls.is_some() || smtp_ssl.is_some() { + println!("[DEPRECATED]: `SMTP_SSL` or `SMTP_EXPLICIT_TLS` is set. Please use `SMTP_SECURITY` instead."); + } + if smtp_explicit_tls.is_some() && smtp_explicit_tls.unwrap() { + return "force_tls".to_string(); + } else if smtp_ssl.is_some() && !smtp_ssl.unwrap() { + return "off".to_string(); + } + // Return the default `starttls` in all other cases + "starttls".to_string() +} + impl Config { pub fn load() -> Result<Self, Error> { // Loading from env and file diff --git a/src/mail.rs b/src/mail.rs index df9919d2..362d4aa3 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -30,7 +30,7 @@ fn mailer() -> SmtpTransport { .timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); // Determine security - let smtp_client = if CONFIG.smtp_ssl() || CONFIG.smtp_explicit_tls() { + let smtp_client = if CONFIG.smtp_security() != *"off" { let mut tls_parameters = TlsParameters::builder(host); if CONFIG.smtp_accept_invalid_hostnames() { tls_parameters = tls_parameters.dangerous_accept_invalid_hostnames(true); @@ -40,7 +40,7 @@ fn mailer() -> SmtpTransport { } let tls_parameters = tls_parameters.build().unwrap(); - if CONFIG.smtp_explicit_tls() { + if CONFIG.smtp_security() == *"force_tls" { smtp_client.tls(Tls::Wrapper(tls_parameters)) } else { smtp_client.tls(Tls::Required(tls_parameters)) diff --git a/src/static/scripts/bootstrap-native.js b/src/static/scripts/bootstrap-native.js index 3827dfa6..c00b4e87 100644 --- a/src/static/scripts/bootstrap-native.js +++ b/src/static/scripts/bootstrap-native.js @@ -1,6 +1,6 @@ /*! - * Native JavaScript for Bootstrap v4.0.8 (https://thednp.github.io/bootstrap.native/) - * Copyright 2015-2021 © dnp_theme + * Native JavaScript for Bootstrap v4.1.0 (https://thednp.github.io/bootstrap.native/) + * Copyright 2015-2022 © dnp_theme * Licensed under MIT (https://github.com/thednp/bootstrap.native/blob/master/LICENSE) */ (function (global, factory) { @@ -9,157 +9,599 @@ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.BSN = factory()); })(this, (function () { 'use strict'; - const transitionEndEvent = 'webkitTransition' in document.head.style ? 'webkitTransitionEnd' : 'transitionend'; + /** @type {Record<string, any>} */ + const EventRegistry = {}; + + /** + * The global event listener. + * + * @this {Element | HTMLElement | Window | Document} + * @param {Event} e + * @returns {void} + */ + function globalListener(e) { + const that = this; + const { type } = e; + const oneEvMap = EventRegistry[type] ? [...EventRegistry[type]] : []; + + oneEvMap.forEach((elementsMap) => { + const [element, listenersMap] = elementsMap; + [...listenersMap].forEach((listenerMap) => { + if (element === that) { + const [listener, options] = listenerMap; + listener.apply(element, [e]); + + if (options && options.once) { + removeListener(element, type, listener, options); + } + } + }); + }); + } - const supportTransition = 'webkitTransition' in document.head.style || 'transition' in document.head.style; + /** + * Register a new listener with its options and attach the `globalListener` + * to the target if this is the first listener. + * + * @param {Element | HTMLElement | Window | Document} element + * @param {string} eventType + * @param {EventListenerObject['handleEvent']} listener + * @param {AddEventListenerOptions=} options + */ + const addListener = (element, eventType, listener, options) => { + // get element listeners first + if (!EventRegistry[eventType]) { + EventRegistry[eventType] = new Map(); + } + const oneEventMap = EventRegistry[eventType]; - const transitionDuration = 'webkitTransition' in document.head.style ? 'webkitTransitionDuration' : 'transitionDuration'; + if (!oneEventMap.has(element)) { + oneEventMap.set(element, new Map()); + } + const oneElementMap = oneEventMap.get(element); - const transitionProperty = 'webkitTransition' in document.head.style ? 'webkitTransitionProperty' : 'transitionProperty'; + // get listeners size + const { size } = oneElementMap; - function getElementTransitionDuration(element) { + // register listener with its options + if (oneElementMap) { + oneElementMap.set(listener, options); + } + + // add listener last + if (!size) { + element.addEventListener(eventType, globalListener, options); + } + }; + + /** + * Remove a listener from registry and detach the `globalListener` + * if no listeners are found in the registry. + * + * @param {Element | HTMLElement | Window | Document} element + * @param {string} eventType + * @param {EventListenerObject['handleEvent']} listener + * @param {AddEventListenerOptions=} options + */ + const removeListener = (element, eventType, listener, options) => { + // get listener first + const oneEventMap = EventRegistry[eventType]; + const oneElementMap = oneEventMap && oneEventMap.get(element); + const savedOptions = oneElementMap && oneElementMap.get(listener); + + // also recover initial options + const { options: eventOptions } = savedOptions !== undefined + ? savedOptions + : { options }; + + // unsubscribe second, remove from registry + if (oneElementMap && oneElementMap.has(listener)) oneElementMap.delete(listener); + if (oneEventMap && (!oneElementMap || !oneElementMap.size)) oneEventMap.delete(element); + if (!oneEventMap || !oneEventMap.size) delete EventRegistry[eventType]; + + // remove listener last + if (!oneElementMap || !oneElementMap.size) { + element.removeEventListener(eventType, globalListener, eventOptions); + } + }; + + /** + * Advanced event listener based on subscribe / publish pattern. + * @see https://www.patterns.dev/posts/classic-design-patterns/#observerpatternjavascript + * @see https://gist.github.com/shystruk/d16c0ee7ac7d194da9644e5d740c8338#file-subpub-js + * @see https://hackernoon.com/do-you-still-register-window-event-listeners-in-each-component-react-in-example-31a4b1f6f1c8 + */ + const EventListener = { + on: addListener, + off: removeListener, + globalListener, + registry: EventRegistry, + }; + + /** + * A global namespace for `click` event. + * @type {string} + */ + const mouseclickEvent = 'click'; + + /** + * A global namespace for 'transitionend' string. + * @type {string} + */ + const transitionEndEvent = 'transitionend'; + + /** + * A global namespace for 'transitionDelay' string. + * @type {string} + */ + const transitionDelay = 'transitionDelay'; + + /** + * A global namespace for `transitionProperty` string for modern browsers. + * + * @type {string} + */ + const transitionProperty = 'transitionProperty'; + + /** + * Shortcut for `window.getComputedStyle(element).propertyName` + * static method. + * + * * If `element` parameter is not an `HTMLElement`, `getComputedStyle` + * throws a `ReferenceError`. + * + * @param {HTMLElement | Element} element target + * @param {string} property the css property + * @return {string} the css property value + */ + function getElementStyle(element, property) { const computedStyle = getComputedStyle(element); - const propertyValue = computedStyle[transitionProperty]; - const durationValue = computedStyle[transitionDuration]; + + // @ts-ignore -- must use camelcase strings, + // or non-camelcase strings with `getPropertyValue` + return property in computedStyle ? computedStyle[property] : ''; + } + + /** + * Utility to get the computed `transitionDelay` + * from Element in miliseconds. + * + * @param {HTMLElement | Element} element target + * @return {number} the value in miliseconds + */ + function getElementTransitionDelay(element) { + const propertyValue = getElementStyle(element, transitionProperty); + const delayValue = getElementStyle(element, transitionDelay); + + const delayScale = delayValue.includes('ms') ? 1 : 1000; + const duration = propertyValue && propertyValue !== 'none' + ? parseFloat(delayValue) * delayScale : 0; + + return !Number.isNaN(duration) ? duration : 0; + } + + /** + * A global namespace for 'transitionDuration' string. + * @type {string} + */ + const transitionDuration = 'transitionDuration'; + + /** + * Utility to get the computed `transitionDuration` + * from Element in miliseconds. + * + * @param {HTMLElement | Element} element target + * @return {number} the value in miliseconds + */ + function getElementTransitionDuration(element) { + const propertyValue = getElementStyle(element, transitionProperty); + const durationValue = getElementStyle(element, transitionDuration); const durationScale = durationValue.includes('ms') ? 1 : 1000; - const duration = supportTransition && propertyValue && propertyValue !== 'none' + const duration = propertyValue && propertyValue !== 'none' ? parseFloat(durationValue) * durationScale : 0; return !Number.isNaN(duration) ? duration : 0; } + /** + * Utility to make sure callbacks are consistently + * called when transition ends. + * + * @param {HTMLElement | Element} element target + * @param {EventListener} handler `transitionend` callback + */ function emulateTransitionEnd(element, handler) { let called = 0; const endEvent = new Event(transitionEndEvent); const duration = getElementTransitionDuration(element); + const delay = getElementTransitionDelay(element); if (duration) { - element.addEventListener(transitionEndEvent, function transitionEndWrapper(e) { + /** + * Wrap the handler in on -> off callback + * @type {EventListener} e Event object + */ + const transitionEndWrapper = (e) => { if (e.target === element) { handler.apply(element, [e]); element.removeEventListener(transitionEndEvent, transitionEndWrapper); called = 1; } - }); + }; + element.addEventListener(transitionEndEvent, transitionEndWrapper); setTimeout(() => { if (!called) element.dispatchEvent(endEvent); - }, duration + 17); + }, duration + delay + 17); } else { handler.apply(element, [endEvent]); } } - function queryElement(selector, parent) { - const lookUp = parent && parent instanceof Element ? parent : document; - return selector instanceof Element ? selector : lookUp.querySelector(selector); - } - + /** + * Returns the `document` or the `#document` element. + * @see https://github.com/floating-ui/floating-ui + * @param {(Node | HTMLElement | Element | globalThis)=} node + * @returns {Document} + */ + function getDocument(node) { + if (node instanceof HTMLElement) return node.ownerDocument; + if (node instanceof Window) return node.document; + return window.document; + } + + /** + * A global array of possible `ParentNode`. + */ + const parentNodes = [Document, Element, HTMLElement]; + + /** + * A global array with `Element` | `HTMLElement`. + */ + const elementNodes = [Element, HTMLElement]; + + /** + * Utility to check if target is typeof `HTMLElement`, `Element`, `Node` + * or find one that matches a selector. + * + * @param {HTMLElement | Element | string} selector the input selector or target element + * @param {(HTMLElement | Element | Document)=} parent optional node to look into + * @return {(HTMLElement | Element)?} the `HTMLElement` or `querySelector` result + */ + function querySelector(selector, parent) { + const lookUp = parentNodes.some((x) => parent instanceof x) + ? parent : getDocument(); + + // @ts-ignore + return elementNodes.some((x) => selector instanceof x) + // @ts-ignore + ? selector : lookUp.querySelector(selector); + } + + /** + * Shortcut for `HTMLElement.closest` method which also works + * with children of `ShadowRoot`. The order of the parameters + * is intentional since they're both required. + * + * @see https://stackoverflow.com/q/54520554/803358 + * + * @param {HTMLElement | Element} element Element to look into + * @param {string} selector the selector name + * @return {(HTMLElement | Element)?} the query result + */ + function closest(element, selector) { + return element ? (element.closest(selector) + // @ts-ignore -- break out of `ShadowRoot` + || closest(element.getRootNode().host, selector)) : null; + } + + /** + * Shortcut for `Object.assign()` static method. + * @param {Record<string, any>} obj a target object + * @param {Record<string, any>} source a source object + */ + const ObjectAssign = (obj, source) => Object.assign(obj, source); + + /** + * Check class in `HTMLElement.classList`. + * + * @param {HTMLElement | Element} element target + * @param {string} classNAME to check + * @returns {boolean} + */ function hasClass(element, classNAME) { return element.classList.contains(classNAME); } + /** + * Remove class from `HTMLElement.classList`. + * + * @param {HTMLElement | Element} element target + * @param {string} classNAME to remove + * @returns {void} + */ function removeClass(element, classNAME) { element.classList.remove(classNAME); } - const addEventListener = 'addEventListener'; + /** + * Shortcut for the `Element.dispatchEvent(Event)` method. + * + * @param {HTMLElement | Element} element is the target + * @param {Event} event is the `Event` object + */ + const dispatchEvent = (element, event) => element.dispatchEvent(event); + + /** @type {Map<string, Map<HTMLElement | Element, Record<string, any>>>} */ + const componentData = new Map(); + /** + * An interface for web components background data. + * @see https://github.com/thednp/bootstrap.native/blob/master/src/components/base-component.js + */ + const Data = { + /** + * Sets web components data. + * @param {HTMLElement | Element | string} target target element + * @param {string} component the component's name or a unique key + * @param {Record<string, any>} instance the component instance + */ + set: (target, component, instance) => { + const element = querySelector(target); + if (!element) return; + + if (!componentData.has(component)) { + componentData.set(component, new Map()); + } + + const instanceMap = componentData.get(component); + // @ts-ignore - not undefined, but defined right above + instanceMap.set(element, instance); + }, + + /** + * Returns all instances for specified component. + * @param {string} component the component's name or a unique key + * @returns {Map<HTMLElement | Element, Record<string, any>>?} all the component instances + */ + getAllFor: (component) => { + const instanceMap = componentData.get(component); + + return instanceMap || null; + }, + + /** + * Returns the instance associated with the target. + * @param {HTMLElement | Element | string} target target element + * @param {string} component the component's name or a unique key + * @returns {Record<string, any>?} the instance + */ + get: (target, component) => { + const element = querySelector(target); + const allForC = Data.getAllFor(component); + const instance = element && allForC && allForC.get(element); + + return instance || null; + }, + + /** + * Removes web components data. + * @param {HTMLElement | Element | string} target target element + * @param {string} component the component's name or a unique key + */ + remove: (target, component) => { + const element = querySelector(target); + const instanceMap = componentData.get(component); + if (!instanceMap || !element) return; + + instanceMap.delete(element); + + if (instanceMap.size === 0) { + componentData.delete(component); + } + }, + }; + + /** + * An alias for `Data.get()`. + * @type {SHORTER.getInstance<any>} + */ + const getInstance = (target, component) => Data.get(target, component); + + /** + * Returns a namespaced `CustomEvent` specific to each component. + * @param {string} EventType Event.type + * @param {Record<string, any>=} config Event.options | Event.properties + * @returns {SHORTER.OriginalEvent} a new namespaced event + */ + function OriginalEvent(EventType, config) { + const OriginalCustomEvent = new CustomEvent(EventType, { + cancelable: true, bubbles: true, + }); - const removeEventListener = 'removeEventListener'; + if (config instanceof Object) { + ObjectAssign(OriginalCustomEvent, config); + } + return OriginalCustomEvent; + } + /** + * Global namespace for most components `fade` class. + */ const fadeClass = 'fade'; + /** + * Global namespace for most components `show` class. + */ const showClass = 'show'; + /** + * Global namespace for most components `dismiss` option. + */ const dataBsDismiss = 'data-bs-dismiss'; - function bootstrapCustomEvent(namespacedEventType, eventProperties) { - const OriginalCustomEvent = new CustomEvent(namespacedEventType, { cancelable: true }); + /** @type {string} */ + const alertString = 'alert'; - if (eventProperties instanceof Object) { - Object.keys(eventProperties).forEach((key) => { - Object.defineProperty(OriginalCustomEvent, key, { - value: eventProperties[key], - }); - }); - } - return OriginalCustomEvent; - } + /** @type {string} */ + const alertComponent = 'Alert'; + /** + * Shortcut for `HTMLElement.getAttribute()` method. + * @param {HTMLElement | Element} element target element + * @param {string} attribute attribute name + * @returns {string?} attribute value + */ + const getAttribute = (element, attribute) => element.getAttribute(attribute); + + /** + * The raw value or a given component option. + * + * @typedef {string | HTMLElement | Function | number | boolean | null} niceValue + */ + + /** + * Utility to normalize component options + * + * @param {any} value the input value + * @return {niceValue} the normalized value + */ function normalizeValue(value) { - if (value === 'true') { + if (value === 'true') { // boolean return true; } - if (value === 'false') { + if (value === 'false') { // boolean return false; } - if (!Number.isNaN(+value)) { + if (!Number.isNaN(+value)) { // number return +value; } - if (value === '' || value === 'null') { + if (value === '' || value === 'null') { // null return null; } - // string / function / Element / Object + // string / function / HTMLElement / object return value; } + /** + * Shortcut for `Object.keys()` static method. + * @param {Record<string, any>} obj a target object + * @returns {string[]} + */ + const ObjectKeys = (obj) => Object.keys(obj); + + /** + * Shortcut for `String.toLowerCase()`. + * + * @param {string} source input string + * @returns {string} lowercase output string + */ + const toLowerCase = (source) => source.toLowerCase(); + + /** + * Utility to normalize component options. + * + * @param {HTMLElement | Element} element target + * @param {Record<string, any>} defaultOps component default options + * @param {Record<string, any>} inputOps component instance options + * @param {string=} ns component namespace + * @return {Record<string, any>} normalized component options object + */ function normalizeOptions(element, defaultOps, inputOps, ns) { + // @ts-ignore -- our targets are always `HTMLElement` + const data = { ...element.dataset }; + /** @type {Record<string, any>} */ const normalOps = {}; + /** @type {Record<string, any>} */ const dataOps = {}; - const data = { ...element.dataset }; + const title = 'title'; - Object.keys(data) - .forEach((k) => { - const key = k.includes(ns) - ? k.replace(ns, '').replace(/[A-Z]/, (match) => match.toLowerCase()) - : k; + ObjectKeys(data).forEach((k) => { + const key = ns && k.includes(ns) + ? k.replace(ns, '').replace(/[A-Z]/, (match) => toLowerCase(match)) + : k; - dataOps[key] = normalizeValue(data[k]); - }); + dataOps[key] = normalizeValue(data[k]); + }); - Object.keys(inputOps) - .forEach((k) => { - inputOps[k] = normalizeValue(inputOps[k]); - }); + ObjectKeys(inputOps).forEach((k) => { + inputOps[k] = normalizeValue(inputOps[k]); + }); - Object.keys(defaultOps) - .forEach((k) => { - if (k in inputOps) { - normalOps[k] = inputOps[k]; - } else if (k in dataOps) { - normalOps[k] = dataOps[k]; - } else { - normalOps[k] = defaultOps[k]; - } - }); + ObjectKeys(defaultOps).forEach((k) => { + if (k in inputOps) { + normalOps[k] = inputOps[k]; + } else if (k in dataOps) { + normalOps[k] = dataOps[k]; + } else { + normalOps[k] = k === title + ? getAttribute(element, title) + : defaultOps[k]; + } + }); return normalOps; } + var version = "4.1.0"; + + const Version = version; + /* Native JavaScript for Bootstrap 5 | Base Component ----------------------------------------------------- */ + /** Returns a new `BaseComponent` instance. */ class BaseComponent { - constructor(name, target, defaults, config) { + /** + * @param {HTMLElement | Element | string} target `Element` or selector string + * @param {BSN.ComponentOptions=} config component instance options + */ + constructor(target, config) { const self = this; - const element = queryElement(target); + const element = querySelector(target); - if (element[name]) element[name].dispose(); + if (!element) { + throw Error(`${self.name} Error: "${target}" is not a valid selector.`); + } + + /** @static @type {BSN.ComponentOptions} */ + self.options = {}; + + const prevInstance = Data.get(element, self.name); + if (prevInstance) prevInstance.dispose(); + + /** @type {HTMLElement | Element} */ self.element = element; - if (defaults && Object.keys(defaults).length) { - self.options = normalizeOptions(element, defaults, (config || {}), 'bs'); + if (self.defaults && Object.keys(self.defaults).length) { + self.options = normalizeOptions(element, self.defaults, (config || {}), 'bs'); } - element[name] = self; + + Data.set(element, self.name, self); } - dispose(name) { + /* eslint-disable */ + /** @static */ + get version() { return Version; } + /* eslint-enable */ + + /** @static */ + get name() { return this.constructor.name; } + + /** @static */ + // @ts-ignore + get defaults() { return this.constructor.defaults; } + + /** + * Removes component from target element; + */ + dispose() { const self = this; - self.element[name] = null; - Object.keys(self).forEach((prop) => { self[prop] = null; }); + Data.remove(self.element, self.name); + // @ts-ignore + ObjectKeys(self).forEach((prop) => { self[prop] = null; }); } } @@ -168,24 +610,39 @@ // ALERT PRIVATE GC // ================ - const alertString = 'alert'; - const alertComponent = 'Alert'; const alertSelector = `.${alertString}`; const alertDismissSelector = `[${dataBsDismiss}="${alertString}"]`; + /** + * Static method which returns an existing `Alert` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Alert>} + */ + const getAlertInstance = (element) => getInstance(element, alertComponent); + + /** + * An `Alert` initialization callback. + * @type {BSN.InitCallback<Alert>} + */ + const alertInitCallback = (element) => new Alert(element); + // ALERT CUSTOM EVENTS // =================== - const closeAlertEvent = bootstrapCustomEvent(`close.bs.${alertString}`); - const closedAlertEvent = bootstrapCustomEvent(`closed.bs.${alertString}`); + const closeAlertEvent = OriginalEvent(`close.bs.${alertString}`); + const closedAlertEvent = OriginalEvent(`closed.bs.${alertString}`); - // ALERT EVENT HANDLERS - // ==================== + // ALERT EVENT HANDLER + // =================== + /** + * Alert `transitionend` callback. + * @param {Alert} self target Alert instance + */ function alertTransitionEnd(self) { - const { element, relatedTarget } = self; + const { element } = self; toggleAlertHandler(self); - if (relatedTarget) closedAlertEvent.relatedTarget = relatedTarget; - element.dispatchEvent(closedAlertEvent); + dispatchEvent(element, closedAlertEvent); self.dispose(); element.remove(); @@ -193,16 +650,24 @@ // ALERT PRIVATE METHOD // ==================== + /** + * Toggle on / off the `click` event listener. + * @param {Alert} self the target alert instance + * @param {boolean=} add when `true`, event listener is added + */ function toggleAlertHandler(self, add) { - const action = add ? addEventListener : removeEventListener; - if (self.dismiss) self.dismiss[action]('click', self.close); + const action = add ? addListener : removeListener; + const { dismiss } = self; + if (dismiss) action(dismiss, mouseclickEvent, self.close); } // ALERT DEFINITION // ================ + /** Creates a new Alert instance. */ class Alert extends BaseComponent { + /** @param {HTMLElement | Element | string} target element or selector */ constructor(target) { - super(alertComponent, target); + super(target); // bind const self = this; @@ -210,28 +675,39 @@ const { element } = self; // the dismiss button - self.dismiss = queryElement(alertDismissSelector, element); - self.relatedTarget = null; + /** @static @type {(HTMLElement | Element)?} */ + self.dismiss = querySelector(alertDismissSelector, element); // add event listener - toggleAlertHandler(self, 1); + toggleAlertHandler(self, true); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return alertComponent; } + /* eslint-enable */ + // ALERT PUBLIC METHODS // ==================== + /** + * Public method that hides the `.alert` element from the user, + * disposes the instance once animation is complete, then + * removes the element from the DOM. + * + * @param {Event=} e most likely the `click` event + * @this {Alert} the `Alert` instance or `EventTarget` + */ close(e) { - const target = e ? e.target : null; - const self = e - ? e.target.closest(alertSelector)[alertComponent] - : this; + // @ts-ignore + const self = e ? getAlertInstance(closest(this, alertSelector)) : this; + if (!self) return; const { element } = self; - if (self && element && hasClass(element, showClass)) { - if (target) { - closeAlertEvent.relatedTarget = target; - self.relatedTarget = target; - } - element.dispatchEvent(closeAlertEvent); + if (hasClass(element, showClass)) { + dispatchEvent(element, closeAlertEvent); if (closeAlertEvent.defaultPrevented) return; removeClass(element, showClass); @@ -242,123 +718,452 @@ } } + /** Remove the component from target element. */ dispose() { toggleAlertHandler(this); - super.dispose(alertComponent); + super.dispose(); } } - Alert.init = { - component: alertComponent, + ObjectAssign(Alert, { selector: alertSelector, - constructor: Alert, - }; + init: alertInitCallback, + getInstance: getAlertInstance, + }); + + /** + * A global namespace for aria-pressed. + * @type {string} + */ + const ariaPressed = 'aria-pressed'; + /** + * Shortcut for `HTMLElement.setAttribute()` method. + * @param {HTMLElement | Element} element target element + * @param {string} attribute attribute name + * @param {string} value attribute value + * @returns {void} + */ + const setAttribute = (element, attribute, value) => element.setAttribute(attribute, value); + + /** + * Add class to `HTMLElement.classList`. + * + * @param {HTMLElement | Element} element target + * @param {string} classNAME to add + * @returns {void} + */ function addClass(element, classNAME) { element.classList.add(classNAME); } + /** + * Global namespace for most components active class. + */ const activeClass = 'active'; + /** + * Global namespace for most components `toggle` option. + */ const dataBsToggle = 'data-bs-toggle'; + /** @type {string} */ + const buttonString = 'button'; + + /** @type {string} */ + const buttonComponent = 'Button'; + /* Native JavaScript for Bootstrap 5 | Button ---------------------------------------------*/ // BUTTON PRIVATE GC // ================= - const buttonString = 'button'; - const buttonComponent = 'Button'; const buttonSelector = `[${dataBsToggle}="${buttonString}"]`; - const ariaPressed = 'aria-pressed'; + + /** + * Static method which returns an existing `Button` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Button>} + */ + const getButtonInstance = (element) => getInstance(element, buttonComponent); + + /** + * A `Button` initialization callback. + * @type {BSN.InitCallback<Button>} + */ + const buttonInitCallback = (element) => new Button(element); // BUTTON PRIVATE METHOD // ===================== + /** + * Toggles on/off the `click` event listener. + * @param {Button} self the `Button` instance + * @param {boolean=} add when `true`, event listener is added + */ function toggleButtonHandler(self, add) { - const action = add ? addEventListener : removeEventListener; - self.element[action]('click', self.toggle); + const action = add ? addListener : removeListener; + action(self.element, mouseclickEvent, self.toggle); } // BUTTON DEFINITION // ================= + /** Creates a new `Button` instance. */ class Button extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target usually a `.btn` element + */ constructor(target) { - super(buttonComponent, target); + super(target); const self = this; // initialization element const { element } = self; // set initial state + /** @type {boolean} */ self.isActive = hasClass(element, activeClass); - element.setAttribute(ariaPressed, !!self.isActive); + setAttribute(element, ariaPressed, `${!!self.isActive}`); // add event listener - toggleButtonHandler(self, 1); + toggleButtonHandler(self, true); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return buttonComponent; } + /* eslint-enable */ + // BUTTON PUBLIC METHODS // ===================== + /** + * Toggles the state of the target button. + * @param {MouseEvent} e usually `click` Event object + */ toggle(e) { if (e) e.preventDefault(); - const self = e ? this[buttonComponent] : this; + // @ts-ignore + const self = e ? getButtonInstance(this) : this; + if (!self) return; const { element } = self; if (hasClass(element, 'disabled')) return; - self.isActive = hasClass(element, activeClass); const { isActive } = self; const action = isActive ? removeClass : addClass; - const ariaValue = isActive ? 'false' : 'true'; action(element, activeClass); - element.setAttribute(ariaPressed, ariaValue); + setAttribute(element, ariaPressed, isActive ? 'false' : 'true'); } + /** Removes the `Button` component from the target element. */ dispose() { toggleButtonHandler(this); - super.dispose(buttonComponent); + super.dispose(); } } - Button.init = { - component: buttonComponent, + ObjectAssign(Button, { selector: buttonSelector, - constructor: Button, + init: buttonInitCallback, + getInstance: getButtonInstance, + }); + + /** + * A global namespace for `mouseenter` event. + * @type {string} + */ + const mouseenterEvent = 'mouseenter'; + + /** + * A global namespace for `mouseleave` event. + * @type {string} + */ + const mouseleaveEvent = 'mouseleave'; + + /** + * A global namespace for `keydown` event. + * @type {string} + */ + const keydownEvent = 'keydown'; + + /** + * A global namespace for `touchmove` event. + * @type {string} + */ + const touchmoveEvent = 'touchmove'; + + /** + * A global namespace for `touchend` event. + * @type {string} + */ + const touchendEvent = 'touchend'; + + /** + * A global namespace for `touchstart` event. + * @type {string} + */ + const touchstartEvent = 'touchstart'; + + /** + * A global namespace for `ArrowLeft` key. + * @type {string} e.which = 37 equivalent + */ + const keyArrowLeft = 'ArrowLeft'; + + /** + * A global namespace for `ArrowRight` key. + * @type {string} e.which = 39 equivalent + */ + const keyArrowRight = 'ArrowRight'; + + /** + * Returns the `Window` object of a target node. + * @see https://github.com/floating-ui/floating-ui + * + * @param {(Node | HTMLElement | Element | Window)=} node target node + * @returns {globalThis} + */ + function getWindow(node) { + if (node == null) { + return window; + } + + if (!(node instanceof Window)) { + const { ownerDocument } = node; + return ownerDocument ? ownerDocument.defaultView || window : window; + } + + // @ts-ignore + return node; + } + + /** + * Returns the bounding client rect of a target `HTMLElement`. + * + * @see https://github.com/floating-ui/floating-ui + * + * @param {HTMLElement | Element} element event.target + * @param {boolean=} includeScale when *true*, the target scale is also computed + * @returns {SHORTER.BoundingClientRect} the bounding client rect object + */ + function getBoundingClientRect(element, includeScale) { + const { + width, height, top, right, bottom, left, + } = element.getBoundingClientRect(); + let scaleX = 1; + let scaleY = 1; + + if (includeScale && element instanceof HTMLElement) { + const { offsetWidth, offsetHeight } = element; + scaleX = offsetWidth > 0 ? Math.round(width) / offsetWidth || 1 : 1; + scaleY = offsetHeight > 0 ? Math.round(height) / offsetHeight || 1 : 1; + } + + return { + width: width / scaleX, + height: height / scaleY, + top: top / scaleY, + right: right / scaleX, + bottom: bottom / scaleY, + left: left / scaleX, + x: left / scaleX, + y: top / scaleY, + }; + } + + /** + * Returns the `document.documentElement` or the `<html>` element. + * + * @param {(Node | HTMLElement | Element | globalThis)=} node + * @returns {HTMLElement | HTMLHtmlElement} + */ + function getDocumentElement(node) { + return getDocument(node).documentElement; + } + + /** + * Utility to determine if an `HTMLElement` + * is partially visible in viewport. + * + * @param {HTMLElement | Element} element target + * @return {boolean} the query result + */ + const isElementInScrollRange = (element) => { + const { top, bottom } = getBoundingClientRect(element); + const { clientHeight } = getDocumentElement(element); + // checks bottom && top + return top <= clientHeight && bottom >= 0; }; - const supportPassive = (() => { - let result = false; - try { - const opts = Object.defineProperty({}, 'passive', { - get() { - result = true; - return result; - }, - }); - document[addEventListener]('DOMContentLoaded', function wrap() { - document[removeEventListener]('DOMContentLoaded', wrap, opts); - }, opts); - } catch (e) { - throw Error('Passive events are not supported'); - } + /** + * Checks if a page is Right To Left. + * @param {(HTMLElement | Element)=} node the target + * @returns {boolean} the query result + */ + const isRTL = (node) => getDocumentElement(node).dir === 'rtl'; + + /** + * A shortcut for `(document|Element).querySelectorAll`. + * + * @param {string} selector the input selector + * @param {(HTMLElement | Element | Document | Node)=} parent optional node to look into + * @return {NodeListOf<HTMLElement | Element>} the query result + */ + function querySelectorAll(selector, parent) { + const lookUp = parent && parentNodes + .some((x) => parent instanceof x) ? parent : getDocument(); + // @ts-ignore -- `ShadowRoot` is also a node + return lookUp.querySelectorAll(selector); + } + + /** + * Shortcut for `HTMLElement.getElementsByClassName` method. Some `Node` elements + * like `ShadowRoot` do not support `getElementsByClassName`. + * + * @param {string} selector the class name + * @param {(HTMLElement | Element | Document)=} parent optional Element to look into + * @return {HTMLCollectionOf<HTMLElement | Element>} the 'HTMLCollection' + */ + function getElementsByClassName(selector, parent) { + const lookUp = parent && parentNodes.some((x) => parent instanceof x) + ? parent : getDocument(); + return lookUp.getElementsByClassName(selector); + } + + /** @type {Map<HTMLElement | Element, any>} */ + const TimeCache = new Map(); + /** + * An interface for one or more `TimerHandler`s per `Element`. + * @see https://github.com/thednp/navbar.js/ + */ + const Timer = { + /** + * Sets a new timeout timer for an element, or element -> key association. + * @param {HTMLElement | Element | string} target target element + * @param {ReturnType<TimerHandler>} callback the callback + * @param {number} delay the execution delay + * @param {string=} key a unique key + */ + set: (target, callback, delay, key) => { + const element = querySelector(target); + + if (!element) return; + + if (key && key.length) { + if (!TimeCache.has(element)) { + TimeCache.set(element, new Map()); + } + const keyTimers = TimeCache.get(element); + keyTimers.set(key, setTimeout(callback, delay)); + } else { + TimeCache.set(element, setTimeout(callback, delay)); + } + }, + + /** + * Returns the timer associated with the target. + * @param {HTMLElement | Element | string} target target element + * @param {string=} key a unique + * @returns {number?} the timer + */ + get: (target, key) => { + const element = querySelector(target); + + if (!element) return null; + const keyTimers = TimeCache.get(element); + + if (key && key.length && keyTimers && keyTimers.get) { + return keyTimers.get(key) || null; + } + return keyTimers || null; + }, + + /** + * Clears the element's timer. + * @param {HTMLElement | Element | string} target target element + * @param {string=} key a unique key + */ + clear: (target, key) => { + const element = querySelector(target); + + if (!element) return; + + if (key && key.length) { + const keyTimers = TimeCache.get(element); + + if (keyTimers && keyTimers.get) { + clearTimeout(keyTimers.get(key)); + keyTimers.delete(key); + if (keyTimers.size === 0) { + TimeCache.delete(element); + } + } + } else { + clearTimeout(TimeCache.get(element)); + TimeCache.delete(element); + } + }, + }; - return result; - })(); + /** + * Utility to force re-paint of an `HTMLElement` target. + * + * @param {HTMLElement | Element} element is the target + * @return {number} the `Element.offsetHeight` value + */ + // @ts-ignore + const reflow = (element) => element.offsetHeight; + + /** + * A global namespace for most scroll event listeners. + * @type {Partial<AddEventListenerOptions>} + */ + const passiveHandler = { passive: true }; + + /** + * Global namespace for most components `target` option. + */ + const dataBsTarget = 'data-bs-target'; - // general event options + /** @type {string} */ + const carouselString = 'carousel'; - var passiveHandler = supportPassive ? { passive: true } : false; + /** @type {string} */ + const carouselComponent = 'Carousel'; - function reflow(element) { - return element.offsetHeight; - } + /** + * Global namespace for most components `parent` option. + */ + const dataBsParent = 'data-bs-parent'; + + /** + * Global namespace for most components `container` option. + */ + const dataBsContainer = 'data-bs-container'; - function isElementInScrollRange(element) { - const bcr = element.getBoundingClientRect(); - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - return bcr.top <= viewportHeight && bcr.bottom >= 0; // bottom && top + /** + * Returns the `Element` that THIS one targets + * via `data-bs-target`, `href`, `data-bs-parent` or `data-bs-container`. + * + * @param {HTMLElement | Element} element the target element + * @returns {(HTMLElement | Element)?} the query result + */ + function getTargetElement(element) { + const targetAttr = [dataBsTarget, dataBsParent, dataBsContainer, 'href']; + const doc = getDocument(element); + + return targetAttr.map((att) => { + const attValue = getAttribute(element, att); + if (attValue) { + return att === dataBsParent ? closest(element, attValue) : querySelector(attValue, doc); + } + return null; + }).filter((x) => x)[0]; } /* Native JavaScript for Bootstrap 5 | Carousel @@ -366,167 +1171,224 @@ // CAROUSEL PRIVATE GC // =================== - const carouselString = 'carousel'; - const carouselComponent = 'Carousel'; const carouselSelector = `[data-bs-ride="${carouselString}"]`; - const carouselControl = `${carouselString}-control`; const carouselItem = `${carouselString}-item`; const dataBsSlideTo = 'data-bs-slide-to'; + const dataBsSlide = 'data-bs-slide'; const pausedClass = 'paused'; - const defaultCarouselOptions = { - pause: 'hover', // 'boolean|string' - keyboard: false, // 'boolean' - touch: true, // 'boolean' - interval: 5000, // 'boolean|number' + + const carouselDefaults = { + pause: 'hover', + keyboard: false, + touch: true, + interval: 5000, }; + + /** + * Static method which returns an existing `Carousel` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Carousel>} + */ + const getCarouselInstance = (element) => getInstance(element, carouselComponent); + + /** + * A `Carousel` initialization callback. + * @type {BSN.InitCallback<Carousel>} + */ + const carouselInitCallback = (element) => new Carousel(element); + let startX = 0; let currentX = 0; let endX = 0; // CAROUSEL CUSTOM EVENTS // ====================== - const carouselSlideEvent = bootstrapCustomEvent(`slide.bs.${carouselString}`); - const carouselSlidEvent = bootstrapCustomEvent(`slid.bs.${carouselString}`); + const carouselSlideEvent = OriginalEvent(`slide.bs.${carouselString}`); + const carouselSlidEvent = OriginalEvent(`slid.bs.${carouselString}`); // CAROUSEL EVENT HANDLERS // ======================= + /** + * The `transitionend` event listener of the `Carousel`. + * @param {Carousel} self the `Carousel` instance + */ function carouselTransitionEndHandler(self) { const { - index, direction, element, slides, options, isAnimating, + index, direction, element, slides, options, } = self; // discontinue disposed instances - if (isAnimating && element[carouselComponent]) { + if (self.isAnimating && getCarouselInstance(element)) { const activeItem = getActiveIndex(self); const orientation = direction === 'left' ? 'next' : 'prev'; const directionClass = direction === 'left' ? 'start' : 'end'; - self.isAnimating = false; addClass(slides[index], activeClass); - removeClass(slides[activeItem], activeClass); - removeClass(slides[index], `${carouselItem}-${orientation}`); removeClass(slides[index], `${carouselItem}-${directionClass}`); + + removeClass(slides[activeItem], activeClass); removeClass(slides[activeItem], `${carouselItem}-${directionClass}`); - element.dispatchEvent(carouselSlidEvent); + dispatchEvent(element, carouselSlidEvent); + Timer.clear(element, dataBsSlide); // check for element, might have been disposed - if (!document.hidden && options.interval - && !hasClass(element, pausedClass)) { + if (!getDocument(element).hidden && options.interval + && !self.isPaused) { self.cycle(); } } } - function carouselPauseHandler(e) { - const eventTarget = e.target; - const self = eventTarget.closest(carouselSelector)[carouselComponent]; - const { element, isAnimating } = self; + /** + * Handles the `mouseenter` / `touchstart` events when *options.pause* + * is set to `hover`. + * + * @this {HTMLElement | Element} + */ + function carouselPauseHandler() { + const element = this; + const self = getCarouselInstance(element); - if (!hasClass(element, pausedClass)) { + if (self && !self.isPaused && !Timer.get(element, pausedClass)) { addClass(element, pausedClass); - if (!isAnimating) { - clearInterval(self.timer); - self.timer = null; - } } } - function carouselResumeHandler(e) { - const eventTarget = e.target; - const self = eventTarget.closest(carouselSelector)[carouselComponent]; - const { isPaused, isAnimating, element } = self; - - if (!isPaused && hasClass(element, pausedClass)) { - removeClass(element, pausedClass); + /** + * Handles the `mouseleave` / `touchend` events when *options.pause* + * is set to `hover`. + * + * @this {HTMLElement | Element} + */ + function carouselResumeHandler() { + const element = this; + const self = getCarouselInstance(element); - if (!isAnimating) { - clearInterval(self.timer); - self.timer = null; - self.cycle(); - } + if (self && self.isPaused && !Timer.get(element, pausedClass)) { + self.cycle(); } } + /** + * Handles the `click` event for the `Carousel` indicators. + * + * @this {HTMLElement} + * @param {MouseEvent} e the `Event` object + */ function carouselIndicatorHandler(e) { e.preventDefault(); - const { target } = e; - const self = target.closest(carouselSelector)[carouselComponent]; + const indicator = this; + const element = closest(indicator, carouselSelector) || getTargetElement(indicator); + if (!element) return; + const self = getCarouselInstance(element); - if (self.isAnimating) return; + if (!self || self.isAnimating) return; - const newIndex = target.getAttribute(dataBsSlideTo); + // @ts-ignore + const newIndex = +getAttribute(indicator, dataBsSlideTo); - if (target && !hasClass(target, activeClass) // event target is not active - && newIndex) { // AND has the specific attribute - self.to(+newIndex); // do the slide + if (indicator && !hasClass(indicator, activeClass) // event target is not active + && !Number.isNaN(newIndex)) { // AND has the specific attribute + self.to(newIndex); // do the slide } } + /** + * Handles the `click` event for the `Carousel` arrows. + * + * @this {HTMLElement} + * @param {MouseEvent} e the `Event` object + */ function carouselControlsHandler(e) { e.preventDefault(); - const that = this; - const self = that.closest(carouselSelector)[carouselComponent]; - const { controls } = self; + const control = this; + const element = closest(control, carouselSelector) || getTargetElement(control); + const self = element && getCarouselInstance(element); + if (!self || self.isAnimating) return; + const orientation = getAttribute(control, dataBsSlide); - if (controls[1] && that === controls[1]) { + if (orientation === 'next') { self.next(); - } else if (controls[1] && that === controls[0]) { + } else if (orientation === 'prev') { self.prev(); } } - function carouselKeyHandler({ which }) { - const [element] = Array.from(document.querySelectorAll(carouselSelector)) + /** + * Handles the keyboard `keydown` event for the visible `Carousel` elements. + * + * @param {KeyboardEvent} e the `Event` object + */ + function carouselKeyHandler({ code }) { + const [element] = [...querySelectorAll(carouselSelector)] .filter((x) => isElementInScrollRange(x)); - if (!element) return; - const self = element[carouselComponent]; + const self = getCarouselInstance(element); + if (!self) return; + const RTL = isRTL(); + const arrowKeyNext = !RTL ? keyArrowRight : keyArrowLeft; + const arrowKeyPrev = !RTL ? keyArrowLeft : keyArrowRight; - switch (which) { - case 39: - self.next(); - break; - case 37: - self.prev(); - break; - } + if (code === arrowKeyPrev) self.prev(); + else if (code === arrowKeyNext) self.next(); } // CAROUSEL TOUCH HANDLERS // ======================= + /** + * Handles the `touchdown` event for the `Carousel` element. + * + * @this {HTMLElement | Element} + * @param {TouchEvent} e the `Event` object + */ function carouselTouchDownHandler(e) { const element = this; - const self = element[carouselComponent]; + const self = getCarouselInstance(element); if (!self || self.isTouch) { return; } startX = e.changedTouches[0].pageX; + // @ts-ignore if (element.contains(e.target)) { self.isTouch = true; - toggleCarouselTouchHandlers(self, 1); + toggleCarouselTouchHandlers(self, true); } } + /** + * Handles the `touchmove` event for the `Carousel` element. + * + * @this {HTMLElement | Element} + * @param {TouchEvent} e + */ function carouselTouchMoveHandler(e) { const { changedTouches, type } = e; - const self = this[carouselComponent]; + const self = getCarouselInstance(this); if (!self || !self.isTouch) { return; } currentX = changedTouches[0].pageX; // cancel touch if more than one changedTouches detected - if (type === 'touchmove' && changedTouches.length > 1) { + if (type === touchmoveEvent && changedTouches.length > 1) { e.preventDefault(); } } + /** + * Handles the `touchend` event for the `Carousel` element. + * + * @this {HTMLElement | Element} + + * @param {TouchEvent} e + */ function carouselTouchEndHandler(e) { const element = this; - const self = element[carouselComponent]; + const self = getCarouselInstance(element); if (!self || !self.isTouch) { return; } @@ -534,6 +1396,7 @@ if (self.isTouch) { // the event target is outside the carousel OR carousel doens't include the related target + // @ts-ignore if ((!element.contains(e.target) || !element.contains(e.relatedTarget)) && Math.abs(startX - endX) < 75) { // AND swipe distance is less than 75px // when the above conditions are satisfied, no need to continue @@ -554,75 +1417,108 @@ // CAROUSEL PRIVATE METHODS // ======================== - function activateCarouselIndicator(self, pageIndex) { // indicators + /** + * Sets active indicator for the `Carousel` instance. + * @param {Carousel} self the `Carousel` instance + * @param {number} pageIndex the index of the new active indicator + */ + function activateCarouselIndicator(self, pageIndex) { const { indicators } = self; - Array.from(indicators).forEach((x) => removeClass(x, activeClass)); + [...indicators].forEach((x) => removeClass(x, activeClass)); + if (self.indicators[pageIndex]) addClass(indicators[pageIndex], activeClass); } + /** + * Toggles the touch event listeners for a given `Carousel` instance. + * @param {Carousel} self the `Carousel` instance + * @param {boolean=} add when `TRUE` event listeners are added + */ function toggleCarouselTouchHandlers(self, add) { const { element } = self; - const action = add ? addEventListener : removeEventListener; - element[action]('touchmove', carouselTouchMoveHandler, passiveHandler); - element[action]('touchend', carouselTouchEndHandler, passiveHandler); + const action = add ? addListener : removeListener; + action(element, touchmoveEvent, carouselTouchMoveHandler, passiveHandler); + action(element, touchendEvent, carouselTouchEndHandler, passiveHandler); } + /** + * Toggles all event listeners for a given `Carousel` instance. + * @param {Carousel} self the `Carousel` instance + * @param {boolean=} add when `TRUE` event listeners are added + */ function toggleCarouselHandlers(self, add) { const { - element, options, slides, controls, indicator, + element, options, slides, controls, indicators, } = self; const { touch, pause, interval, keyboard, } = options; - const action = add ? addEventListener : removeEventListener; + const action = add ? addListener : removeListener; if (pause && interval) { - element[action]('mouseenter', carouselPauseHandler); - element[action]('mouseleave', carouselResumeHandler); - element[action]('touchstart', carouselPauseHandler, passiveHandler); - element[action]('touchend', carouselResumeHandler, passiveHandler); + action(element, mouseenterEvent, carouselPauseHandler); + action(element, mouseleaveEvent, carouselResumeHandler); + action(element, touchstartEvent, carouselPauseHandler, passiveHandler); + action(element, touchendEvent, carouselResumeHandler, passiveHandler); } if (touch && slides.length > 1) { - element[action]('touchstart', carouselTouchDownHandler, passiveHandler); + action(element, touchstartEvent, carouselTouchDownHandler, passiveHandler); } - controls.forEach((arrow) => { - if (arrow) arrow[action]('click', carouselControlsHandler); - }); + if (controls.length) { + controls.forEach((arrow) => { + if (arrow) action(arrow, mouseclickEvent, carouselControlsHandler); + }); + } - if (indicator) indicator[action]('click', carouselIndicatorHandler); - if (keyboard) window[action]('keydown', carouselKeyHandler); + if (indicators.length) { + indicators.forEach((indicator) => { + action(indicator, mouseclickEvent, carouselIndicatorHandler); + }); + } + // @ts-ignore + if (keyboard) action(getWindow(element), keydownEvent, carouselKeyHandler); } + /** + * Returns the index of the current active item. + * @param {Carousel} self the `Carousel` instance + * @returns {number} the query result + */ function getActiveIndex(self) { const { slides, element } = self; - return Array.from(slides) - .indexOf(element.getElementsByClassName(`${carouselItem} ${activeClass}`)[0]) || 0; + const activeItem = querySelector(`.${carouselItem}.${activeClass}`, element); + // @ts-ignore + return [...slides].indexOf(activeItem); } // CAROUSEL DEFINITION // =================== + /** Creates a new `Carousel` instance. */ class Carousel extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target mostly a `.carousel` element + * @param {BSN.Options.Carousel=} config instance options + */ constructor(target, config) { - super(carouselComponent, target, defaultCarouselOptions, config); + super(target, config); // bind const self = this; // additional properties - self.timer = null; - self.direction = 'left'; - self.isPaused = false; - self.isAnimating = false; + /** @type {string} */ + self.direction = isRTL() ? 'right' : 'left'; + /** @type {number} */ self.index = 0; - self.timer = null; + /** @type {boolean} */ self.isTouch = false; // initialization element const { element } = self; // carousel elements // a LIVE collection is prefferable - self.slides = element.getElementsByClassName(carouselItem); + self.slides = getElementsByClassName(carouselItem, element); const { slides } = self; // invalidate when not enough items @@ -630,20 +1526,26 @@ if (slides.length < 2) { return; } self.controls = [ - queryElement(`.${carouselControl}-prev`, element), - queryElement(`.${carouselControl}-next`, element), + ...querySelectorAll(`[${dataBsSlide}]`, element), + ...querySelectorAll(`[${dataBsSlide}][${dataBsTarget}="#${element.id}"]`), ]; + /** @type {(HTMLElement | Element)?} */ + self.indicator = querySelector(`.${carouselString}-indicators`, element); + // a LIVE collection is prefferable - self.indicator = queryElement('.carousel-indicators', element); - self.indicators = (self.indicator && self.indicator.querySelectorAll(`[${dataBsSlideTo}]`)) || []; + /** @type {(HTMLElement | Element)[]} */ + self.indicators = [ + ...(self.indicator ? querySelectorAll(`[${dataBsSlideTo}]`, self.indicator) : []), + ...querySelectorAll(`[${dataBsSlideTo}][${dataBsTarget}="#${element.id}"]`), + ]; // set JavaScript and DATA API options const { options } = self; // don't use TRUE as interval, it's actually 0, use the default 5000ms better self.options.interval = options.interval === true - ? defaultCarouselOptions.interval + ? carouselDefaults.interval : options.interval; // set first slide active if none @@ -653,73 +1555,106 @@ } // attach event handlers - toggleCarouselHandlers(self, 1); + toggleCarouselHandlers(self, true); // start to cycle if interval is set if (options.interval) self.cycle(); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return carouselComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return carouselDefaults; } + /* eslint-enable */ + + /** + * Check if instance is paused. + * @returns {boolean} + */ + get isPaused() { + return hasClass(this.element, pausedClass); + } + + /** + * Check if instance is animating. + * @returns {boolean} + */ + get isAnimating() { + return querySelector(`.${carouselItem}-next,.${carouselItem}-prev`, this.element) !== null; + } + // CAROUSEL PUBLIC METHODS // ======================= + /** Slide automatically through items. */ cycle() { const self = this; - const { isPaused, element, options } = self; - if (self.timer) { - clearInterval(self.timer); - self.timer = null; - } + const { element, options, isPaused } = self; + Timer.clear(element, carouselString); if (isPaused) { + Timer.clear(element, pausedClass); removeClass(element, pausedClass); - self.isPaused = !isPaused; } - self.timer = setInterval(() => { - if (isElementInScrollRange(element)) { + Timer.set(element, () => { + if (!self.isPaused && isElementInScrollRange(element)) { self.index += 1; self.to(self.index); } - }, options.interval); + }, options.interval, carouselString); } + /** Pause the automatic cycle. */ pause() { const self = this; - const { element, options, isPaused } = self; - if (options.interval && !isPaused) { - clearInterval(self.timer); - self.timer = null; + const { element, options } = self; + if (!self.isPaused && options.interval) { addClass(element, pausedClass); - self.isPaused = !isPaused; + Timer.set(element, () => {}, 1, pausedClass); } } + /** Slide to the next item. */ next() { const self = this; if (!self.isAnimating) { self.index += 1; self.to(self.index); } } + /** Slide to the previous item. */ prev() { const self = this; if (!self.isAnimating) { self.index -= 1; self.to(self.index); } } + /** + * Jump to the item with the `idx` index. + * @param {number} idx the index of the item to jump to + */ to(idx) { const self = this; const { - element, isAnimating, slides, options, + element, slides, options, } = self; const activeItem = getActiveIndex(self); + const RTL = isRTL(); let next = idx; // when controled via methods, make sure to check again // first return if we're on the same item #227 - if (isAnimating || activeItem === next) return; + if (self.isAnimating || activeItem === next) return; // determine transition direction if ((activeItem < next) || (activeItem === 0 && next === slides.length - 1)) { - self.direction = 'left'; // next + self.direction = RTL ? 'right' : 'left'; // next } else if ((activeItem > next) || (activeItem === slides.length - 1 && next === 0)) { - self.direction = 'right'; // prev + self.direction = RTL ? 'left' : 'right'; // prev } const { direction } = self; @@ -729,155 +1664,179 @@ // orientation, class name, eventProperties const orientation = direction === 'left' ? 'next' : 'prev'; const directionClass = direction === 'left' ? 'start' : 'end'; + const eventProperties = { - relatedTarget: slides[next], direction, from: activeItem, to: next, + relatedTarget: slides[next], + from: activeItem, + to: next, + direction, }; // update event properties - Object.keys(eventProperties).forEach((k) => { - carouselSlideEvent[k] = eventProperties[k]; - carouselSlidEvent[k] = eventProperties[k]; - }); + ObjectAssign(carouselSlideEvent, eventProperties); + ObjectAssign(carouselSlidEvent, eventProperties); // discontinue when prevented - element.dispatchEvent(carouselSlideEvent); + dispatchEvent(element, carouselSlideEvent); if (carouselSlideEvent.defaultPrevented) return; // update index self.index = next; - - clearInterval(self.timer); - self.timer = null; - - self.isAnimating = true; activateCarouselIndicator(self, next); if (getElementTransitionDuration(slides[next]) && hasClass(element, 'slide')) { - addClass(slides[next], `${carouselItem}-${orientation}`); - reflow(slides[next]); - addClass(slides[next], `${carouselItem}-${directionClass}`); - addClass(slides[activeItem], `${carouselItem}-${directionClass}`); - - emulateTransitionEnd(slides[next], () => carouselTransitionEndHandler(self)); + Timer.set(element, () => { + addClass(slides[next], `${carouselItem}-${orientation}`); + reflow(slides[next]); + addClass(slides[next], `${carouselItem}-${directionClass}`); + addClass(slides[activeItem], `${carouselItem}-${directionClass}`); + + emulateTransitionEnd(slides[next], () => carouselTransitionEndHandler(self)); + }, 17, dataBsSlide); } else { addClass(slides[next], activeClass); removeClass(slides[activeItem], activeClass); - setTimeout(() => { - self.isAnimating = false; - + Timer.set(element, () => { + Timer.clear(element, dataBsSlide); // check for element, might have been disposed - if (element && options.interval && !hasClass(element, pausedClass)) { + if (element && options.interval && !self.isPaused) { self.cycle(); } - element.dispatchEvent(carouselSlidEvent); - }, 100); + dispatchEvent(element, carouselSlidEvent); + }, 17, dataBsSlide); } } + /** Remove `Carousel` component from target. */ dispose() { const self = this; const { slides } = self; const itemClasses = ['start', 'end', 'prev', 'next']; - Array.from(slides).forEach((slide, idx) => { + [...slides].forEach((slide, idx) => { if (hasClass(slide, activeClass)) activateCarouselIndicator(self, idx); itemClasses.forEach((c) => removeClass(slide, `${carouselItem}-${c}`)); }); toggleCarouselHandlers(self); - clearInterval(self.timer); - super.dispose(carouselComponent); + super.dispose(); } } - Carousel.init = { - component: carouselComponent, + ObjectAssign(Carousel, { selector: carouselSelector, - constructor: Carousel, - }; - + init: carouselInitCallback, + getInstance: getCarouselInstance, + }); + + /** + * A global namespace for aria-expanded. + * @type {string} + */ const ariaExpanded = 'aria-expanded'; - // collapse / tab + /** + * Global namespace for most components `collapsing` class. + * As used by `Collapse` / `Tab`. + */ const collapsingClass = 'collapsing'; - const dataBsTarget = 'data-bs-target'; - - const dataBsParent = 'data-bs-parent'; - - const dataBsContainer = 'data-bs-container'; + /** @type {string} */ + const collapseString = 'collapse'; - function getTargetElement(element) { - return queryElement(element.getAttribute(dataBsTarget) || element.getAttribute('href')) - || element.closest(element.getAttribute(dataBsParent)) - || queryElement(element.getAttribute(dataBsContainer)); - } + /** @type {string} */ + const collapseComponent = 'Collapse'; /* Native JavaScript for Bootstrap 5 | Collapse ----------------------------------------------- */ // COLLAPSE GC // =========== - const collapseString = 'collapse'; - const collapseComponent = 'Collapse'; const collapseSelector = `.${collapseString}`; const collapseToggleSelector = `[${dataBsToggle}="${collapseString}"]`; + const collapseDefaults = { parent: null }; + + /** + * Static method which returns an existing `Collapse` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Collapse>} + */ + const getCollapseInstance = (element) => getInstance(element, collapseComponent); + + /** + * A `Collapse` initialization callback. + * @type {BSN.InitCallback<Collapse>} + */ + const collapseInitCallback = (element) => new Collapse(element); // COLLAPSE CUSTOM EVENTS // ====================== - const showCollapseEvent = bootstrapCustomEvent(`show.bs.${collapseString}`); - const shownCollapseEvent = bootstrapCustomEvent(`shown.bs.${collapseString}`); - const hideCollapseEvent = bootstrapCustomEvent(`hide.bs.${collapseString}`); - const hiddenCollapseEvent = bootstrapCustomEvent(`hidden.bs.${collapseString}`); + const showCollapseEvent = OriginalEvent(`show.bs.${collapseString}`); + const shownCollapseEvent = OriginalEvent(`shown.bs.${collapseString}`); + const hideCollapseEvent = OriginalEvent(`hide.bs.${collapseString}`); + const hiddenCollapseEvent = OriginalEvent(`hidden.bs.${collapseString}`); // COLLAPSE PRIVATE METHODS // ======================== + /** + * Expand the designated `Element`. + * @param {Collapse} self the `Collapse` instance + */ function expandCollapse(self) { const { element, parent, triggers, } = self; - element.dispatchEvent(showCollapseEvent); + dispatchEvent(element, showCollapseEvent); if (showCollapseEvent.defaultPrevented) return; - self.isAnimating = true; - if (parent) parent.isAnimating = true; + Timer.set(element, () => {}, 17); + if (parent) Timer.set(parent, () => {}, 17); addClass(element, collapsingClass); removeClass(element, collapseString); + // @ts-ignore element.style.height = `${element.scrollHeight}px`; emulateTransitionEnd(element, () => { - self.isAnimating = false; - if (parent) parent.isAnimating = false; + Timer.clear(element); + if (parent) Timer.clear(parent); - triggers.forEach((btn) => btn.setAttribute(ariaExpanded, 'true')); + triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'true')); removeClass(element, collapsingClass); addClass(element, collapseString); addClass(element, showClass); + // @ts-ignore element.style.height = ''; - element.dispatchEvent(shownCollapseEvent); + dispatchEvent(element, shownCollapseEvent); }); } + /** + * Collapse the designated `Element`. + * @param {Collapse} self the `Collapse` instance + */ function collapseContent(self) { const { + // @ts-ignore element, parent, triggers, } = self; - element.dispatchEvent(hideCollapseEvent); + dispatchEvent(element, hideCollapseEvent); if (hideCollapseEvent.defaultPrevented) return; - self.isAnimating = true; - if (parent) parent.isAnimating = true; + Timer.set(element, () => {}, 17); + if (parent) Timer.set(parent, () => {}, 17); + // @ts-ignore element.style.height = `${element.scrollHeight}px`; removeClass(element, collapseString); @@ -885,40 +1844,51 @@ addClass(element, collapsingClass); reflow(element); + // @ts-ignore element.style.height = '0px'; emulateTransitionEnd(element, () => { - self.isAnimating = false; - if (parent) parent.isAnimating = false; + Timer.clear(element); + if (parent) Timer.clear(parent); - triggers.forEach((btn) => btn.setAttribute(ariaExpanded, 'false')); + triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'false')); removeClass(element, collapsingClass); addClass(element, collapseString); + // @ts-ignore element.style.height = ''; - element.dispatchEvent(hiddenCollapseEvent); + dispatchEvent(element, hiddenCollapseEvent); }); } + /** + * Toggles on/off the event listener(s) of the `Collapse` instance. + * @param {Collapse} self the `Collapse` instance + * @param {boolean=} add when `true`, the event listener is added + */ function toggleCollapseHandler(self, add) { - const action = add ? addEventListener : removeEventListener; + const action = add ? addListener : removeListener; const { triggers } = self; if (triggers.length) { - triggers.forEach((btn) => btn[action]('click', collapseClickHandler)); + triggers.forEach((btn) => action(btn, mouseclickEvent, collapseClickHandler)); } } // COLLAPSE EVENT HANDLER // ====================== + /** + * Handles the `click` event for the `Collapse` instance. + * @param {MouseEvent} e the `Event` object + */ function collapseClickHandler(e) { - const { target } = e; - const trigger = target.closest(collapseToggleSelector); - const element = getTargetElement(trigger); - const self = element && element[collapseComponent]; - if (self) self.toggle(target); + const { target } = e; // @ts-ignore - our target is `HTMLElement` + const trigger = target && closest(target, collapseToggleSelector); + const element = trigger && getTargetElement(trigger); + const self = element && getCollapseInstance(element); + if (self) self.toggle(); // event target is anchor link #398 if (trigger && trigger.tagName === 'A') e.preventDefault(); @@ -926,9 +1896,15 @@ // COLLAPSE DEFINITION // =================== + + /** Returns a new `Colapse` instance. */ class Collapse extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target and `Element` that matches the selector + * @param {BSN.Options.Collapse=} config instance options + */ constructor(target, config) { - super(collapseComponent, target, { parent: null }, config); + super(target, config); // bind const self = this; @@ -936,33 +1912,45 @@ const { element, options } = self; // set triggering elements - self.triggers = Array.from(document.querySelectorAll(collapseToggleSelector)) + /** @type {(HTMLElement | Element)[]} */ + self.triggers = [...querySelectorAll(collapseToggleSelector)] .filter((btn) => getTargetElement(btn) === element); // set parent accordion - self.parent = queryElement(options.parent); - const { parent } = self; - - // set initial state - self.isAnimating = false; - if (parent) parent.isAnimating = false; + /** @type {(HTMLElement | Element)?} */ + self.parent = querySelector(options.parent); // add event listeners - toggleCollapseHandler(self, 1); - } + toggleCollapseHandler(self, true); + } + + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return collapseComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return collapseDefaults; } + /* eslint-enable */ // COLLAPSE PUBLIC METHODS // ======================= - toggle(related) { + /** Toggles the visibility of the collapse. */ + toggle() { const self = this; - if (!hasClass(self.element, showClass)) self.show(related); - else self.hide(related); + if (!hasClass(self.element, showClass)) self.show(); + else self.hide(); } + /** Hides the collapse. */ hide() { const self = this; - const { triggers, isAnimating } = self; - if (isAnimating) return; + const { triggers, element } = self; + if (Timer.get(element)) return; collapseContent(self); if (triggers.length) { @@ -970,21 +1958,22 @@ } } + /** Shows the collapse. */ show() { const self = this; const { - element, parent, triggers, isAnimating, + element, parent, triggers, } = self; let activeCollapse; let activeCollapseInstance; if (parent) { - activeCollapse = Array.from(parent.querySelectorAll(`.${collapseString}.${showClass}`)) - .find((i) => i[collapseComponent]); - activeCollapseInstance = activeCollapse && activeCollapse[collapseComponent]; + activeCollapse = [...querySelectorAll(`.${collapseString}.${showClass}`, parent)] + .find((i) => getCollapseInstance(i)); + activeCollapseInstance = activeCollapse && getCollapseInstance(activeCollapse); } - if ((!parent || (parent && !parent.isAnimating)) && !isAnimating) { + if ((!parent || (parent && !Timer.get(parent))) && !Timer.get(element)) { if (activeCollapseInstance && activeCollapse !== element) { collapseContent(activeCollapseInstance); activeCollapseInstance.triggers.forEach((btn) => { @@ -999,36 +1988,114 @@ } } + /** Remove the `Collapse` component from the target `Element`. */ dispose() { const self = this; - const { parent } = self; toggleCollapseHandler(self); - if (parent) delete parent.isAnimating; - super.dispose(collapseComponent); + super.dispose(); } } - Collapse.init = { - component: collapseComponent, + ObjectAssign(Collapse, { selector: collapseSelector, - constructor: Collapse, - }; - + init: collapseInitCallback, + getInstance: getCollapseInstance, + }); + + /** + * A global namespace for `focus` event. + * @type {string} + */ + const focusEvent = 'focus'; + + /** + * A global namespace for `keyup` event. + * @type {string} + */ + const keyupEvent = 'keyup'; + + /** + * A global namespace for `scroll` event. + * @type {string} + */ + const scrollEvent = 'scroll'; + + /** + * A global namespace for `resize` event. + * @type {string} + */ + const resizeEvent = 'resize'; + + /** + * A global namespace for `ArrowUp` key. + * @type {string} e.which = 38 equivalent + */ + const keyArrowUp = 'ArrowUp'; + + /** + * A global namespace for `ArrowDown` key. + * @type {string} e.which = 40 equivalent + */ + const keyArrowDown = 'ArrowDown'; + + /** + * A global namespace for `Escape` key. + * @type {string} e.which = 27 equivalent + */ + const keyEscape = 'Escape'; + + /** + * Shortcut for `HTMLElement.hasAttribute()` method. + * @param {HTMLElement | Element} element target element + * @param {string} attribute attribute name + * @returns {boolean} the query result + */ + const hasAttribute = (element, attribute) => element.hasAttribute(attribute); + + /** + * Shortcut for multiple uses of `HTMLElement.style.propertyName` method. + * @param {HTMLElement | Element} element target element + * @param {Partial<CSSStyleDeclaration>} styles attribute value + */ + // @ts-ignore + const setElementStyle = (element, styles) => { ObjectAssign(element.style, styles); }; + + /** + * Utility to focus an `HTMLElement` target. + * + * @param {HTMLElement | Element} element is the target + */ + // @ts-ignore -- `Element`s resulted from querySelector can focus too + const focus = (element) => element.focus(); + + /** + * Global namespace for `Dropdown` types / classes. + */ const dropdownMenuClasses = ['dropdown', 'dropup', 'dropstart', 'dropend']; - const dropdownMenuClass = 'dropdown-menu'; + /** @type {string} */ + const dropdownComponent = 'Dropdown'; - function isEmptyAnchor(elem) { - const parentAnchor = elem.closest('A'); - // anchor href starts with # - return elem && ((elem.hasAttribute('href') && elem.href.slice(-1) === '#') - // OR a child of an anchor with href starts with # - || (parentAnchor && parentAnchor.hasAttribute('href') && parentAnchor.href.slice(-1) === '#')); - } + /** + * Global namespace for `.dropdown-menu`. + */ + const dropdownMenuClass = 'dropdown-menu'; - function setFocus(element) { - element.focus(); + /** + * Checks if an *event.target* or its parent has an `href="#"` value. + * We need to prevent jumping around onclick, don't we? + * + * @param {HTMLElement | HTMLAnchorElement | EventTarget} element the target element + * @returns {boolean} the query result + */ + function isEmptyAnchor(element) { + // @ts-ignore -- `EventTarget` must be `HTMLElement` + const parentAnchor = closest(element, 'A'); + // @ts-ignore -- anchor href starts with # + return element && ((hasAttribute(element, 'href') && element.href.slice(-1) === '#') + // @ts-ignore -- OR a child of an anchor with href starts with # + || (parentAnchor && hasAttribute(parentAnchor, 'href') && parentAnchor.href.slice(-1) === '#')); } /* Native JavaScript for Bootstrap 5 | Dropdown @@ -1036,109 +2103,127 @@ // DROPDOWN PRIVATE GC // =================== - const [dropdownString] = dropdownMenuClasses; - const dropdownComponent = 'Dropdown'; + const [ + dropdownString, + dropupString, + dropstartString, + dropendString, + ] = dropdownMenuClasses; const dropdownSelector = `[${dataBsToggle}="${dropdownString}"]`; + /** + * Static method which returns an existing `Dropdown` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Dropdown>} + */ + const getDropdownInstance = (element) => getInstance(element, dropdownComponent); + + /** + * A `Dropdown` initialization callback. + * @type {BSN.InitCallback<Dropdown>} + */ + const dropdownInitCallback = (element) => new Dropdown(element); + // DROPDOWN PRIVATE GC // =================== - const dropupString = dropdownMenuClasses[1]; - const dropstartString = dropdownMenuClasses[2]; - const dropendString = dropdownMenuClasses[3]; const dropdownMenuEndClass = `${dropdownMenuClass}-end`; - const hideMenuClass = ['d-block', 'invisible']; const verticalClass = [dropdownString, dropupString]; const horizontalClass = [dropstartString, dropendString]; - const defaultDropdownOptions = { + const menuFocusTags = ['A', 'BUTTON']; + + const dropdownDefaults = { offset: 5, // [number] 5(px) display: 'dynamic', // [dynamic|static] }; // DROPDOWN CUSTOM EVENTS - // ======================== - const showDropdownEvent = bootstrapCustomEvent(`show.bs.${dropdownString}`); - const shownDropdownEvent = bootstrapCustomEvent(`shown.bs.${dropdownString}`); - const hideDropdownEvent = bootstrapCustomEvent(`hide.bs.${dropdownString}`); - const hiddenDropdownEvent = bootstrapCustomEvent(`hidden.bs.${dropdownString}`); + // ====================== + const showDropdownEvent = OriginalEvent(`show.bs.${dropdownString}`); + const shownDropdownEvent = OriginalEvent(`shown.bs.${dropdownString}`); + const hideDropdownEvent = OriginalEvent(`hide.bs.${dropdownString}`); + const hiddenDropdownEvent = OriginalEvent(`hidden.bs.${dropdownString}`); // DROPDOWN PRIVATE METHODS // ======================== - function styleDropdown(self, show) { + /** + * Apply specific style or class names to a `.dropdown-menu` to automatically + * accomodate the layout and the page scroll. + * + * @param {Dropdown} self the `Dropdown` instance + */ + function styleDropdown(self) { const { - element, menu, originalClass, menuEnd, options, + element, menu, parentElement, options, } = self; const { offset } = options; - const parent = element.parentElement; + + // don't apply any style on mobile view + if (getElementStyle(menu, 'position') === 'static') return; + + const RTL = isRTL(element); + const menuEnd = hasClass(parentElement, dropdownMenuEndClass); // reset menu offset and position const resetProps = ['margin', 'top', 'bottom', 'left', 'right']; + // @ts-ignore resetProps.forEach((p) => { menu.style[p] = ''; }); - removeClass(parent, 'position-static'); - - if (!show) { - const menuEndNow = hasClass(menu, dropdownMenuEndClass); - parent.className = originalClass.join(' '); - if (menuEndNow && !menuEnd) removeClass(menu, dropdownMenuEndClass); - else if (!menuEndNow && menuEnd) addClass(menu, dropdownMenuEndClass); - return; - } // set initial position class // take into account .btn-group parent as .dropdown - let positionClass = dropdownMenuClasses.find((c) => originalClass.includes(c)) || dropdownString; + let positionClass = dropdownMenuClasses.find((c) => hasClass(parentElement, c)) || dropdownString; + /** @type {Record<string, Record<string, any>>} */ let dropdownMargin = { dropdown: [offset, 0, 0], dropup: [0, 0, offset], - dropstart: [-1, offset, 0], - dropend: [-1, 0, 0, offset], + dropstart: RTL ? [-1, 0, 0, offset] : [-1, offset, 0], + dropend: RTL ? [-1, offset, 0] : [-1, 0, 0, offset], }; + /** @type {Record<string, Record<string, any>>} */ const dropdownPosition = { dropdown: { top: '100%' }, dropup: { top: 'auto', bottom: '100%' }, - dropstart: { left: 'auto', right: '100%' }, - dropend: { left: '100%', right: 'auto' }, - menuEnd: { right: 0, left: 'auto' }, + dropstart: RTL ? { left: '100%', right: 'auto' } : { left: 'auto', right: '100%' }, + dropend: RTL ? { left: 'auto', right: '100%' } : { left: '100%', right: 'auto' }, + menuEnd: RTL ? { right: 'auto', left: 0 } : { right: 0, left: 'auto' }, }; - // force showing the menu to calculate its size - hideMenuClass.forEach((c) => addClass(menu, c)); - - const dropdownRegex = new RegExp(`\\b(${dropdownString}|${dropupString}|${dropstartString}|${dropendString})+`); - const elementDimensions = { w: element.offsetWidth, h: element.offsetHeight }; - const menuDimensions = { w: menu.offsetWidth, h: menu.offsetHeight }; - const HTML = document.documentElement; - const BD = document.body; - const windowWidth = (HTML.clientWidth || BD.clientWidth); - const windowHeight = (HTML.clientHeight || BD.clientHeight); - const targetBCR = element.getBoundingClientRect(); - // dropdownMenuEnd && [ dropdown | dropup ] - const leftExceed = targetBCR.left + elementDimensions.w - menuDimensions.w < 0; - // dropstart - const leftFullExceed = targetBCR.left - menuDimensions.w < 0; - // !dropdownMenuEnd && [ dropdown | dropup ] - const rightExceed = targetBCR.left + menuDimensions.w >= windowWidth; + // @ts-ignore + const { offsetWidth: menuWidth, offsetHeight: menuHeight } = menu; + + const { clientWidth, clientHeight } = getDocumentElement(element); + const { + left: targetLeft, top: targetTop, + width: targetWidth, height: targetHeight, + } = getBoundingClientRect(element); + + // dropstart | dropend + const leftFullExceed = targetLeft - menuWidth - offset < 0; // dropend - const rightFullExceed = targetBCR.left + menuDimensions.w + elementDimensions.w >= windowWidth; + const rightFullExceed = targetLeft + menuWidth + targetWidth + offset >= clientWidth; // dropstart | dropend - const bottomExceed = targetBCR.top + menuDimensions.h >= windowHeight; + const bottomExceed = targetTop + menuHeight + offset >= clientHeight; // dropdown - const bottomFullExceed = targetBCR.top + menuDimensions.h + elementDimensions.h >= windowHeight; + const bottomFullExceed = targetTop + menuHeight + targetHeight + offset >= clientHeight; // dropup - const topExceed = targetBCR.top - menuDimensions.h < 0; + const topExceed = targetTop - menuHeight - offset < 0; + // dropdown / dropup + const leftExceed = ((!RTL && menuEnd) || (RTL && !menuEnd)) + && targetLeft + targetWidth - menuWidth < 0; + const rightExceed = ((RTL && menuEnd) || (!RTL && !menuEnd)) + && targetLeft + menuWidth >= clientWidth; // recompute position + // handle RTL as well if (horizontalClass.includes(positionClass) && leftFullExceed && rightFullExceed) { positionClass = dropdownString; } - if (horizontalClass.includes(positionClass) && bottomExceed) { - positionClass = dropupString; - } - if (positionClass === dropstartString && leftFullExceed && !bottomExceed) { + if (positionClass === dropstartString && (!RTL ? leftFullExceed : rightFullExceed)) { positionClass = dropendString; } - if (positionClass === dropendString && rightFullExceed && !bottomExceed) { + if (positionClass === dropendString && (RTL ? leftFullExceed : rightFullExceed)) { positionClass = dropstartString; } if (positionClass === dropupString && topExceed && !bottomFullExceed) { @@ -1147,319 +2232,581 @@ if (positionClass === dropdownString && bottomFullExceed && !topExceed) { positionClass = dropupString; } + // override position for horizontal classes + if (horizontalClass.includes(positionClass) && bottomExceed) { + ObjectAssign(dropdownPosition[positionClass], { + top: 'auto', bottom: 0, + }); + } + // override position for vertical classes + if (verticalClass.includes(positionClass) && (leftExceed || rightExceed)) { + // don't realign when menu is wider than window + // in both RTL and non-RTL readability is KING + if (targetLeft + targetWidth + Math.abs(menuWidth - targetWidth) + offset < clientWidth) { + ObjectAssign(dropdownPosition[positionClass], + leftExceed ? { left: 0, right: 'auto' } : { left: 'auto', right: 0 }); + } + } - // set spacing dropdownMargin = dropdownMargin[positionClass]; + // @ts-ignore menu.style.margin = `${dropdownMargin.map((x) => (x ? `${x}px` : x)).join(' ')}`; - Object.keys(dropdownPosition[positionClass]).forEach((position) => { - menu.style[position] = dropdownPosition[positionClass][position]; - }); - // update dropdown position class - if (!hasClass(parent, positionClass)) { - parent.className = parent.className.replace(dropdownRegex, positionClass); - } - - // update dropdown / dropup to handle parent btn-group element - // as well as the dropdown-menu-end utility class - if (verticalClass.includes(positionClass)) { - if (!menuEnd && rightExceed) addClass(menu, dropdownMenuEndClass); - else if (menuEnd && leftExceed) removeClass(menu, dropdownMenuEndClass); + setElementStyle(menu, dropdownPosition[positionClass]); - if (hasClass(menu, dropdownMenuEndClass)) { - Object.keys(dropdownPosition.menuEnd).forEach((p) => { - menu.style[p] = dropdownPosition.menuEnd[p]; - }); - } + // update dropdown-menu-end + if (hasClass(menu, dropdownMenuEndClass)) { + setElementStyle(menu, dropdownPosition.menuEnd); } + } - // remove util classes from the menu, we have its size - hideMenuClass.forEach((c) => removeClass(menu, c)); + /** + * Returns an `Array` of focusable items in the given dropdown-menu. + * @param {HTMLElement | Element} menu + * @returns {(HTMLElement | Element)[]} + */ + function getMenuItems(menu) { + // @ts-ignore + return [...menu.children].map((c) => { + if (c && menuFocusTags.includes(c.tagName)) return c; + const { firstElementChild } = c; + if (firstElementChild && menuFocusTags.includes(firstElementChild.tagName)) { + return firstElementChild; + } + return null; + }).filter((c) => c); } + /** + * Toggles on/off the listeners for the events that close the dropdown + * as well as event that request a new position for the dropdown. + * + * @param {Dropdown} self the `Dropdown` instance + */ function toggleDropdownDismiss(self) { - const action = self.open ? addEventListener : removeEventListener; + const { element } = self; + const action = self.open ? addListener : removeListener; + const doc = getDocument(element); - document[action]('click', dropdownDismissHandler); - document[action]('focus', dropdownDismissHandler); - document[action]('keydown', dropdownPreventScroll); - document[action]('keyup', dropdownKeyHandler); + action(doc, mouseclickEvent, dropdownDismissHandler); + action(doc, focusEvent, dropdownDismissHandler); + action(doc, keydownEvent, dropdownPreventScroll); + action(doc, keyupEvent, dropdownKeyHandler); if (self.options.display === 'dynamic') { - window[action]('scroll', dropdownLayoutHandler, passiveHandler); - window[action]('resize', dropdownLayoutHandler, passiveHandler); + [scrollEvent, resizeEvent].forEach((ev) => { + // @ts-ignore + action(getWindow(element), ev, dropdownLayoutHandler, passiveHandler); + }); } } + /** + * Toggles on/off the `click` event listener of the `Dropdown`. + * + * @param {Dropdown} self the `Dropdown` instance + * @param {boolean=} add when `true`, it will add the event listener + */ function toggleDropdownHandler(self, add) { - const action = add ? addEventListener : removeEventListener; - self.element[action]('click', dropdownClickHandler); - } - - function getCurrentOpenDropdown() { - const currentParent = dropdownMenuClasses.concat('btn-group') - .map((c) => document.getElementsByClassName(`${c} ${showClass}`)) + const action = add ? addListener : removeListener; + action(self.element, mouseclickEvent, dropdownClickHandler); + } + + /** + * Returns the currently open `.dropdown` element. + * + * @param {(Document | HTMLElement | Element | globalThis)=} element target + * @returns {HTMLElement?} the query result + */ + function getCurrentOpenDropdown(element) { + const currentParent = [...dropdownMenuClasses, 'btn-group', 'input-group'] + .map((c) => getElementsByClassName(`${c} ${showClass}`), getDocument(element)) .find((x) => x.length); if (currentParent && currentParent.length) { - return Array.from(currentParent[0].children).find((x) => x.hasAttribute(dataBsToggle)); + // @ts-ignore -- HTMLElement is also Element + return [...currentParent[0].children] + .find((x) => hasAttribute(x, dataBsToggle)); } return null; } // DROPDOWN EVENT HANDLERS // ======================= + /** + * Handles the `click` event for the `Dropdown` instance. + * + * @param {MouseEvent} e event object + * @this {Document} + */ function dropdownDismissHandler(e) { const { target, type } = e; - if (!target.closest) return; // some weird FF bug #409 + // @ts-ignore + if (!target || !target.closest) return; // some weird FF bug #409 + + // @ts-ignore + const element = getCurrentOpenDropdown(target); + if (!element) return; + + const self = getDropdownInstance(element); + if (!self) return; - const element = getCurrentOpenDropdown(); - const parent = element && element.parentNode; - const self = element && element[dropdownComponent]; - const menu = self && self.menu; + const { parentElement, menu } = self; - const hasData = target.closest(dropdownSelector) !== null; - const isForm = parent && parent.contains(target) - && (target.tagName === 'form' || target.closest('form') !== null); + // @ts-ignore + const hasData = closest(target, dropdownSelector) !== null; + // @ts-ignore + const isForm = parentElement && parentElement.contains(target) + // @ts-ignore + && (target.tagName === 'form' || closest(target, 'form') !== null); - if (type === 'click' && isEmptyAnchor(target)) { + // @ts-ignore + if (type === mouseclickEvent && isEmptyAnchor(target)) { e.preventDefault(); } - if (type === 'focus' + if (type === focusEvent // @ts-ignore && (target === element || target === menu || menu.contains(target))) { return; } if (isForm || hasData) ; else if (self) { - self.hide(element); + self.hide(); } } + /** + * Handles `click` event listener for `Dropdown`. + * @this {HTMLElement | Element} + * @param {MouseEvent} e event object + */ function dropdownClickHandler(e) { const element = this; - const self = element[dropdownComponent]; - self.toggle(element); + const { target } = e; + const self = getDropdownInstance(element); - if (isEmptyAnchor(e.target)) e.preventDefault(); + if (self) { + self.toggle(); + if (target && isEmptyAnchor(target)) e.preventDefault(); + } } + /** + * Prevents scroll when dropdown-menu is visible. + * @param {KeyboardEvent} e event object + */ function dropdownPreventScroll(e) { - if (e.which === 38 || e.which === 40) e.preventDefault(); - } - - function dropdownKeyHandler({ which }) { - const element = getCurrentOpenDropdown(); - const self = element[dropdownComponent]; - const { menu, menuItems, open } = self; - const activeItem = document.activeElement; - const isSameElement = activeItem === element; - const isInsideMenu = menu.contains(activeItem); - const isMenuItem = activeItem.parentNode === menu || activeItem.parentNode.parentNode === menu; - - let idx = menuItems.indexOf(activeItem); - - if (isMenuItem) { // navigate up | down - if (isSameElement) { + if ([keyArrowDown, keyArrowUp].includes(e.code)) e.preventDefault(); + } + + /** + * Handles keyboard `keydown` events for `Dropdown`. + * @param {KeyboardEvent} e keyboard key + * @this {Document} + */ + function dropdownKeyHandler(e) { + const { code } = e; + const element = getCurrentOpenDropdown(this); + const self = element && getDropdownInstance(element); + const activeItem = element && getDocument(element).activeElement; + if (!self || !activeItem) return; + const { menu, open } = self; + const menuItems = getMenuItems(menu); + + // arrow up & down + if (menuItems && menuItems.length && [keyArrowDown, keyArrowUp].includes(code)) { + let idx = menuItems.indexOf(activeItem); + if (activeItem === element) { idx = 0; - } else if (which === 38) { + } else if (code === keyArrowUp) { idx = idx > 1 ? idx - 1 : 0; - } else if (which === 40) { + } else if (code === keyArrowDown) { idx = idx < menuItems.length - 1 ? idx + 1 : idx; } - - if (menuItems[idx]) setFocus(menuItems[idx]); + if (menuItems[idx]) focus(menuItems[idx]); } - if (((menuItems.length && isMenuItem) // menu has items - || (!menuItems.length && (isInsideMenu || isSameElement)) // menu might be a form - || !isInsideMenu) // or the focused element is not in the menu at all - && open && which === 27 // menu must be open - ) { + if (keyEscape === code && open) { self.toggle(); + focus(element); } } + /** + * @this {globalThis} + * @returns {void} + */ function dropdownLayoutHandler() { - const element = getCurrentOpenDropdown(); - const self = element && element[dropdownComponent]; + const element = getCurrentOpenDropdown(this); + const self = element && getDropdownInstance(element); - if (self && self.open) styleDropdown(self, 1); + if (self && self.open) styleDropdown(self); } // DROPDOWN DEFINITION // =================== + /** Returns a new Dropdown instance. */ class Dropdown extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target Element or string selector + * @param {BSN.Options.Dropdown=} config the instance options + */ constructor(target, config) { - super(dropdownComponent, target, defaultDropdownOptions, config); + super(target, config); // bind const self = this; // initialization element const { element } = self; + const { parentElement } = element; // set targets - const parent = element.parentElement; - self.menu = queryElement(`.${dropdownMenuClass}`, parent); - const { menu } = self; - - self.originalClass = Array.from(parent.classList); - - // set original position - self.menuEnd = hasClass(menu, dropdownMenuEndClass); - - self.menuItems = []; - - Array.from(menu.children).forEach((child) => { - if (child.children.length && (child.children[0].tagName === 'A')) self.menuItems.push(child.children[0]); - if (child.tagName === 'A') self.menuItems.push(child); - }); + /** @type {(Element | HTMLElement)} */ + // @ts-ignore + self.parentElement = parentElement; + /** @type {(Element | HTMLElement)} */ + // @ts-ignore + self.menu = querySelector(`.${dropdownMenuClass}`, parentElement); // set initial state to closed + /** @type {boolean} */ self.open = false; // add event listener - toggleDropdownHandler(self, 1); - } + toggleDropdownHandler(self, true); + } + + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return dropdownComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return dropdownDefaults; } + /* eslint-enable */ // DROPDOWN PUBLIC METHODS // ======================= - toggle(related) { + /** Shows/hides the dropdown menu to the user. */ + toggle() { const self = this; - const { open } = self; - if (open) self.hide(related); - else self.show(related); + if (self.open) self.hide(); + else self.show(); } - show(related) { + /** Shows the dropdown menu to the user. */ + show() { const self = this; - const currentParent = queryElement(dropdownMenuClasses.concat('btn-group').map((c) => `.${c}.${showClass}`).join(',')); - const currentElement = currentParent && queryElement(dropdownSelector, currentParent); - - if (currentElement) currentElement[dropdownComponent].hide(); + const { + element, open, menu, parentElement, + } = self; - const { element, menu, open } = self; - const parent = element.parentNode; + const currentElement = getCurrentOpenDropdown(element); + const currentInstance = currentElement && getDropdownInstance(currentElement); + if (currentInstance) currentInstance.hide(); - // update relatedTarget and dispatch - showDropdownEvent.relatedTarget = related || null; - parent.dispatchEvent(showDropdownEvent); + // dispatch + [showDropdownEvent, shownDropdownEvent].forEach((e) => { e.relatedTarget = element; }); + dispatchEvent(parentElement, showDropdownEvent); if (showDropdownEvent.defaultPrevented) return; - // change menu position - styleDropdown(self, 1); - addClass(menu, showClass); - addClass(parent, showClass); + addClass(parentElement, showClass); + setAttribute(element, ariaExpanded, 'true'); + + // change menu position + styleDropdown(self); - element.setAttribute(ariaExpanded, true); self.open = !open; setTimeout(() => { - setFocus(menu.getElementsByTagName('INPUT')[0] || element); // focus the first input item | element + focus(element); // focus the element toggleDropdownDismiss(self); - - shownDropdownEvent.relatedTarget = related || null; - parent.dispatchEvent(shownDropdownEvent); + dispatchEvent(parentElement, shownDropdownEvent); }, 1); } - hide(related) { + /** Hides the dropdown menu from the user. */ + hide() { const self = this; - const { element, menu, open } = self; - const parent = element.parentNode; - hideDropdownEvent.relatedTarget = related || null; - parent.dispatchEvent(hideDropdownEvent); + const { + element, open, menu, parentElement, + } = self; + [hideDropdownEvent, hiddenDropdownEvent].forEach((e) => { e.relatedTarget = element; }); + + dispatchEvent(parentElement, hideDropdownEvent); if (hideDropdownEvent.defaultPrevented) return; removeClass(menu, showClass); - removeClass(parent, showClass); + removeClass(parentElement, showClass); + setAttribute(element, ariaExpanded, 'false'); - // revert to original position - styleDropdown(self); - - element.setAttribute(ariaExpanded, false); self.open = !open; - setFocus(element); - // only re-attach handler if the instance is not disposed setTimeout(() => toggleDropdownDismiss(self), 1); - // update relatedTarget and dispatch - hiddenDropdownEvent.relatedTarget = related || null; - parent.dispatchEvent(hiddenDropdownEvent); + dispatchEvent(parentElement, hiddenDropdownEvent); } + /** Removes the `Dropdown` component from the target element. */ dispose() { const self = this; - const { element } = self; + const { parentElement } = self; - if (hasClass(element.parentNode, showClass) && self.open) self.hide(); + if (hasClass(parentElement, showClass) && self.open) self.hide(); toggleDropdownHandler(self); - super.dispose(dropdownComponent); + super.dispose(); } } - Dropdown.init = { - component: dropdownComponent, + ObjectAssign(Dropdown, { selector: dropdownSelector, - constructor: Dropdown, - }; - + init: dropdownInitCallback, + getInstance: getDropdownInstance, + }); + + /** + * A global namespace for aria-hidden. + * @type {string} + */ const ariaHidden = 'aria-hidden'; + /** + * A global namespace for aria-modal. + * @type {string} + */ const ariaModal = 'aria-modal'; + /** + * Shortcut for `HTMLElement.removeAttribute()` method. + * @param {HTMLElement | Element} element target element + * @param {string} attribute attribute name + * @returns {void} + */ + const removeAttribute = (element, attribute) => element.removeAttribute(attribute); + + /** + * Returns the `document.body` or the `<body>` element. + * + * @param {(Node | HTMLElement | Element | globalThis)=} node + * @returns {HTMLElement | HTMLBodyElement} + */ + function getDocumentBody(node) { + return getDocument(node).body; + } + + /** @type {string} */ + const modalString = 'modal'; + + /** @type {string} */ + const modalComponent = 'Modal'; + + /** + * Check if target is a `ShadowRoot`. + * + * @param {any} element target + * @returns {boolean} the query result + */ + const isShadowRoot = (element) => { + const OwnElement = getWindow(element).ShadowRoot; + return element instanceof OwnElement || element instanceof ShadowRoot; + }; + + /** + * Returns the `parentNode` also going through `ShadowRoot`. + * @see https://github.com/floating-ui/floating-ui + * + * @param {Node | HTMLElement | Element} node the target node + * @returns {Node | HTMLElement | Element} the apropriate parent node + */ + function getParentNode(node) { + if (node.nodeName === 'HTML') { + return node; + } + + // this is a quicker (but less type safe) way to save quite some bytes from the bundle + return ( + // @ts-ignore + node.assignedSlot // step into the shadow DOM of the parent of a slotted node + || node.parentNode // @ts-ignore DOM Element detected + || (isShadowRoot(node) ? node.host : null) // ShadowRoot detected + || getDocumentElement(node) // fallback + ); + } + + /** + * Check if a target element is a `<table>`, `<td>` or `<th>`. + * @param {any} element the target element + * @returns {boolean} the query result + */ + const isTableElement = (element) => ['TABLE', 'TD', 'TH'].includes(element.tagName); + + /** + * Checks if an element is an `HTMLElement`. + * + * @param {any} element the target object + * @returns {boolean} the query result + */ + const isHTMLElement = (element) => element instanceof HTMLElement; + + /** + * Returns an `HTMLElement` to be used as default value for *options.container* + * for `Tooltip` / `Popover` components. + * + * When `getOffset` is *true*, it returns the `offsetParent` for tooltip/popover + * offsets computation similar to **floating-ui**. + * @see https://github.com/floating-ui/floating-ui + * + * @param {HTMLElement | Element} element the target + * @param {boolean=} getOffset when *true* it will return an `offsetParent` + * @returns {HTMLElement | HTMLBodyElement | Window | globalThis} the query result + */ + function getElementContainer(element, getOffset) { + const majorBlockTags = ['HTML', 'BODY']; + + if (getOffset) { + /** @type {any} */ + let { offsetParent } = element; + const win = getWindow(element); + // const { innerWidth } = getDocumentElement(element); + + while (offsetParent && (isTableElement(offsetParent) + || (isHTMLElement(offsetParent) + // we must count for both fixed & sticky + && !['sticky', 'fixed'].includes(getElementStyle(offsetParent, 'position'))))) { + offsetParent = offsetParent.offsetParent; + } + + if (!offsetParent || (offsetParent + && (majorBlockTags.includes(offsetParent.tagName) + || getElementStyle(offsetParent, 'position') === 'static'))) { + offsetParent = win; + } + return offsetParent; + } + + /** @type {(HTMLElement)[]} */ + const containers = []; + /** @type {any} */ + let { parentNode } = element; + + while (parentNode && !majorBlockTags.includes(parentNode.nodeName)) { + parentNode = getParentNode(parentNode); + if (!(isShadowRoot(parentNode) || !!parentNode.shadowRoot + || isTableElement(parentNode))) { + containers.push(parentNode); + } + } + + return containers.find((c, i) => { + if (getElementStyle(c, 'position') !== 'relative' + && containers.slice(i + 1).every((r) => getElementStyle(r, 'position') === 'static')) { + return c; + } + return null; + }) || getDocumentBody(element); + } + + /** + * Global namespace for components `fixed-top` class. + */ const fixedTopClass = 'fixed-top'; + /** + * Global namespace for components `fixed-bottom` class. + */ const fixedBottomClass = 'fixed-bottom'; + /** + * Global namespace for components `sticky-top` class. + */ const stickyTopClass = 'sticky-top'; - const fixedItems = Array.from(document.getElementsByClassName(fixedTopClass)) - .concat(Array.from(document.getElementsByClassName(fixedBottomClass))) - .concat(Array.from(document.getElementsByClassName(stickyTopClass))) - .concat(Array.from(document.getElementsByClassName('is-fixed'))); + /** + * Global namespace for components `position-sticky` class. + */ + const positionStickyClass = 'position-sticky'; + + /** @param {(HTMLElement | Element | Document)=} parent */ + const getFixedItems = (parent) => [ + ...getElementsByClassName(fixedTopClass, parent), + ...getElementsByClassName(fixedBottomClass, parent), + ...getElementsByClassName(stickyTopClass, parent), + ...getElementsByClassName(positionStickyClass, parent), + ...getElementsByClassName('is-fixed', parent), + ]; + + /** + * Removes *padding* and *overflow* from the `<body>` + * and all spacing from fixed items. + * @param {(HTMLElement | Element)=} element the target modal/offcanvas + */ + function resetScrollbar(element) { + const bd = getDocumentBody(element); + setElementStyle(bd, { + paddingRight: '', + overflow: '', + }); - function resetScrollbar() { - const bd = document.body; - bd.style.paddingRight = ''; - bd.style.overflow = ''; + const fixedItems = getFixedItems(bd); if (fixedItems.length) { fixedItems.forEach((fixed) => { - fixed.style.paddingRight = ''; - fixed.style.marginRight = ''; + setElementStyle(fixed, { + paddingRight: '', + marginRight: '', + }); }); } } - function measureScrollbar() { - const windowWidth = document.documentElement.clientWidth; - return Math.abs(window.innerWidth - windowWidth); - } - - function setScrollbar(scrollbarWidth, overflow) { - const bd = document.body; - const bdStyle = getComputedStyle(bd); - const bodyPad = parseInt(bdStyle.paddingRight, 10); - const isOpen = bdStyle.overflow === 'hidden'; - const sbWidth = isOpen && bodyPad ? 0 : scrollbarWidth; + /** + * Returns the scrollbar width if the body does overflow + * the window. + * @param {(HTMLElement | Element)=} element + * @returns {number} the value + */ + function measureScrollbar(element) { + const { clientWidth } = getDocumentElement(element); + const { innerWidth } = getWindow(element); + return Math.abs(innerWidth - clientWidth); + } + + /** + * Sets the `<body>` and fixed items style when modal / offcanvas + * is shown to the user. + * + * @param {HTMLElement | Element} element the target modal/offcanvas + * @param {boolean=} overflow body does overflow or not + */ + function setScrollbar(element, overflow) { + const bd = getDocumentBody(element); + const bodyPad = parseInt(getElementStyle(bd, 'paddingRight'), 10); + const isOpen = getElementStyle(bd, 'overflow') === 'hidden'; + const sbWidth = isOpen && bodyPad ? 0 : measureScrollbar(element); + const fixedItems = getFixedItems(bd); if (overflow) { - bd.style.overflow = 'hidden'; - bd.style.paddingRight = `${bodyPad + sbWidth}px`; + setElementStyle(bd, { + overflow: 'hidden', + paddingRight: `${bodyPad + sbWidth}px`, + }); if (fixedItems.length) { fixedItems.forEach((fixed) => { - const isSticky = hasClass(fixed, stickyTopClass); - const itemPadValue = getComputedStyle(fixed).paddingRight; + const itemPadValue = getElementStyle(fixed, 'paddingRight'); + // @ts-ignore fixed.style.paddingRight = `${parseInt(itemPadValue, 10) + sbWidth}px`; - if (isSticky) { - const itemMValue = getComputedStyle(fixed).marginRight; + if ([stickyTopClass, positionStickyClass].some((c) => hasClass(fixed, c))) { + const itemMValue = getElementStyle(fixed, 'marginRight'); + // @ts-ignore fixed.style.marginRight = `${parseInt(itemMValue, 10) - sbWidth}px`; } }); @@ -1467,16 +2814,31 @@ } } - const modalBackdropClass = 'modal-backdrop'; - const offcanvasBackdropClass = 'offcanvas-backdrop'; - const modalActiveSelector = `.modal.${showClass}`; - const offcanvasActiveSelector = `.offcanvas.${showClass}`; - const overlay = document.createElement('div'); + /** @type {string} */ + const offcanvasString = 'offcanvas'; + + const backdropString = 'backdrop'; + const modalBackdropClass = `${modalString}-${backdropString}`; + const offcanvasBackdropClass = `${offcanvasString}-${backdropString}`; + const modalActiveSelector = `.${modalString}.${showClass}`; + const offcanvasActiveSelector = `.${offcanvasString}.${showClass}`; + + // any document would suffice + const overlay = getDocument().createElement('div'); - function getCurrentOpen() { - return queryElement(`${modalActiveSelector},${offcanvasActiveSelector}`); + /** + * Returns the current active modal / offcancas element. + * @param {(HTMLElement | Element)=} element the context element + * @returns {(HTMLElement | Element)?} the requested element + */ + function getCurrentOpen(element) { + return querySelector(`${modalActiveSelector},${offcanvasActiveSelector}`, getDocument(element)); } + /** + * Toogles from a Modal overlay to an Offcanvas, or vice-versa. + * @param {boolean=} isModal + */ function toggleOverlayType(isModal) { const targetClass = isModal ? modalBackdropClass : offcanvasBackdropClass; [modalBackdropClass, offcanvasBackdropClass].forEach((c) => { @@ -1485,33 +2847,52 @@ addClass(overlay, targetClass); } - function appendOverlay(hasFade, isModal) { + /** + * Append the overlay to DOM. + * @param {HTMLElement | Element} container + * @param {boolean} hasFade + * @param {boolean=} isModal + */ + function appendOverlay(container, hasFade, isModal) { toggleOverlayType(isModal); - document.body.append(overlay); + container.append(overlay); if (hasFade) addClass(overlay, fadeClass); } + /** + * Shows the overlay to the user. + */ function showOverlay() { addClass(overlay, showClass); reflow(overlay); } + /** + * Hides the overlay from the user. + */ function hideOverlay() { removeClass(overlay, showClass); } - function removeOverlay() { - const currentOpen = getCurrentOpen(); - - if (!currentOpen) { + /** + * Removes the overlay from DOM. + * @param {(HTMLElement | Element)=} element + */ + function removeOverlay(element) { + if (!getCurrentOpen(element)) { removeClass(overlay, fadeClass); overlay.remove(); - resetScrollbar(); + resetScrollbar(element); } } + /** + * @param {HTMLElement | Element} element target + * @returns {boolean} + */ function isVisible(element) { - return getComputedStyle(element).visibility !== 'hidden' + return element && getElementStyle(element, 'visibility') !== 'hidden' + // @ts-ignore && element.offsetParent !== null; } @@ -1520,110 +2901,157 @@ // MODAL PRIVATE GC // ================ - const modalString = 'modal'; - const modalComponent = 'Modal'; const modalSelector = `.${modalString}`; const modalToggleSelector = `[${dataBsToggle}="${modalString}"]`; const modalDismissSelector = `[${dataBsDismiss}="${modalString}"]`; const modalStaticClass = `${modalString}-static`; - const modalDefaultOptions = { + + const modalDefaults = { backdrop: true, // boolean|string keyboard: true, // boolean }; + /** + * Static method which returns an existing `Modal` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Modal>} + */ + const getModalInstance = (element) => getInstance(element, modalComponent); + + /** + * A `Modal` initialization callback. + * @type {BSN.InitCallback<Modal>} + */ + const modalInitCallback = (element) => new Modal(element); + // MODAL CUSTOM EVENTS // =================== - const showModalEvent = bootstrapCustomEvent(`show.bs.${modalString}`); - const shownModalEvent = bootstrapCustomEvent(`shown.bs.${modalString}`); - const hideModalEvent = bootstrapCustomEvent(`hide.bs.${modalString}`); - const hiddenModalEvent = bootstrapCustomEvent(`hidden.bs.${modalString}`); + const showModalEvent = OriginalEvent(`show.bs.${modalString}`); + const shownModalEvent = OriginalEvent(`shown.bs.${modalString}`); + const hideModalEvent = OriginalEvent(`hide.bs.${modalString}`); + const hiddenModalEvent = OriginalEvent(`hidden.bs.${modalString}`); // MODAL PRIVATE METHODS // ===================== + /** + * Applies special style for the `<body>` and fixed elements + * when a modal instance is shown to the user. + * + * @param {Modal} self the `Modal` instance + */ function setModalScrollbar(self) { - const { element, scrollbarWidth } = self; - const bd = document.body; - const html = document.documentElement; - const bodyOverflow = html.clientHeight !== html.scrollHeight - || bd.clientHeight !== bd.scrollHeight; - const modalOverflow = element.clientHeight !== element.scrollHeight; + const { element } = self; + const scrollbarWidth = measureScrollbar(element); + const { clientHeight, scrollHeight } = getDocumentElement(element); + const { clientHeight: modalHeight, scrollHeight: modalScrollHeight } = element; + const modalOverflow = modalHeight !== modalScrollHeight; if (!modalOverflow && scrollbarWidth) { - element.style.paddingRight = `${scrollbarWidth}px`; + const pad = isRTL(element) ? 'paddingLeft' : 'paddingRight'; + // @ts-ignore + element.style[pad] = `${scrollbarWidth}px`; } - setScrollbar(scrollbarWidth, (modalOverflow || bodyOverflow)); + setScrollbar(element, (modalOverflow || clientHeight !== scrollHeight)); } + /** + * Toggles on/off the listeners of events that close the modal. + * + * @param {Modal} self the `Modal` instance + * @param {boolean=} add when `true`, event listeners are added + */ function toggleModalDismiss(self, add) { - const action = add ? addEventListener : removeEventListener; - window[action]('resize', self.update, passiveHandler); - self.element[action]('click', modalDismissHandler); - document[action]('keydown', modalKeyHandler); + const action = add ? addListener : removeListener; + const { element } = self; + action(element, mouseclickEvent, modalDismissHandler); + // @ts-ignore + action(getWindow(element), resizeEvent, self.update, passiveHandler); + action(getDocument(element), keydownEvent, modalKeyHandler); } + /** + * Toggles on/off the `click` event listener of the `Modal` instance. + * @param {Modal} self the `Modal` instance + * @param {boolean=} add when `true`, event listener is added + */ function toggleModalHandler(self, add) { - const action = add ? addEventListener : removeEventListener; + const action = add ? addListener : removeListener; const { triggers } = self; if (triggers.length) { - triggers.forEach((btn) => btn[action]('click', modalClickHandler)); + triggers.forEach((btn) => action(btn, mouseclickEvent, modalClickHandler)); } } + /** + * Executes after a modal is hidden to the user. + * @param {Modal} self the `Modal` instance + */ function afterModalHide(self) { - const { triggers, options } = self; - if (!getCurrentOpen()) { - if (options.backdrop) removeOverlay(); - resetScrollbar(); - } - self.element.style.paddingRight = ''; - self.isAnimating = false; + const { triggers, element } = self; + removeOverlay(element); + // @ts-ignore + element.style.paddingRight = ''; if (triggers.length) { const visibleTrigger = triggers.find((x) => isVisible(x)); - if (visibleTrigger) setFocus(visibleTrigger); + if (visibleTrigger) focus(visibleTrigger); } } + /** + * Executes after a modal is shown to the user. + * @param {Modal} self the `Modal` instance + */ function afterModalShow(self) { const { element, relatedTarget } = self; - setFocus(element); - self.isAnimating = false; - - toggleModalDismiss(self, 1); + focus(element); + toggleModalDismiss(self, true); shownModalEvent.relatedTarget = relatedTarget; - element.dispatchEvent(shownModalEvent); + dispatchEvent(element, shownModalEvent); } + /** + * Executes before a modal is shown to the user. + * @param {Modal} self the `Modal` instance + */ function beforeModalShow(self) { const { element, hasFade } = self; + // @ts-ignore element.style.display = 'block'; setModalScrollbar(self); - if (!getCurrentOpen()) { - document.body.style.overflow = 'hidden'; + if (!getCurrentOpen(element)) { + getDocumentBody(element).style.overflow = 'hidden'; } addClass(element, showClass); - element.removeAttribute(ariaHidden); - element.setAttribute(ariaModal, true); + removeAttribute(element, ariaHidden); + setAttribute(element, ariaModal, 'true'); if (hasFade) emulateTransitionEnd(element, () => afterModalShow(self)); else afterModalShow(self); } + /** + * Executes before a modal is hidden to the user. + * @param {Modal} self the `Modal` instance + * @param {boolean=} force when `true` skip animation + */ function beforeModalHide(self, force) { const { element, options, relatedTarget, hasFade, } = self; + // @ts-ignore element.style.display = ''; // force can also be the transitionEvent object, we wanna make sure it's not // call is not forced and overlay is visible if (options.backdrop && !force && hasFade && hasClass(overlay, showClass) - && !getCurrentOpen()) { // AND no modal is visible + && !getCurrentOpen(element)) { // AND no modal is visible hideOverlay(); emulateTransitionEnd(overlay, () => afterModalHide(self)); } else { @@ -1633,55 +3061,77 @@ toggleModalDismiss(self); hiddenModalEvent.relatedTarget = relatedTarget; - element.dispatchEvent(hiddenModalEvent); + dispatchEvent(element, hiddenModalEvent); } // MODAL EVENT HANDLERS // ==================== + /** + * Handles the `click` event listener for modal. + * @param {MouseEvent} e the `Event` object + * @this {HTMLElement | Element} + */ function modalClickHandler(e) { const { target } = e; - const trigger = target.closest(modalToggleSelector); - const element = getTargetElement(trigger); - const self = element && element[modalComponent]; - if (trigger.tagName === 'A') e.preventDefault(); + const trigger = target && closest(this, modalToggleSelector); + const element = trigger && getTargetElement(trigger); + const self = element && getModalInstance(element); - if (self.isAnimating) return; + if (!self) return; + if (trigger && trigger.tagName === 'A') e.preventDefault(); self.relatedTarget = trigger; - self.toggle(); } - function modalKeyHandler({ which }) { - const element = queryElement(modalActiveSelector); - const self = element[modalComponent]; - const { options, isAnimating } = self; - if (!isAnimating // modal has no animations running - && options.keyboard && which === 27 // the keyboard option is enabled and the key is 27 + /** + * Handles the `keydown` event listener for modal + * to hide the modal when user type the `ESC` key. + * + * @param {KeyboardEvent} e the `Event` object + */ + function modalKeyHandler({ code }) { + const element = querySelector(modalActiveSelector); + const self = element && getModalInstance(element); + if (!self) return; + const { options } = self; + if (options.keyboard && code === keyEscape // the keyboard option is enabled and the key is 27 && hasClass(element, showClass)) { // the modal is not visible self.relatedTarget = null; self.hide(); } } + /** + * Handles the `click` event listeners that hide the modal. + * + * @this {HTMLElement | Element} + * @param {MouseEvent} e the `Event` object + */ function modalDismissHandler(e) { const element = this; - const self = element[modalComponent]; + const self = getModalInstance(element); - if (self.isAnimating) return; + // this timer is needed + if (!self || Timer.get(element)) return; const { options, isStatic, modalDialog } = self; const { backdrop } = options; const { target } = e; - const selectedText = document.getSelection().toString().length; + + // @ts-ignore + const selectedText = getDocument(element).getSelection().toString().length; + // @ts-ignore const targetInsideDialog = modalDialog.contains(target); - const dismiss = target.closest(modalDismissSelector); + // @ts-ignore + const dismiss = target && closest(target, modalDismissSelector); if (isStatic && !targetInsideDialog) { - addClass(element, modalStaticClass); - self.isAnimating = true; - emulateTransitionEnd(modalDialog, () => staticTransitionEnd(self)); + Timer.set(element, () => { + addClass(element, modalStaticClass); + emulateTransitionEnd(modalDialog, () => staticTransitionEnd(self)); + }, 17); } else if (dismiss || (!selectedText && !isStatic && !targetInsideDialog && backdrop)) { self.relatedTarget = dismiss || null; self.hide(); @@ -1689,18 +3139,29 @@ } } + /** + * Handles the `transitionend` event listeners for `Modal`. + * + * @param {Modal} self the `Modal` instance + */ function staticTransitionEnd(self) { - const duration = getElementTransitionDuration(self.modalDialog) + 17; - removeClass(self.element, modalStaticClass); + const { element, modalDialog } = self; + const duration = getElementTransitionDuration(modalDialog) + 17; + removeClass(element, modalStaticClass); // user must wait for zoom out transition - setTimeout(() => { self.isAnimating = false; }, duration); + Timer.set(element, () => Timer.clear(element), duration); } // MODAL DEFINITION // ================ + /** Returns a new `Modal` instance. */ class Modal extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target usually the `.modal` element + * @param {BSN.Options.Modal=} config instance options + */ constructor(target, config) { - super(modalComponent, target, modalDefaultOptions, config); + super(target, config); // bind const self = this; @@ -1709,64 +3170,83 @@ const { element } = self; // the modal-dialog - self.modalDialog = queryElement(`.${modalString}-dialog`, element); + /** @type {(HTMLElement | Element)} */ + // @ts-ignore + self.modalDialog = querySelector(`.${modalString}-dialog`, element); // modal can have multiple triggering elements - self.triggers = Array.from(document.querySelectorAll(modalToggleSelector)) + /** @type {(HTMLElement | Element)[]} */ + self.triggers = [...querySelectorAll(modalToggleSelector)] .filter((btn) => getTargetElement(btn) === element); // additional internals + /** @type {boolean} */ self.isStatic = self.options.backdrop === 'static'; + /** @type {boolean} */ self.hasFade = hasClass(element, fadeClass); - self.isAnimating = false; - self.scrollbarWidth = measureScrollbar(); + /** @type {(HTMLElement | Element)?} */ self.relatedTarget = null; + /** @type {HTMLBodyElement | HTMLElement | Element} */ + // @ts-ignore + self.container = getElementContainer(element); // attach event listeners - toggleModalHandler(self, 1); + toggleModalHandler(self, true); // bind self.update = self.update.bind(self); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return modalComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return modalDefaults; } + /* eslint-enable */ + // MODAL PUBLIC METHODS // ==================== + /** Toggles the visibility of the modal. */ toggle() { const self = this; if (hasClass(self.element, showClass)) self.hide(); else self.show(); } + /** Shows the modal to the user. */ show() { const self = this; const { - element, options, isAnimating, hasFade, relatedTarget, + element, options, hasFade, relatedTarget, container, } = self; const { backdrop } = options; let overlayDelay = 0; - if (hasClass(element, showClass) && !isAnimating) return; + if (hasClass(element, showClass)) return; showModalEvent.relatedTarget = relatedTarget || null; - element.dispatchEvent(showModalEvent); + dispatchEvent(element, showModalEvent); if (showModalEvent.defaultPrevented) return; // we elegantly hide any opened modal/offcanvas - const currentOpen = getCurrentOpen(); + const currentOpen = getCurrentOpen(element); if (currentOpen && currentOpen !== element) { - const that = currentOpen[modalComponent] - ? currentOpen[modalComponent] - : currentOpen.Offcanvas; - that.hide(); + const this1 = getModalInstance(currentOpen); + const that1 = this1 || getInstance(currentOpen, 'Offcanvas'); + that1.hide(); } - self.isAnimating = true; - if (backdrop) { if (!currentOpen && !hasClass(overlay, showClass)) { - appendOverlay(hasFade, 1); + appendOverlay(container, hasFade, true); } else { - toggleOverlayType(1); + toggleOverlayType(true); } overlayDelay = getElementTransitionDuration(overlay); @@ -1780,115 +3260,165 @@ } } + /** + * Hide the modal from the user. + * @param {boolean=} force when `true` it will skip animation + */ hide(force) { const self = this; const { - element, isAnimating, hasFade, relatedTarget, + element, hasFade, relatedTarget, } = self; - if (!hasClass(element, showClass) && !isAnimating) return; + + if (!hasClass(element, showClass)) return; hideModalEvent.relatedTarget = relatedTarget || null; - element.dispatchEvent(hideModalEvent); + dispatchEvent(element, hideModalEvent); if (hideModalEvent.defaultPrevented) return; - - self.isAnimating = true; removeClass(element, showClass); - element.setAttribute(ariaHidden, true); - element.removeAttribute(ariaModal); + setAttribute(element, ariaHidden, 'true'); + removeAttribute(element, ariaModal); - if (hasFade && force !== 1) { + if (hasFade && force !== false) { emulateTransitionEnd(element, () => beforeModalHide(self)); } else { beforeModalHide(self, force); } } + /** Updates the modal layout. */ update() { const self = this; if (hasClass(self.element, showClass)) setModalScrollbar(self); } + /** Removes the `Modal` component from target element. */ dispose() { const self = this; - self.hide(1); // forced call + self.hide(true); // forced call toggleModalHandler(self); - super.dispose(modalComponent); + super.dispose(); } } - Modal.init = { - component: modalComponent, + ObjectAssign(Modal, { selector: modalSelector, - constructor: Modal, - }; + init: modalInitCallback, + getInstance: getModalInstance, + }); + + /** @type {string} */ + const offcanvasComponent = 'Offcanvas'; /* Native JavaScript for Bootstrap 5 | OffCanvas ------------------------------------------------ */ // OFFCANVAS PRIVATE GC // ==================== - const offcanvasString = 'offcanvas'; - const offcanvasComponent = 'Offcanvas'; - const OffcanvasSelector = `.${offcanvasString}`; + const offcanvasSelector = `.${offcanvasString}`; const offcanvasToggleSelector = `[${dataBsToggle}="${offcanvasString}"]`; const offcanvasDismissSelector = `[${dataBsDismiss}="${offcanvasString}"]`; const offcanvasTogglingClass = `${offcanvasString}-toggling`; - const offcanvasDefaultOptions = { + + const offcanvasDefaults = { backdrop: true, // boolean keyboard: true, // boolean scroll: false, // boolean }; + /** + * Static method which returns an existing `Offcanvas` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Offcanvas>} + */ + const getOffcanvasInstance = (element) => getInstance(element, offcanvasComponent); + + /** + * An `Offcanvas` initialization callback. + * @type {BSN.InitCallback<Offcanvas>} + */ + const offcanvasInitCallback = (element) => new Offcanvas(element); + // OFFCANVAS CUSTOM EVENTS // ======================= - const showOffcanvasEvent = bootstrapCustomEvent(`show.bs.${offcanvasString}`); - const shownOffcanvasEvent = bootstrapCustomEvent(`shown.bs.${offcanvasString}`); - const hideOffcanvasEvent = bootstrapCustomEvent(`hide.bs.${offcanvasString}`); - const hiddenOffcanvasEvent = bootstrapCustomEvent(`hidden.bs.${offcanvasString}`); + const showOffcanvasEvent = OriginalEvent(`show.bs.${offcanvasString}`); + const shownOffcanvasEvent = OriginalEvent(`shown.bs.${offcanvasString}`); + const hideOffcanvasEvent = OriginalEvent(`hide.bs.${offcanvasString}`); + const hiddenOffcanvasEvent = OriginalEvent(`hidden.bs.${offcanvasString}`); // OFFCANVAS PRIVATE METHODS // ========================= + /** + * Sets additional style for the `<body>` and other elements + * when showing an offcanvas to the user. + * + * @param {Offcanvas} self the `Offcanvas` instance + */ function setOffCanvasScrollbar(self) { - const bd = document.body; - const html = document.documentElement; - const bodyOverflow = html.clientHeight !== html.scrollHeight - || bd.clientHeight !== bd.scrollHeight; - setScrollbar(self.scrollbarWidth, bodyOverflow); + const { element } = self; + const { clientHeight, scrollHeight } = getDocumentElement(element); + setScrollbar(element, clientHeight !== scrollHeight); } + /** + * Toggles on/off the `click` event listeners. + * + * @param {Offcanvas} self the `Offcanvas` instance + * @param {boolean=} add when *true*, listeners are added + */ function toggleOffcanvasEvents(self, add) { - const action = add ? addEventListener : removeEventListener; - self.triggers.forEach((btn) => btn[action]('click', offcanvasTriggerHandler)); - } - - function toggleOffCanvasDismiss(add) { - const action = add ? addEventListener : removeEventListener; - document[action]('keydown', offcanvasKeyDismissHandler); - document[action]('click', offcanvasDismissHandler); - } - + const action = add ? addListener : removeListener; + self.triggers.forEach((btn) => action(btn, mouseclickEvent, offcanvasTriggerHandler)); + } + + /** + * Toggles on/off the listeners of the events that close the offcanvas. + * + * @param {Offcanvas} self the `Offcanvas` instance + * @param {boolean=} add when *true* listeners are added + */ + function toggleOffCanvasDismiss(self, add) { + const action = add ? addListener : removeListener; + const doc = getDocument(self.element); + action(doc, keydownEvent, offcanvasKeyDismissHandler); + action(doc, mouseclickEvent, offcanvasDismissHandler); + } + + /** + * Executes before showing the offcanvas. + * + * @param {Offcanvas} self the `Offcanvas` instance + */ function beforeOffcanvasShow(self) { const { element, options } = self; if (!options.scroll) { - document.body.style.overflow = 'hidden'; setOffCanvasScrollbar(self); + getDocumentBody(element).style.overflow = 'hidden'; } addClass(element, offcanvasTogglingClass); addClass(element, showClass); + // @ts-ignore element.style.visibility = 'visible'; emulateTransitionEnd(element, () => showOffcanvasComplete(self)); } + /** + * Executes before hiding the offcanvas. + * + * @param {Offcanvas} self the `Offcanvas` instance + */ function beforeOffcanvasHide(self) { const { element, options } = self; - const currentOpen = getCurrentOpen(); + const currentOpen = getCurrentOpen(element); + // @ts-ignore element.blur(); if (!currentOpen && options.backdrop && hasClass(overlay, showClass)) { @@ -1899,169 +3429,217 @@ // OFFCANVAS EVENT HANDLERS // ======================== + /** + * Handles the `click` event listeners. + * + * @this {HTMLElement | Element} + * @param {MouseEvent} e the `Event` object + */ function offcanvasTriggerHandler(e) { - const trigger = this.closest(offcanvasToggleSelector); - const element = getTargetElement(trigger); - const self = element && element[offcanvasComponent]; + const trigger = closest(this, offcanvasToggleSelector); + const element = trigger && getTargetElement(trigger); + const self = element && getOffcanvasInstance(element); - if (trigger.tagName === 'A') e.preventDefault(); if (self) { self.relatedTarget = trigger; self.toggle(); + if (trigger && trigger.tagName === 'A') { + e.preventDefault(); + } } } + /** + * Handles the event listeners that close the offcanvas. + * + * @this {Document} + * @param {MouseEvent} e the `Event` object + */ function offcanvasDismissHandler(e) { - const element = queryElement(offcanvasActiveSelector); + const element = querySelector(offcanvasActiveSelector, this); if (!element) return; - const offCanvasDismiss = queryElement(offcanvasDismissSelector, element); - const self = element[offcanvasComponent]; + const offCanvasDismiss = querySelector(offcanvasDismissSelector, element); + const self = getOffcanvasInstance(element); + if (!self) return; const { options, triggers } = self; const { target } = e; - const trigger = target.closest(offcanvasToggleSelector); + // @ts-ignore -- `EventTarget` is `HTMLElement` + const trigger = closest(target, offcanvasToggleSelector); + const selection = getDocument(element).getSelection(); - if (trigger && trigger.tagName === 'A') e.preventDefault(); - - if ((!element.contains(target) && options.backdrop + if (!(selection && selection.toString().length) + // @ts-ignore + && ((!element.contains(target) && options.backdrop && (!trigger || (trigger && !triggers.includes(trigger)))) - || (offCanvasDismiss && offCanvasDismiss.contains(target))) { - self.relatedTarget = target === offCanvasDismiss ? offCanvasDismiss : null; + // @ts-ignore + || (offCanvasDismiss && offCanvasDismiss.contains(target)))) { + // @ts-ignore + self.relatedTarget = offCanvasDismiss && offCanvasDismiss.contains(target) + ? offCanvasDismiss : null; self.hide(); } + if (trigger && trigger.tagName === 'A') e.preventDefault(); } - function offcanvasKeyDismissHandler({ which }) { - const element = queryElement(offcanvasActiveSelector); + /** + * Handles the `keydown` event listener for offcanvas + * to hide it when user type the `ESC` key. + * + * @param {KeyboardEvent} e the `Event` object + * @this {Document} + */ + function offcanvasKeyDismissHandler({ code }) { + const element = querySelector(offcanvasActiveSelector, this); if (!element) return; - const self = element[offcanvasComponent]; + const self = getOffcanvasInstance(element); - if (self && self.options.keyboard && which === 27) { + if (self && self.options.keyboard && code === keyEscape) { self.relatedTarget = null; self.hide(); } } + /** + * Handles the `transitionend` when showing the offcanvas. + * + * @param {Offcanvas} self the `Offcanvas` instance + */ function showOffcanvasComplete(self) { - const { element, triggers, relatedTarget } = self; + const { element, triggers } = self; removeClass(element, offcanvasTogglingClass); - element.removeAttribute(ariaHidden); - element.setAttribute(ariaModal, true); - element.setAttribute('role', 'dialog'); - self.isAnimating = false; + removeAttribute(element, ariaHidden); + setAttribute(element, ariaModal, 'true'); + setAttribute(element, 'role', 'dialog'); if (triggers.length) { - triggers.forEach((btn) => btn.setAttribute(ariaExpanded, true)); + triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'true')); } - shownOffcanvasEvent.relatedTarget = relatedTarget || null; - element.dispatchEvent(shownOffcanvasEvent); + dispatchEvent(element, shownOffcanvasEvent); - toggleOffCanvasDismiss(1); - setFocus(element); + toggleOffCanvasDismiss(self, true); + focus(element); } + /** + * Handles the `transitionend` when hiding the offcanvas. + * + * @param {Offcanvas} self the `Offcanvas` instance + */ function hideOffcanvasComplete(self) { - const { - element, options, relatedTarget, triggers, - } = self; - const currentOpen = getCurrentOpen(); + const { element, triggers } = self; - element.setAttribute(ariaHidden, true); - element.removeAttribute(ariaModal); - element.removeAttribute('role'); + setAttribute(element, ariaHidden, 'true'); + removeAttribute(element, ariaModal); + removeAttribute(element, 'role'); + // @ts-ignore element.style.visibility = ''; - self.isAnimating = false; if (triggers.length) { - triggers.forEach((btn) => btn.setAttribute(ariaExpanded, false)); + triggers.forEach((btn) => setAttribute(btn, ariaExpanded, 'false')); const visibleTrigger = triggers.find((x) => isVisible(x)); - if (visibleTrigger) setFocus(visibleTrigger); + if (visibleTrigger) focus(visibleTrigger); } - // handle new offcanvas showing up - if (!currentOpen) { - if (options.backdrop) removeOverlay(); - if (!options.scroll) { - resetScrollbar(); - } - } + removeOverlay(element); - hiddenOffcanvasEvent.relatedTarget = relatedTarget || null; - element.dispatchEvent(hiddenOffcanvasEvent); + dispatchEvent(element, hiddenOffcanvasEvent); removeClass(element, offcanvasTogglingClass); - toggleOffCanvasDismiss(); + // must check for open instances + if (!getCurrentOpen(element)) { + toggleOffCanvasDismiss(self); + } } // OFFCANVAS DEFINITION // ==================== + /** Returns a new `Offcanvas` instance. */ class Offcanvas extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target usually an `.offcanvas` element + * @param {BSN.Options.Offcanvas=} config instance options + */ constructor(target, config) { - super(offcanvasComponent, target, offcanvasDefaultOptions, config); + super(target, config); const self = this; // instance element const { element } = self; // all the triggering buttons - self.triggers = Array.from(document.querySelectorAll(offcanvasToggleSelector)) + /** @type {(HTMLElement | Element)[]} */ + self.triggers = [...querySelectorAll(offcanvasToggleSelector)] .filter((btn) => getTargetElement(btn) === element); // additional instance property - self.isAnimating = false; - self.scrollbarWidth = measureScrollbar(); + /** @type {HTMLBodyElement | HTMLElement | Element} */ + // @ts-ignore + self.container = getElementContainer(element); + /** @type {(HTMLElement | Element)?} */ + self.relatedTarget = null; // attach event listeners - toggleOffcanvasEvents(self, 1); - } + toggleOffcanvasEvents(self, true); + } + + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return offcanvasComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return offcanvasDefaults; } + /* eslint-enable */ // OFFCANVAS PUBLIC METHODS // ======================== + /** Shows or hides the offcanvas from the user. */ toggle() { const self = this; if (hasClass(self.element, showClass)) self.hide(); else self.show(); } + /** Shows the offcanvas to the user. */ show() { - const self = this[offcanvasComponent] ? this[offcanvasComponent] : this; + const self = this; const { - element, options, isAnimating, relatedTarget, + element, options, container, relatedTarget, } = self; let overlayDelay = 0; - if (hasClass(element, showClass) || isAnimating) return; - - showOffcanvasEvent.relatedTarget = relatedTarget || null; - element.dispatchEvent(showOffcanvasEvent); + if (hasClass(element, showClass)) return; + showOffcanvasEvent.relatedTarget = relatedTarget; + shownOffcanvasEvent.relatedTarget = relatedTarget; + dispatchEvent(element, showOffcanvasEvent); if (showOffcanvasEvent.defaultPrevented) return; // we elegantly hide any opened modal/offcanvas - const currentOpen = getCurrentOpen(); + const currentOpen = getCurrentOpen(element); if (currentOpen && currentOpen !== element) { - const that = currentOpen[offcanvasComponent] - ? currentOpen[offcanvasComponent] - : currentOpen.Modal; - that.hide(); + const this1 = getOffcanvasInstance(currentOpen); + const that1 = this1 || getInstance(currentOpen, 'Modal'); + that1.hide(); } - self.isAnimating = true; - if (options.backdrop) { if (!currentOpen) { - appendOverlay(1); + appendOverlay(container, true); } else { toggleOverlayType(); } - overlayDelay = getElementTransitionDuration(overlay); - if (!hasClass(overlay, showClass)) showOverlay(); setTimeout(() => beforeOffcanvasShow(self), overlayDelay); @@ -2073,17 +3651,21 @@ } } + /** + * Hides the offcanvas from the user. + * @param {boolean=} force when `true` it will skip animation + */ hide(force) { const self = this; - const { element, isAnimating, relatedTarget } = self; + const { element, relatedTarget } = self; - if (!hasClass(element, showClass) || isAnimating) return; + if (!hasClass(element, showClass)) return; - hideOffcanvasEvent.relatedTarget = relatedTarget || null; - element.dispatchEvent(hideOffcanvasEvent); + hideOffcanvasEvent.relatedTarget = relatedTarget; + hiddenOffcanvasEvent.relatedTarget = relatedTarget; + dispatchEvent(element, hideOffcanvasEvent); if (hideOffcanvasEvent.defaultPrevented) return; - self.isAnimating = true; addClass(element, offcanvasTogglingClass); removeClass(element, showClass); @@ -2092,78 +3674,180 @@ } else beforeOffcanvasHide(self); } + /** Removes the `Offcanvas` from the target element. */ dispose() { const self = this; - self.hide(1); + self.hide(true); toggleOffcanvasEvents(self); - super.dispose(offcanvasComponent); + super.dispose(); } } - Offcanvas.init = { - component: offcanvasComponent, - selector: OffcanvasSelector, - constructor: Offcanvas, - }; + ObjectAssign(Offcanvas, { + selector: offcanvasSelector, + init: offcanvasInitCallback, + getInstance: getOffcanvasInstance, + }); - const ariaDescribedBy = 'aria-describedby'; + /** @type {string} */ + const popoverString = 'popover'; - var tipClassPositions = { - top: 'top', bottom: 'bottom', left: 'start', right: 'end', - }; + /** @type {string} */ + const popoverComponent = 'Popover'; - function isVisibleTip(tip, container) { - return container.contains(tip); - } + /** @type {string} */ + const tooltipString = 'tooltip'; - function isMedia(element) { - return [SVGElement, HTMLImageElement, HTMLVideoElement] + /** + * Returns a template for Popover / Tooltip. + * + * @param {string} tipType the expected markup type + * @returns {string} the template markup + */ + function getTipTemplate(tipType) { + const isTooltip = tipType === tooltipString; + const bodyClass = isTooltip ? `${tipType}-inner` : `${tipType}-body`; + const header = !isTooltip ? `<h3 class="${tipType}-header"></h3>` : ''; + const arrow = `<div class="${tipType}-arrow"></div>`; + const body = `<div class="${bodyClass}"></div>`; + return `<div class="${tipType}" role="${tooltipString}">${header + arrow + body}</div>`; + } + + /** + * Checks if an element is an `<svg>` (or any type of SVG element), + * `<img>` or `<video>`. + * + * *Tooltip* / *Popover* works different with media elements. + * @param {any} element the target element + * @returns {boolean} the query result + */ + const isMedia = (element) => element + && [SVGElement, HTMLImageElement, HTMLVideoElement] .some((mediaType) => element instanceof mediaType); + + /** + * Returns an `{x,y}` object with the target + * `HTMLElement` / `Node` scroll position. + * + * @see https://github.com/floating-ui/floating-ui + * + * @param {HTMLElement | Element | Window} element target node / element + * @returns {{x: number, y: number}} the scroll tuple + */ + function getNodeScroll(element) { + const isWin = 'scrollX' in element; + const x = isWin ? element.scrollX : element.scrollLeft; + const y = isWin ? element.scrollY : element.scrollTop; + + return { x, y }; + } + + /** + * Checks if a target `HTMLElement` is affected by scale. + * @see https://github.com/floating-ui/floating-ui + * + * @param {HTMLElement} element target + * @returns {boolean} the query result + */ + function isScaledElement(element) { + const { width, height } = getBoundingClientRect(element); + const { offsetWidth, offsetHeight } = element; + return Math.round(width) !== offsetWidth + || Math.round(height) !== offsetHeight; + } + + /** + * Returns the rect relative to an offset parent. + * @see https://github.com/floating-ui/floating-ui + * + * @param {HTMLElement | Element} element target + * @param {HTMLElement | Element | Window} offsetParent the container / offset parent + * @param {{x: number, y: number}} scroll + * @returns {SHORTER.OffsetRect} + */ + function getRectRelativeToOffsetParent(element, offsetParent, scroll) { + const isParentAnElement = offsetParent instanceof HTMLElement; + const rect = getBoundingClientRect(element, isParentAnElement && isScaledElement(offsetParent)); + const offsets = { x: 0, y: 0 }; + + if (isParentAnElement) { + const offsetRect = getBoundingClientRect(offsetParent, true); + offsets.x = offsetRect.x + offsetParent.clientLeft; + offsets.y = offsetRect.y + offsetParent.clientTop; + } + + return { + x: rect.left + scroll.x - offsets.x, + y: rect.top + scroll.y - offsets.y, + width: rect.width, + height: rect.height, + }; } - // both popovers and tooltips (this, event) + /** @type {Record<string, string>} */ + var tipClassPositions = { + top: 'top', + bottom: 'bottom', + left: 'start', + right: 'end', + }; + + /** + * Style popovers and tooltips. + * @param {BSN.Tooltip | BSN.Popover} self the `Popover` / `Tooltip` instance + * @param {PointerEvent=} e event object + */ function styleTip(self, e) { const tipClasses = /\b(top|bottom|start|end)+/; - const tip = self.tooltip || self.popover; - // reset tip style - tip.style.top = ''; - tip.style.left = ''; - tip.style.right = ''; - // continue with metrics - const isPopover = !!self.popover; - let tipDimensions = { w: tip.offsetWidth, h: tip.offsetHeight }; - const windowWidth = (document.documentElement.clientWidth || document.body.clientWidth); - const windowHeight = (document.documentElement.clientHeight || document.body.clientHeight); const { - element, options, arrow, positions, + element, tooltip, options, arrow, offsetParent, } = self; - let { container, placement } = options; - let parentIsBody = container === document.body; - - const { elementPosition, containerIsStatic, relContainer } = positions; - let { containerIsRelative } = positions; - // static containers should refer to another relative container or the body - container = relContainer || container; - containerIsRelative = containerIsStatic && relContainer ? 1 : containerIsRelative; - parentIsBody = container === document.body; - const parentRect = container.getBoundingClientRect(); - const leftBoundry = containerIsRelative ? parentRect.left : 0; - const rightBoundry = containerIsRelative ? parentRect.right : windowWidth; - // this case should not be possible - // containerIsAbsolute = !parentIsBody && containerPosition === 'absolute', - // this case requires a container with position: relative - const absoluteTarget = elementPosition === 'absolute'; - const targetRect = element.getBoundingClientRect(); - const scroll = parentIsBody - ? { x: window.pageXOffset, y: window.pageYOffset } - : { x: container.scrollLeft, y: container.scrollTop }; - const elemDimensions = { w: element.offsetWidth, h: element.offsetHeight }; - const top = containerIsRelative ? element.offsetTop : targetRect.top; - const left = containerIsRelative ? element.offsetLeft : targetRect.left; + const tipPositions = { ...tipClassPositions }; + + // reset tooltip style (top: 0, left: 0 works best) + setElementStyle(tooltip, { top: '0px', left: '0px', right: '' }); + // @ts-ignore + const isPopover = self.name === popoverComponent; + const tipWidth = tooltip.offsetWidth; + const tipHeight = tooltip.offsetHeight; + const RTL = isRTL(element); + if (RTL) { + tipPositions.left = 'end'; + tipPositions.right = 'start'; + } + const documentElement = getDocumentElement(element); + const windowWidth = documentElement.clientWidth; + const windowHeight = documentElement.clientHeight; + const { container } = options; + let { placement } = options; + const { + left: parentLeft, right: parentRight, top: parentTop, + } = getBoundingClientRect(container, true); + const parentWidth = container.clientWidth; + const scrollbarWidth = Math.abs(parentWidth - container.offsetWidth); + const parentPosition = getElementStyle(container, 'position'); + // const absoluteParent = parentPosition === 'absolute'; + const fixedParent = parentPosition === 'fixed'; + const staticParent = parentPosition === 'static'; + const stickyParent = parentPosition === 'sticky'; + const isSticky = stickyParent && parentTop === parseFloat(getElementStyle(container, 'top')); + // const absoluteTarget = getElementStyle(element, 'position') === 'absolute'; + // const stickyFixedParent = ['sticky', 'fixed'].includes(parentPosition); + const leftBoundry = RTL && fixedParent ? scrollbarWidth : 0; + const rightBoundry = fixedParent ? parentWidth + parentLeft + (RTL ? scrollbarWidth : 0) + : parentWidth + parentLeft + (windowWidth - parentRight) - 1; + const { + width: elemWidth, + height: elemHeight, + left: elemRectLeft, + right: elemRectRight, + top: elemRectTop, + } = getBoundingClientRect(element, true); + + const scroll = getNodeScroll(offsetParent); + const { x, y } = getRectRelativeToOffsetParent(element, offsetParent, scroll); // reset arrow style - arrow.style.top = ''; - arrow.style.left = ''; - arrow.style.right = ''; + setElementStyle(arrow, { top: '', left: '', right: '' }); let topPosition; let leftPosition; let rightPosition; @@ -2171,568 +3855,982 @@ let arrowLeft; let arrowRight; - // check placement - let topExceed = targetRect.top - tipDimensions.h < 0; - let bottomExceed = targetRect.top + tipDimensions.h + elemDimensions.h >= windowHeight; - let leftExceed = targetRect.left - tipDimensions.w < leftBoundry; - let rightExceed = targetRect.left + tipDimensions.w + elemDimensions.w >= rightBoundry; + const arrowWidth = arrow.offsetWidth || 0; + const arrowHeight = arrow.offsetHeight || 0; + const arrowAdjust = arrowWidth / 2; - topExceed = ['left', 'right'].includes(placement) - ? targetRect.top + elemDimensions.h / 2 - tipDimensions.h / 2 < 0 + // check placement + let topExceed = elemRectTop - tipHeight - arrowHeight < 0; + let bottomExceed = elemRectTop + tipHeight + elemHeight + + arrowHeight >= windowHeight; + let leftExceed = elemRectLeft - tipWidth - arrowWidth < leftBoundry; + let rightExceed = elemRectLeft + tipWidth + elemWidth + + arrowWidth >= rightBoundry; + + const horizontal = ['left', 'right']; + const vertical = ['top', 'bottom']; + topExceed = horizontal.includes(placement) + ? elemRectTop + elemHeight / 2 - tipHeight / 2 - arrowHeight < 0 : topExceed; - bottomExceed = ['left', 'right'].includes(placement) - ? targetRect.top + tipDimensions.h / 2 + elemDimensions.h / 2 >= windowHeight + bottomExceed = horizontal.includes(placement) + ? elemRectTop + tipHeight / 2 + elemHeight / 2 + arrowHeight >= windowHeight : bottomExceed; - leftExceed = ['top', 'bottom'].includes(placement) - ? targetRect.left + elemDimensions.w / 2 - tipDimensions.w / 2 < leftBoundry + leftExceed = vertical.includes(placement) + ? elemRectLeft + elemWidth / 2 - tipWidth / 2 < leftBoundry : leftExceed; - rightExceed = ['top', 'bottom'].includes(placement) - ? targetRect.left + tipDimensions.w / 2 + elemDimensions.w / 2 >= rightBoundry + rightExceed = vertical.includes(placement) + ? elemRectLeft + tipWidth / 2 + elemWidth / 2 >= rightBoundry : rightExceed; // recompute placement // first, when both left and right limits are exceeded, we fall back to top|bottom - placement = (['left', 'right'].includes(placement)) && leftExceed && rightExceed ? 'top' : placement; + placement = (horizontal.includes(placement)) && leftExceed && rightExceed ? 'top' : placement; placement = placement === 'top' && topExceed ? 'bottom' : placement; placement = placement === 'bottom' && bottomExceed ? 'top' : placement; placement = placement === 'left' && leftExceed ? 'right' : placement; placement = placement === 'right' && rightExceed ? 'left' : placement; // update tooltip/popover class - if (!tip.className.includes(placement)) { - tip.className = tip.className.replace(tipClasses, tipClassPositions[placement]); + if (!tooltip.className.includes(placement)) { + tooltip.className = tooltip.className.replace(tipClasses, tipPositions[placement]); } - // if position has changed, update tip dimensions - tipDimensions = { w: tip.offsetWidth, h: tip.offsetHeight }; - - // we check the computed width & height and update here - const arrowWidth = arrow.offsetWidth || 0; - const arrowHeight = arrow.offsetHeight || 0; - const arrowAdjust = arrowWidth / 2; // compute tooltip / popover coordinates - if (['left', 'right'].includes(placement)) { // secondary|side positions + if (horizontal.includes(placement)) { // secondary|side positions if (placement === 'left') { // LEFT - leftPosition = left + scroll.x - tipDimensions.w - (isPopover ? arrowWidth : 0); + leftPosition = x - tipWidth - (isPopover ? arrowWidth : 0); } else { // RIGHT - leftPosition = left + scroll.x + elemDimensions.w + (isPopover ? arrowWidth : 0); + leftPosition = x + elemWidth + (isPopover ? arrowWidth : 0); } // adjust top and arrow if (topExceed) { - topPosition = top + scroll.y; - arrowTop = elemDimensions.h / 2 - arrowWidth; + topPosition = y; + topPosition += (isSticky ? -parentTop - scroll.y : 0); + + arrowTop = elemHeight / 2 - arrowWidth; } else if (bottomExceed) { - topPosition = top + scroll.y - tipDimensions.h + elemDimensions.h; - arrowTop = tipDimensions.h - elemDimensions.h / 2 - arrowWidth; + topPosition = y - tipHeight + elemHeight; + topPosition += (isSticky ? -parentTop - scroll.y : 0); + + arrowTop = tipHeight - elemHeight / 2 - arrowWidth; } else { - topPosition = top + scroll.y - tipDimensions.h / 2 + elemDimensions.h / 2; - arrowTop = tipDimensions.h / 2 - arrowHeight / 2; + topPosition = y - tipHeight / 2 + elemHeight / 2; + topPosition += (isSticky ? -parentTop - scroll.y : 0); + + arrowTop = tipHeight / 2 - arrowHeight / 2; } - } else if (['top', 'bottom'].includes(placement)) { + } else if (vertical.includes(placement)) { if (e && isMedia(element)) { - const eX = !containerIsRelative - ? e.pageX - : e.layerX + (absoluteTarget ? element.offsetLeft : 0); - const eY = !containerIsRelative - ? e.pageY - : e.layerY + (absoluteTarget ? element.offsetTop : 0); + let eX = 0; + let eY = 0; + if (staticParent) { + eX = e.pageX; + eY = e.pageY; + } else { // fixedParent | stickyParent + eX = e.clientX - parentLeft + (fixedParent ? scroll.x : 0); + eY = e.clientY - parentTop + (fixedParent ? scroll.y : 0); + } + + // some weird RTL bug + eX -= RTL && fixedParent && scrollbarWidth ? scrollbarWidth : 0; if (placement === 'top') { - topPosition = eY - tipDimensions.h - (isPopover ? arrowWidth : arrowHeight); + topPosition = eY - tipHeight - arrowWidth; } else { - topPosition = eY + arrowHeight; + topPosition = eY + arrowWidth; } - // adjust left | right and also the arrow - if (e.clientX - tipDimensions.w / 2 < leftBoundry) { // when exceeds left + // adjust (left | right) and also the arrow + if (e.clientX - tipWidth / 2 < leftBoundry) { leftPosition = 0; arrowLeft = eX - arrowAdjust; - } else if (e.clientX + tipDimensions.w * 0.51 >= rightBoundry) { // when exceeds right + } else if (e.clientX + tipWidth / 2 > rightBoundry) { leftPosition = 'auto'; rightPosition = 0; - arrowLeft = tipDimensions.w - (rightBoundry - eX) - arrowAdjust; - } else { // normal top/bottom - leftPosition = eX - tipDimensions.w / 2; - arrowLeft = tipDimensions.w / 2 - arrowAdjust; + arrowRight = rightBoundry - eX - arrowAdjust; + arrowRight -= fixedParent ? parentLeft + (RTL ? scrollbarWidth : 0) : 0; + + // normal top/bottom + } else { + leftPosition = eX - tipWidth / 2; + arrowLeft = tipWidth / 2 - arrowAdjust; } } else { if (placement === 'top') { - topPosition = top + scroll.y - tipDimensions.h - (isPopover ? arrowHeight : 0); + topPosition = y - tipHeight - (isPopover ? arrowHeight : 0); } else { // BOTTOM - topPosition = top + scroll.y + elemDimensions.h + (isPopover ? arrowHeight : 0); + topPosition = y + elemHeight + (isPopover ? arrowHeight : 0); } // adjust left | right and also the arrow if (leftExceed) { leftPosition = 0; - arrowLeft = left + elemDimensions.w / 2 - arrowAdjust; + arrowLeft = x + elemWidth / 2 - arrowAdjust; } else if (rightExceed) { leftPosition = 'auto'; rightPosition = 0; - arrowRight = elemDimensions.w / 2 + (parentRect.right - targetRect.right) - arrowAdjust; + arrowRight = elemWidth / 2 + rightBoundry - elemRectRight - arrowAdjust; } else { - leftPosition = left + scroll.x - tipDimensions.w / 2 + elemDimensions.w / 2; - arrowLeft = tipDimensions.w / 2 - arrowAdjust; + leftPosition = x - tipWidth / 2 + elemWidth / 2; + arrowLeft = tipWidth / 2 - arrowAdjust; } } } - // apply style to tooltip/popover and its arrow - tip.style.top = `${topPosition}px`; - tip.style.left = leftPosition === 'auto' ? leftPosition : `${leftPosition}px`; - tip.style.right = rightPosition !== undefined ? `${rightPosition}px` : ''; - // update arrow placement or clear side - if (arrowTop !== undefined) { - arrow.style.top = `${arrowTop}px`; - } + // apply style to tooltip/popover + setElementStyle(tooltip, { + top: `${topPosition}px`, + left: leftPosition === 'auto' ? leftPosition : `${leftPosition}px`, + right: rightPosition !== undefined ? `${rightPosition}px` : '', + }); - if (arrowLeft !== undefined) { - arrow.style.left = `${arrowLeft}px`; - } else if (arrowRight !== undefined) { - arrow.style.right = `${arrowRight}px`; + // update arrow placement + if (arrow instanceof HTMLElement) { + if (arrowTop !== undefined) { + arrow.style.top = `${arrowTop}px`; + } + if (arrowLeft !== undefined) { + arrow.style.left = `${arrowLeft}px`; + } else if (arrowRight !== undefined) { + arrow.style.right = `${arrowRight}px`; + } } } - let bsnUID = 1; - - // popover, tooltip, scrollspy need a unique id - function getUID(element, key) { - bsnUID += 1; - return element[key] || bsnUID; - } - - function getTipContainer(element) { - // maybe the element is inside a modal - const modal = element.closest('.modal'); - - // OR maybe the element is inside a fixed navbar - const navbarFixed = element.closest(`.${fixedTopClass},.${fixedBottomClass}`); + const tooltipDefaults = { + /** @type {string} */ + template: getTipTemplate(tooltipString), + /** @type {string?} */ + title: null, // string + /** @type {string?} */ + customClass: null, // string | null + /** @type {string} */ + trigger: 'hover focus', + /** @type {string?} */ + placement: 'top', // string + /** @type {((c:string)=>string)?} */ + sanitizeFn: null, // function + /** @type {boolean} */ + animation: true, // bool + /** @type {number} */ + delay: 200, // number + /** @type {(HTMLElement | Element)?} */ + container: null, + }; - // set default container option appropriate for the context - return modal || navbarFixed || document.body; - } + /** + * A global namespace for aria-describedby. + * @type {string} + */ + const ariaDescribedBy = 'aria-describedby'; - function closestRelative(element) { - let retval = null; - let el = element; - while (el !== document.body) { - el = el.parentElement; - if (getComputedStyle(el).position === 'relative') { - retval = el; - break; + /** + * A global namespace for `mousedown` event. + * @type {string} + */ + const mousedownEvent = 'mousedown'; + + /** + * A global namespace for `mousemove` event. + * @type {string} + */ + const mousemoveEvent = 'mousemove'; + + /** + * A global namespace for `focusin` event. + * @type {string} + */ + const focusinEvent = 'focusin'; + + /** + * A global namespace for `focusout` event. + * @type {string} + */ + const focusoutEvent = 'focusout'; + + /** + * A global namespace for `hover` event. + * @type {string} + */ + const mousehoverEvent = 'hover'; + + let elementUID = 1; + const elementIDMap = new Map(); + + /** + * Returns a unique identifier for popover, tooltip, scrollspy. + * + * @param {HTMLElement | Element} element target element + * @param {string=} key predefined key + * @returns {number} an existing or new unique ID + */ + function getUID(element, key) { + elementUID += 1; + let elMap = elementIDMap.get(element); + let result = elementUID; + + if (key && key.length) { + if (elMap) { + const elMapId = elMap.get(key); + if (!Number.isNaN(elMapId)) { + result = elMapId; + } else { + elMap.set(key, result); + } + } else { + elementIDMap.set(element, new Map()); + elMap = elementIDMap.get(element); + elMap.set(key, result); } + } else if (!Number.isNaN(elMap)) { + result = elMap; + } else { + elementIDMap.set(element, result); } - return retval; + return result; } - function setHtml(element, content, sanitizeFn) { - if (typeof content === 'string' && !content.length) return; + // @ts-ignore + const { userAgentData: uaDATA } = navigator; - if (typeof content === 'object') { - element.append(content); - } else { - let dirty = content.trim(); // fixing #233 + /** + * A global namespace for `userAgentData` object. + */ + const userAgentData = uaDATA; - if (typeof sanitizeFn === 'function') dirty = sanitizeFn(dirty); + const { userAgent: userAgentString } = navigator; - const domParser = new DOMParser(); - const tempDocument = domParser.parseFromString(dirty, 'text/html'); - const method = tempDocument.children.length ? 'innerHTML' : 'innerText'; - element[method] = tempDocument.body[method]; - } - } + /** + * A global namespace for `navigator.userAgent` string. + */ + const userAgent = userAgentString; - /* Native JavaScript for Bootstrap 5 | Popover - ---------------------------------------------- */ + const appleBrands = /(iPhone|iPod|iPad)/; - // POPOVER PRIVATE GC - // ================== - const popoverString = 'popover'; - const popoverComponent = 'Popover'; - const popoverSelector = `[${dataBsToggle}="${popoverString}"],[data-tip="${popoverString}"]`; - const popoverDefaultOptions = { - template: '<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>', // string - title: null, // string - content: null, // string - customClass: null, // string - trigger: 'hover', // string - placement: 'top', // string - btnClose: '<button class="btn-close" aria-label="Close"></button>', // string - sanitizeFn: null, // function - dismissible: false, // boolean - animation: true, // boolean - delay: 200, // number - }; + /** + * A global `boolean` for Apple browsers. + * @type {boolean} + */ + const isApple = !userAgentData ? appleBrands.test(userAgent) + : userAgentData.brands.some((/** @type {Record<string, any>} */x) => appleBrands.test(x.brand)); - // POPOVER PRIVATE GC - // ================== - const appleBrands = /(iPhone|iPod|iPad)/; - const isIphone = navigator.userAgentData - ? navigator.userAgentData.brands.some((x) => appleBrands.test(x.brand)) - : appleBrands.test(navigator.userAgent); - const popoverHeaderClass = `${popoverString}-header`; - const popoverBodyClass = `${popoverString}-body`; + /** + * Global namespace for `data-bs-title` attribute. + */ + const dataOriginalTitle = 'data-original-title'; - // POPOVER CUSTOM EVENTS - // ===================== - const showPopoverEvent = bootstrapCustomEvent(`show.bs.${popoverString}`); - const shownPopoverEvent = bootstrapCustomEvent(`shown.bs.${popoverString}`); - const hidePopoverEvent = bootstrapCustomEvent(`hide.bs.${popoverString}`); - const hiddenPopoverEvent = bootstrapCustomEvent(`hidden.bs.${popoverString}`); + /** @type {string} */ + const tooltipComponent = 'Tooltip'; - // POPOVER EVENT HANDLERS - // ====================== - function popoverForceFocus() { - setFocus(this); - } + /** + * Append an existing `Element` to Popover / Tooltip component or HTML + * markup string to be parsed & sanitized to be used as popover / tooltip content. + * + * @param {HTMLElement | Element} element target + * @param {HTMLElement | Element | string} content the `Element` to append / string + * @param {ReturnType<any>} sanitizeFn a function to sanitize string content + */ + function setHtml(element, content, sanitizeFn) { + if (typeof content === 'string' && !content.length) return; - function popoverTouchHandler({ target }) { - const self = this; - const { popover, element } = self; + if (typeof content === 'string') { + let dirty = content.trim(); // fixing #233 + if (typeof sanitizeFn === 'function') dirty = sanitizeFn(dirty); - if ((popover && popover.contains(target)) // popover includes touch target - || target === element // OR touch target is element - || element.contains(target)) ; else { - self.hide(); + const domParser = new DOMParser(); + const tempDocument = domParser.parseFromString(dirty, 'text/html'); + const { body } = tempDocument; + const method = body.children.length ? 'innerHTML' : 'innerText'; + // @ts-ignore + element[method] = body[method]; + } else if (content instanceof HTMLElement) { + element.append(content); } } - // POPOVER PRIVATE METHODS - // ======================= - function createPopover(self) { - const { id, options } = self; + /** + * Creates a new tooltip / popover. + * + * @param {BSN.Popover | BSN.Tooltip} self the `Popover` instance + */ + function createTip(self) { + const { id, element, options } = self; const { animation, customClass, sanitizeFn, placement, dismissible, } = options; - let { - title, content, - } = options; - const { - template, btnClose, - } = options; + let { title, content } = options; + const isTooltip = self.name === tooltipComponent; + const tipString = isTooltip ? tooltipString : popoverString; + const { template, btnClose } = options; + const tipPositions = { ...tipClassPositions }; + + if (isRTL(element)) { + tipPositions.left = 'end'; + tipPositions.right = 'start'; + } // set initial popover class - const placementClass = `bs-${popoverString}-${tipClassPositions[placement]}`; + const placementClass = `bs-${tipString}-${tipPositions[placement]}`; // load template + /** @type {(HTMLElement | Element)?} */ let popoverTemplate; - if (typeof template === 'object') { + if ([Element, HTMLElement].some((x) => template instanceof x)) { popoverTemplate = template; } else { - const htmlMarkup = document.createElement('div'); + const htmlMarkup = getDocument(element).createElement('div'); setHtml(htmlMarkup, template, sanitizeFn); - popoverTemplate = htmlMarkup.firstChild; + popoverTemplate = htmlMarkup.firstElementChild; } + // set popover markup - self.popover = popoverTemplate.cloneNode(true); + self.tooltip = popoverTemplate && popoverTemplate.cloneNode(true); - const { popover } = self; + const { tooltip } = self; // set id and role attributes - popover.setAttribute('id', id); - popover.setAttribute('role', 'tooltip'); + setAttribute(tooltip, 'id', id); + setAttribute(tooltip, 'role', tooltipString); - const popoverHeader = queryElement(`.${popoverHeaderClass}`, popover); - const popoverBody = queryElement(`.${popoverBodyClass}`, popover); + const bodyClass = isTooltip ? `${tooltipString}-inner` : `${popoverString}-body`; + const tooltipHeader = isTooltip ? null : querySelector(`.${popoverString}-header`, tooltip); + const tooltipBody = querySelector(`.${bodyClass}`, tooltip); // set arrow and enable access for styleTip - self.arrow = queryElement(`.${popoverString}-arrow`, popover); + self.arrow = querySelector(`.${tipString}-arrow`, tooltip); // set dismissible button if (dismissible) { if (title) { - if (title instanceof Element) setHtml(title, btnClose, sanitizeFn); + if (title instanceof HTMLElement) setHtml(title, btnClose, sanitizeFn); else title += btnClose; } else { - if (popoverHeader) popoverHeader.remove(); - if (content instanceof Element) setHtml(content, btnClose, sanitizeFn); + if (tooltipHeader) tooltipHeader.remove(); + if (content instanceof HTMLElement) setHtml(content, btnClose, sanitizeFn); else content += btnClose; } } // fill the template with content from options / data attributes // also sanitize title && content - if (title && popoverHeader) setHtml(popoverHeader, title, sanitizeFn); - if (content && popoverBody) setHtml(popoverBody, content, sanitizeFn); - - // set btn and enable access for styleTip - [self.btn] = popover.getElementsByClassName('btn-close'); + if (!isTooltip) { + if (title && tooltipHeader) setHtml(tooltipHeader, title, sanitizeFn); + if (content && tooltipBody) setHtml(tooltipBody, content, sanitizeFn); + // @ts-ignore -- set btn + self.btn = querySelector('.btn-close', tooltip); + } else if (title && tooltipBody) setHtml(tooltipBody, title, sanitizeFn); // set popover animation and placement - if (!hasClass(popover, popoverString)) addClass(popover, popoverString); - if (animation && !hasClass(popover, fadeClass)) addClass(popover, fadeClass); - if (customClass && !hasClass(popover, customClass)) { - addClass(popover, customClass); + if (!hasClass(tooltip, tipString)) addClass(tooltip, tipString); + if (animation && !hasClass(tooltip, fadeClass)) addClass(tooltip, fadeClass); + if (customClass && !hasClass(tooltip, customClass)) { + addClass(tooltip, customClass); } - if (!hasClass(popover, placementClass)) addClass(popover, placementClass); + if (!hasClass(tooltip, placementClass)) addClass(tooltip, placementClass); } - function removePopover(self) { - const { element, popover } = self; - element.removeAttribute(ariaDescribedBy); - popover.remove(); - self.timer = null; + /** + * @param {(HTMLElement | Element)?} tip target + * @param {HTMLElement | ParentNode} container parent container + * @returns {boolean} + */ + function isVisibleTip(tip, container) { + return tip instanceof HTMLElement && container.contains(tip); } - function togglePopoverHandlers(self, add) { - const action = add ? addEventListener : removeEventListener; - const { element, options } = self; - const { trigger, dismissible } = options; - self.enabled = !!add; + /* Native JavaScript for Bootstrap 5 | Tooltip + ---------------------------------------------- */ - if (trigger === 'hover') { - element[action]('mousedown', self.show); - element[action]('mouseenter', self.show); - if (isMedia(element)) element[action]('mousemove', self.update, passiveHandler); - if (!dismissible) element[action]('mouseleave', self.hide); - } else if (trigger === 'click') { - element[action](trigger, self.toggle); - } else if (trigger === 'focus') { - if (isIphone) element[action]('click', popoverForceFocus); - element[action]('focusin', self.show); + // TOOLTIP PRIVATE GC + // ================== + const tooltipSelector = `[${dataBsToggle}="${tooltipString}"],[data-tip="${tooltipString}"]`; + const titleAttr = 'title'; + + /** + * Static method which returns an existing `Tooltip` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Tooltip>} + */ + let getTooltipInstance = (element) => getInstance(element, tooltipComponent); + + /** + * A `Tooltip` initialization callback. + * @type {BSN.InitCallback<Tooltip>} + */ + const tooltipInitCallback = (element) => new Tooltip(element); + + // TOOLTIP PRIVATE METHODS + // ======================= + /** + * Removes the tooltip from the DOM. + * + * @param {Tooltip} self the `Tooltip` instance + */ + function removeTooltip(self) { + const { element, tooltip } = self; + removeAttribute(element, ariaDescribedBy); + tooltip.remove(); + } + + /** + * Executes after the instance has been disposed. + * + * @param {Tooltip} self the `Tooltip` instance + */ + function disposeTooltipComplete(self) { + const { element } = self; + toggleTooltipHandlers(self); + + if (element.hasAttribute(dataOriginalTitle) && self.name === tooltipString) { + toggleTooltipTitle(self); + } + } + + /** + * Toggles on/off the special `Tooltip` event listeners. + * + * @param {Tooltip} self the `Tooltip` instance + * @param {boolean=} add when `true`, event listeners are added + */ + function toggleTooltipAction(self, add) { + const action = add ? addListener : removeListener; + const { element } = self; + + action(getDocument(element), touchstartEvent, self.handleTouch, passiveHandler); + + if (!isMedia(element)) { + [scrollEvent, resizeEvent].forEach((ev) => { + // @ts-ignore + action(getWindow(element), ev, self.update, passiveHandler); + }); } } - function dismissHandlerToggle(self, add) { - const action = add ? addEventListener : removeEventListener; - const { options, element, btn } = self; + /** + * Executes after the tooltip was shown to the user. + * + * @param {Tooltip} self the `Tooltip` instance + */ + function tooltipShownAction(self) { + const { element } = self; + const shownTooltipEvent = OriginalEvent(`shown.bs.${toLowerCase(self.name)}`); + + toggleTooltipAction(self, true); + dispatchEvent(element, shownTooltipEvent); + Timer.clear(element, 'in'); + } + + /** + * Executes after the tooltip was hidden to the user. + * + * @param {Tooltip} self the `Tooltip` instance + */ + function tooltipHiddenAction(self) { + const { element } = self; + const hiddenTooltipEvent = OriginalEvent(`hidden.bs.${toLowerCase(self.name)}`); + + toggleTooltipAction(self); + removeTooltip(self); + dispatchEvent(element, hiddenTooltipEvent); + Timer.clear(element, 'out'); + } + + /** + * Toggles on/off the `Tooltip` event listeners. + * + * @param {Tooltip} self the `Tooltip` instance + * @param {boolean=} add when `true`, event listeners are added + */ + function toggleTooltipHandlers(self, add) { + const action = add ? addListener : removeListener; + // @ts-ignore -- btn is only for dismissible popover + const { element, options, btn } = self; const { trigger, dismissible } = options; - if (dismissible) { - if (btn) btn[action]('click', self.hide); - } else { - if (trigger === 'focus') element[action]('focusout', self.hide); - if (trigger === 'hover') document[action]('touchstart', popoverTouchHandler, passiveHandler); + if (trigger.includes('manual')) return; + + self.enabled = !!add; + + /** @type {string[]} */ + const triggerOptions = trigger.split(' '); + const elemIsMedia = isMedia(element); + + if (elemIsMedia) { + action(element, mousemoveEvent, self.update, passiveHandler); } + triggerOptions.forEach((tr) => { + if (elemIsMedia || tr === mousehoverEvent) { + action(element, mousedownEvent, self.show); + action(element, mouseenterEvent, self.show); + + if (dismissible && btn) { + action(btn, mouseclickEvent, self.hide); + } else { + action(element, mouseleaveEvent, self.hide); + action(getDocument(element), touchstartEvent, self.handleTouch, passiveHandler); + } + } else if (tr === mouseclickEvent) { + action(element, tr, (!dismissible ? self.toggle : self.show)); + } else if (tr === focusEvent) { + action(element, focusinEvent, self.show); + if (!dismissible) action(element, focusoutEvent, self.hide); + if (isApple) action(element, mouseclickEvent, () => focus(element)); + } + }); + } + + /** + * Toggles on/off the `Tooltip` event listeners that hide/update the tooltip. + * + * @param {Tooltip} self the `Tooltip` instance + * @param {boolean=} add when `true`, event listeners are added + */ + function toggleTooltipOpenHandlers(self, add) { + const action = add ? addListener : removeListener; + const { element, options, offsetParent } = self; + const { container } = options; + const { offsetHeight, scrollHeight } = container; + const parentModal = closest(element, `.${modalString}`); + const parentOffcanvas = closest(element, `.${offcanvasString}`); + if (!isMedia(element)) { - window[action]('scroll', self.update, passiveHandler); - window[action]('resize', self.update, passiveHandler); + const win = getWindow(element); + const overflow = offsetHeight !== scrollHeight; + const scrollTarget = overflow || offsetParent !== win ? container : win; + // @ts-ignore + action(win, resizeEvent, self.update, passiveHandler); + action(scrollTarget, scrollEvent, self.update, passiveHandler); } - } - function popoverShowTrigger(self) { - self.element.dispatchEvent(shownPopoverEvent); + // dismiss tooltips inside modal / offcanvas + if (parentModal) action(parentModal, `hide.bs.${modalString}`, self.hide); + if (parentOffcanvas) action(parentOffcanvas, `hide.bs.${offcanvasString}`, self.hide); } - function popoverHideTrigger(self) { - removePopover(self); - self.element.dispatchEvent(hiddenPopoverEvent); + /** + * Toggles the `title` and `data-original-title` attributes. + * + * @param {Tooltip} self the `Tooltip` instance + * @param {string=} content when `true`, event listeners are added + */ + function toggleTooltipTitle(self, content) { + // [0 - add, 1 - remove] | [0 - remove, 1 - add] + const titleAtt = [dataOriginalTitle, titleAttr]; + const { element } = self; + + setAttribute(element, titleAtt[content ? 0 : 1], + // @ts-ignore + (content || getAttribute(element, titleAtt[0]))); + removeAttribute(element, titleAtt[content ? 1 : 0]); } - // POPOVER DEFINITION + // TOOLTIP DEFINITION // ================== - class Popover extends BaseComponent { + /** Creates a new `Tooltip` instance. */ + class Tooltip extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target the target element + * @param {BSN.Options.Tooltip=} config the instance options + */ constructor(target, config) { - popoverDefaultOptions.container = getTipContainer(queryElement(target)); - super(popoverComponent, target, popoverDefaultOptions, config); + super(target, config); // bind const self = this; - - // initialization element const { element } = self; - // additional instance properties - self.timer = null; - self.popover = null; - self.arrow = null; - self.btn = null; - self.enabled = false; - // set unique ID for aria-describedby - self.id = `${popoverString}-${getUID(element)}`; - - // set instance options + const isTooltip = self.name === tooltipComponent; + const tipString = isTooltip ? tooltipString : popoverString; + const tipComponent = isTooltip ? tooltipComponent : popoverComponent; + + getTooltipInstance = (elem) => getInstance(elem, tipComponent); + + // additional properties + /** @type {any} */ + self.tooltip = {}; + if (!isTooltip) { + /** @type {any?} */ + // @ts-ignore + self.btn = null; + } + /** @type {any} */ + self.arrow = {}; + /** @type {any} */ + self.offsetParent = {}; + /** @type {boolean} */ + self.enabled = true; + /** @type {string} Set unique ID for `aria-describedby`. */ + self.id = `${tipString}-${getUID(element, tipString)}`; + + // instance options const { options } = self; - // media elements only work with body as a container - self.options.container = isMedia(element) - ? popoverDefaultOptions.container - : queryElement(options.container); - - // reset default container - popoverDefaultOptions.container = null; - - // invalidate when no content is set - if (!options.content) return; - - // crate popover - createPopover(self); - - // set positions - const { container } = self.options; - const elementPosition = getComputedStyle(element).position; - const containerPosition = getComputedStyle(container).position; - const parentIsBody = container === document.body; - const containerIsStatic = !parentIsBody && containerPosition === 'static'; - const containerIsRelative = !parentIsBody && containerPosition === 'relative'; - const relContainer = containerIsStatic && closestRelative(container); - self.positions = { - elementPosition, - containerIsRelative, - containerIsStatic, - relContainer, - }; + // invalidate + if ((!options.title && isTooltip) || (!isTooltip && !options.content)) return; - // bind + const container = querySelector(options.container); + const idealContainer = getElementContainer(element); + + // bypass container option when its position is static/relative + self.options.container = !container || (container + && ['static', 'relative'].includes(getElementStyle(container, 'position'))) + ? idealContainer + : container || getDocumentBody(element); + + // reset default options + tooltipDefaults[titleAttr] = null; + + // all functions bind + self.handleTouch = self.handleTouch.bind(self); self.update = self.update.bind(self); + self.show = self.show.bind(self); + self.hide = self.hide.bind(self); + self.toggle = self.toggle.bind(self); - // attach event listeners - togglePopoverHandlers(self, 1); - } + // set title attributes and add event listeners + if (element.hasAttribute(titleAttr) && isTooltip) { + toggleTooltipTitle(self, options.title); + } - update(e) { - styleTip(this, e); - } + // create tooltip here + createTip(self); - // POPOVER PUBLIC METHODS - // ====================== - toggle(e) { - const self = e ? this[popoverComponent] : this; - const { popover, options } = self; - if (!isVisibleTip(popover, options.container)) self.show(); - else self.hide(); - } + // attach events + toggleTooltipHandlers(self, true); + } + + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return tooltipComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return tooltipDefaults; } + /* eslint-enable */ + // TOOLTIP PUBLIC METHODS + // ====================== + /** + * Shows the tooltip. + * + * @param {Event=} e the `Event` object + * @this {Tooltip} + */ show(e) { - const self = e ? this[popoverComponent] : this; + const self = this; const { - element, popover, options, id, + options, tooltip, element, id, } = self; - const { container } = options; + const { container, animation } = options; + const outTimer = Timer.get(element, 'out'); + + Timer.clear(element, 'out'); - clearTimeout(self.timer); - if (!isVisibleTip(popover, container)) { - element.dispatchEvent(showPopoverEvent); - if (showPopoverEvent.defaultPrevented) return; + if (tooltip && !outTimer && !isVisibleTip(tooltip, container)) { + Timer.set(element, () => { + const showTooltipEvent = OriginalEvent(`show.bs.${toLowerCase(self.name)}`); + dispatchEvent(element, showTooltipEvent); + if (showTooltipEvent.defaultPrevented) return; - // append to the container - container.append(popover); - element.setAttribute(ariaDescribedBy, id); + // append to container + container.append(tooltip); + setAttribute(element, ariaDescribedBy, `#${id}`); + // set offsetParent + self.offsetParent = getElementContainer(tooltip, true); - self.update(e); - if (!hasClass(popover, showClass)) addClass(popover, showClass); - dismissHandlerToggle(self, 1); + self.update(e); + toggleTooltipOpenHandlers(self, true); - if (options.animation) emulateTransitionEnd(popover, () => popoverShowTrigger(self)); - else popoverShowTrigger(self); + if (!hasClass(tooltip, showClass)) addClass(tooltip, showClass); + if (animation) emulateTransitionEnd(tooltip, () => tooltipShownAction(self)); + else tooltipShownAction(self); + }, 17, 'in'); } } - hide(e) { - let self; - if (e && this[popoverComponent]) { - self = this[popoverComponent]; - } else if (e) { // dismissible popover - const dPopover = this.closest(`.${popoverString}`); - const dEl = dPopover && queryElement(`[${ariaDescribedBy}="${dPopover.id}"]`); - self = dEl[popoverComponent]; - } else { - self = this; + /** + * Hides the tooltip. + * + * @this {Tooltip} + */ + hide() { + const self = this; + const { options, tooltip, element } = self; + const { container, animation, delay } = options; + + Timer.clear(element, 'in'); + + if (tooltip && isVisibleTip(tooltip, container)) { + Timer.set(element, () => { + const hideTooltipEvent = OriginalEvent(`hide.bs.${toLowerCase(self.name)}`); + dispatchEvent(element, hideTooltipEvent); + + if (hideTooltipEvent.defaultPrevented) return; + + // @ts-ignore + removeClass(tooltip, showClass); + toggleTooltipOpenHandlers(self); + + if (animation) emulateTransitionEnd(tooltip, () => tooltipHiddenAction(self)); + else tooltipHiddenAction(self); + }, delay + 17, 'out'); } - const { element, popover, options } = self; + } - clearTimeout(self.timer); - self.timer = setTimeout(() => { - if (isVisibleTip(popover, options.container)) { - element.dispatchEvent(hidePopoverEvent); - if (hidePopoverEvent.defaultPrevented) return; + /** + * Updates the tooltip position. + * + * @param {Event=} e the `Event` object + * @this {Tooltip} the `Tooltip` instance + */ + update(e) { + // @ts-ignore + styleTip(this, e); + } - removeClass(popover, showClass); - dismissHandlerToggle(self); + /** + * Toggles the tooltip visibility. + * + * @param {Event=} e the `Event` object + * @this {Tooltip} the instance + */ + toggle(e) { + const self = this; + const { tooltip, options } = self; - if (options.animation) emulateTransitionEnd(popover, () => popoverHideTrigger(self)); - else popoverHideTrigger(self); - } - }, options.delay + 17); + if (!isVisibleTip(tooltip, options.container)) self.show(e); + else self.hide(); } + /** Enables the tooltip. */ enable() { const self = this; const { enabled } = self; if (!enabled) { - togglePopoverHandlers(self, 1); + toggleTooltipHandlers(self, true); self.enabled = !enabled; } } + /** Disables the tooltip. */ disable() { const self = this; - const { enabled, popover, options } = self; + const { + element, tooltip, options, enabled, + } = self; + const { animation, container, delay } = options; if (enabled) { - if (isVisibleTip(popover, options.container) && options.animation) { + if (isVisibleTip(tooltip, container) && animation) { self.hide(); - setTimeout( - () => togglePopoverHandlers(self), - getElementTransitionDuration(popover) + options.delay + 17, - ); + Timer.set(element, () => { + toggleTooltipHandlers(self); + Timer.clear(element, tooltipString); + }, getElementTransitionDuration(tooltip) + delay + 17, tooltipString); } else { - togglePopoverHandlers(self); + toggleTooltipHandlers(self); } self.enabled = !enabled; } } + /** Toggles the `disabled` property. */ toggleEnabled() { const self = this; if (!self.enabled) self.enable(); else self.disable(); } + /** + * Handles the `touchstart` event listener for `Tooltip` + * @this {Tooltip} + * @param {TouchEvent} e the `Event` object + */ + handleTouch({ target }) { + const { tooltip, element } = this; + + if (tooltip.contains(target) || target === element + // @ts-ignore + || (target && element.contains(target))) ; else { + this.hide(); + } + } + + /** Removes the `Tooltip` from the target element. */ dispose() { const self = this; - const { popover, options } = self; - const { container, animation } = options; - if (animation && isVisibleTip(popover, container)) { - self.options.delay = 0; // reset delay + const { tooltip, options } = self; + + if (options.animation && isVisibleTip(tooltip, options.container)) { + options.delay = 0; // reset delay self.hide(); - emulateTransitionEnd(popover, () => togglePopoverHandlers(self)); + emulateTransitionEnd(tooltip, () => disposeTooltipComplete(self)); } else { - togglePopoverHandlers(self); + disposeTooltipComplete(self); } - super.dispose(popoverComponent); + super.dispose(); } } - Popover.init = { - component: popoverComponent, - selector: popoverSelector, - constructor: Popover, + ObjectAssign(Tooltip, { + selector: tooltipSelector, + init: tooltipInitCallback, + getInstance: getTooltipInstance, + styleTip, + }); + + /* Native JavaScript for Bootstrap 5 | Popover + ---------------------------------------------- */ + + // POPOVER PRIVATE GC + // ================== + const popoverSelector = `[${dataBsToggle}="${popoverString}"],[data-tip="${popoverString}"]`; + + const popoverDefaults = { + ...tooltipDefaults, + /** @type {string} */ + template: getTipTemplate(popoverString), + /** @type {string} */ + btnClose: '<button class="btn-close" aria-label="Close"></button>', + /** @type {boolean} */ + dismissible: false, + /** @type {string?} */ + content: null, }; + // POPOVER DEFINITION + // ================== + /** Returns a new `Popover` instance. */ + class Popover extends Tooltip { + /* eslint-disable -- we want to specify Popover Options */ + /** + * @param {HTMLElement | Element | string} target the target element + * @param {BSN.Options.Popover=} config the instance options + */ + constructor(target, config) { + super(target, config); + } + /** + * Returns component name string. + * @readonly @static + */ + get name() { return popoverComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return popoverDefaults; } + /* eslint-enable */ + + /* extend original `show()` */ + show() { + super.show(); + // @ts-ignore -- btn only exists within dismissible popover + const { options, btn } = this; + if (options.dismissible && btn) setTimeout(() => focus(btn), 17); + } + } + + /** + * Static method which returns an existing `Popover` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Popover>} + */ + const getPopoverInstance = (element) => getInstance(element, popoverComponent); + + /** + * A `Popover` initialization callback. + * @type {BSN.InitCallback<Popover>} + */ + const popoverInitCallback = (element) => new Popover(element); + + ObjectAssign(Popover, { + selector: popoverSelector, + init: popoverInitCallback, + getInstance: getPopoverInstance, + styleTip, + }); + + /** + * Shortcut for `HTMLElement.getElementsByTagName` method. Some `Node` elements + * like `ShadowRoot` do not support `getElementsByTagName`. + * + * @param {string} selector the tag name + * @param {(HTMLElement | Element | Document)=} parent optional Element to look into + * @return {HTMLCollectionOf<HTMLElement | Element>} the 'HTMLCollection' + */ + function getElementsByTagName(selector, parent) { + const lookUp = parent && parentNodes + .some((x) => parent instanceof x) ? parent : getDocument(); + return lookUp.getElementsByTagName(selector); + } + + /** @type {string} */ + const scrollspyString = 'scrollspy'; + + /** @type {string} */ + const scrollspyComponent = 'ScrollSpy'; + /* Native JavaScript for Bootstrap 5 | ScrollSpy ------------------------------------------------ */ + // console.log(typeof addEventListener) + // SCROLLSPY PRIVATE GC // ==================== - const scrollspyString = 'scrollspy'; - const scrollspyComponent = 'ScrollSpy'; const scrollspySelector = '[data-bs-spy="scroll"]'; - const scrollSpyDefaultOptions = { + + const scrollspyDefaults = { offset: 10, target: null, }; + /** + * Static method which returns an existing `ScrollSpy` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<ScrollSpy>} + */ + const getScrollSpyInstance = (element) => getInstance(element, scrollspyComponent); + + /** + * A `ScrollSpy` initialization callback. + * @type {BSN.InitCallback<ScrollSpy>} + */ + const scrollspyInitCallback = (element) => new ScrollSpy(element); + // SCROLLSPY CUSTOM EVENT // ====================== - const activateScrollSpy = bootstrapCustomEvent(`activate.bs.${scrollspyString}`); + const activateScrollSpy = OriginalEvent(`activate.bs.${scrollspyString}`); // SCROLLSPY PRIVATE METHODS // ========================= + /** + * Update the state of all items. + * @param {ScrollSpy} self the `ScrollSpy` instance + */ function updateSpyTargets(self) { const { - target, scrollTarget, isWindow, options, itemsLength, scrollHeight, + target, scrollTarget, options, itemsLength, scrollHeight, element, } = self; const { offset } = options; - const links = target.getElementsByTagName('A'); + const isWin = scrollTarget instanceof Window; - self.scrollTop = isWindow - ? scrollTarget.pageYOffset - : scrollTarget.scrollTop; + const links = target && getElementsByTagName('A', target); + const scrollHEIGHT = scrollTarget && getScrollHeight(scrollTarget); + + // @ts-ignore + self.scrollTop = isWin ? scrollTarget.scrollY : scrollTarget.scrollTop; // only update items/offsets once or with each mutation - if (itemsLength !== links.length || getScrollHeight(scrollTarget) !== scrollHeight) { + if (links && (itemsLength !== links.length || scrollHEIGHT !== scrollHeight)) { let href; let targetItem; let rect; @@ -2740,56 +4838,81 @@ // reset arrays & update self.items = []; self.offsets = []; - self.scrollHeight = getScrollHeight(scrollTarget); + self.scrollHeight = scrollHEIGHT; self.maxScroll = self.scrollHeight - getOffsetHeight(self); - Array.from(links).forEach((link) => { - href = link.getAttribute('href'); - targetItem = href && href.charAt(0) === '#' && href.slice(-1) !== '#' && queryElement(href); + [...links].forEach((link) => { + href = getAttribute(link, 'href'); + targetItem = href && href.charAt(0) === '#' && href.slice(-1) !== '#' + && querySelector(href, getDocument(element)); if (targetItem) { self.items.push(link); - rect = targetItem.getBoundingClientRect(); - self.offsets.push((isWindow ? rect.top + self.scrollTop : targetItem.offsetTop) - offset); + rect = getBoundingClientRect(targetItem); + // @ts-ignore + self.offsets.push((isWin ? rect.top + self.scrollTop : targetItem.offsetTop) - offset); } }); self.itemsLength = self.items.length; } } + /** + * Returns the `scrollHeight` property of the scrolling element. + * @param {HTMLElement | Element | Window | globalThis} scrollTarget the `ScrollSpy` instance + * @return {number} `scrollTarget` height + */ function getScrollHeight(scrollTarget) { - return scrollTarget.scrollHeight || Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight, - ); - } - - function getOffsetHeight({ element, isWindow }) { - if (!isWindow) return element.getBoundingClientRect().height; - return window.innerHeight; - } - + return scrollTarget instanceof HTMLElement + ? scrollTarget.scrollHeight // @ts-ignore + : getDocumentElement(scrollTarget).scrollHeight; + } + + /** + * Returns the height property of the scrolling element. + * @param {ScrollSpy} params the `ScrollSpy` instance + * @returns {number} + */ + function getOffsetHeight({ element, scrollTarget }) { + return (scrollTarget instanceof Window) + ? scrollTarget.innerHeight + : getBoundingClientRect(element).height; + } + + /** + * Clear all items of the target. + * @param {HTMLElement | Element} target a single item + */ function clear(target) { - Array.from(target.getElementsByTagName('A')).forEach((item) => { + [...getElementsByTagName('A', target)].forEach((item) => { if (hasClass(item, activeClass)) removeClass(item, activeClass); }); } + /** + * Activates a new item. + * @param {ScrollSpy} self the `ScrollSpy` instance + * @param {HTMLElement | Element} item a single item + */ function activate(self, item) { const { target, element } = self; + // @ts-ignore clear(target); + // @ts-ignore self.activeItem = item; addClass(item, activeClass); // activate all parents const parents = []; let parentItem = item; - while (parentItem !== document.body) { - parentItem = parentItem.parentNode; + while (parentItem !== getDocumentBody(element)) { + // @ts-ignore + parentItem = parentItem.parentElement; if (hasClass(parentItem, 'nav') || hasClass(parentItem, 'dropdown-menu')) parents.push(parentItem); } parents.forEach((menuItem) => { + /** @type {(HTMLElement | Element)?} */ const parentLink = menuItem.previousElementSibling; if (parentLink && !hasClass(parentLink, activeClass)) { @@ -2797,21 +4920,32 @@ } }); - // update relatedTarget and dispatch + // dispatch activateScrollSpy.relatedTarget = item; - element.dispatchEvent(activateScrollSpy); + dispatchEvent(element, activateScrollSpy); } + /** + * Toggles on/off the component event listener. + * @param {ScrollSpy} self the `ScrollSpy` instance + * @param {boolean=} add when `true`, listener is added + */ function toggleSpyHandlers(self, add) { - const action = add ? addEventListener : removeEventListener; - self.scrollTarget[action]('scroll', self.refresh, passiveHandler); + const action = add ? addListener : removeListener; + // @ts-ignore + action(self.scrollTarget, scrollEvent, self.refresh, passiveHandler); } // SCROLLSPY DEFINITION // ==================== + /** Returns a new `ScrollSpy` instance. */ class ScrollSpy extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target the target element + * @param {BSN.Options.ScrollSpy=} config the instance options + */ constructor(target, config) { - super(scrollspyComponent, target, scrollSpyDefaultOptions, config); + super(target, config); // bind const self = this; @@ -2819,37 +4953,63 @@ const { element, options } = self; // additional properties - self.target = queryElement(options.target); + /** @type {(HTMLElement | Element)?} */ + self.target = querySelector(options.target, getDocument(element)); // invalidate if (!self.target) return; + const win = getWindow(element); + // set initial state - self.scrollTarget = element.clientHeight < element.scrollHeight ? element : window; - self.isWindow = self.scrollTarget === window; + /** @type {HTMLElement | Element | Window | globalThis} */ + self.scrollTarget = element.clientHeight < element.scrollHeight ? element : win; + /** @type {number} */ self.scrollTop = 0; + /** @type {number} */ self.maxScroll = 0; + /** @type {number} */ self.scrollHeight = 0; + /** @type {(HTMLElement | Element)?} */ self.activeItem = null; + /** @type {(HTMLElement | Element)[]} */ self.items = []; + /** @type {number} */ + self.itemsLength = 0; + /** @type {number[]} */ self.offsets = []; // bind events self.refresh = self.refresh.bind(self); // add event handlers - toggleSpyHandlers(self, 1); + toggleSpyHandlers(self, true); self.refresh(); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return scrollspyComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return scrollspyDefaults; } + /* eslint-enable */ + // SCROLLSPY PUBLIC METHODS // ======================== + /** Updates all items. */ refresh() { const self = this; const { target } = self; // check if target is visible and invalidate + // @ts-ignore if (target.offsetHeight === 0) return; updateSpyTargets(self); @@ -2871,6 +5031,7 @@ if (activeItem && scrollTop < offsets[0] && offsets[0] > 0) { self.activeItem = null; + // @ts-ignore clear(target); return; } @@ -2883,259 +5044,379 @@ }); } + /** Removes `ScrollSpy` from the target element. */ dispose() { toggleSpyHandlers(this); - super.dispose(scrollspyComponent); + super.dispose(); } } - ScrollSpy.init = { - component: scrollspyComponent, + ObjectAssign(ScrollSpy, { selector: scrollspySelector, - constructor: ScrollSpy, - }; - + init: scrollspyInitCallback, + getInstance: getScrollSpyInstance, + }); + + /** + * A global namespace for aria-selected. + * @type {string} + */ const ariaSelected = 'aria-selected'; + /** @type {string} */ + const tabString = 'tab'; + + /** @type {string} */ + const tabComponent = 'Tab'; + /* Native JavaScript for Bootstrap 5 | Tab ------------------------------------------ */ // TAB PRIVATE GC // ================ - const tabString = 'tab'; - const tabComponent = 'Tab'; const tabSelector = `[${dataBsToggle}="${tabString}"]`; + /** + * Static method which returns an existing `Tab` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Tab>} + */ + const getTabInstance = (element) => getInstance(element, tabComponent); + + /** + * A `Tab` initialization callback. + * @type {BSN.InitCallback<Tab>} + */ + const tabInitCallback = (element) => new Tab(element); + // TAB CUSTOM EVENTS // ================= - const showTabEvent = bootstrapCustomEvent(`show.bs.${tabString}`); - const shownTabEvent = bootstrapCustomEvent(`shown.bs.${tabString}`); - const hideTabEvent = bootstrapCustomEvent(`hide.bs.${tabString}`); - const hiddenTabEvent = bootstrapCustomEvent(`hidden.bs.${tabString}`); - - let nextTab; - let nextTabContent; - let nextTabHeight; - let activeTab; - let activeTabContent; - let tabContainerHeight; - let tabEqualContents; + const showTabEvent = OriginalEvent(`show.bs.${tabString}`); + const shownTabEvent = OriginalEvent(`shown.bs.${tabString}`); + const hideTabEvent = OriginalEvent(`hide.bs.${tabString}`); + const hiddenTabEvent = OriginalEvent(`hidden.bs.${tabString}`); + + /** + * @type {Map<(HTMLElement | Element), any>} + */ + const tabPrivate = new Map(); // TAB PRIVATE METHODS // =================== + /** + * Executes after tab transition has finished. + * @param {Tab} self the `Tab` instance + */ function triggerTabEnd(self) { const { tabContent, nav } = self; - tabContent.style.height = ''; - removeClass(tabContent, collapsingClass); - nav.isAnimating = false; + + if (tabContent) { + // @ts-ignore + tabContent.style.height = ''; + removeClass(tabContent, collapsingClass); + } + + if (nav) Timer.clear(nav); } + /** + * Executes before showing the tab content. + * @param {Tab} self the `Tab` instance + */ function triggerTabShow(self) { - const { tabContent, nav } = self; + const { element, tabContent, nav } = self; + const { currentHeight, nextHeight } = tabPrivate.get(element); + const { tab } = nav && tabPrivate.get(nav); if (tabContent) { // height animation - if (tabEqualContents) { + if (currentHeight === nextHeight) { triggerTabEnd(self); } else { setTimeout(() => { // enables height animation - tabContent.style.height = `${nextTabHeight}px`; // height animation + // @ts-ignore + tabContent.style.height = `${nextHeight}px`; // height animation reflow(tabContent); emulateTransitionEnd(tabContent, () => triggerTabEnd(self)); }, 50); } - } else { - nav.isAnimating = false; - } - shownTabEvent.relatedTarget = activeTab; - nextTab.dispatchEvent(shownTabEvent); + } else if (nav) Timer.clear(nav); + shownTabEvent.relatedTarget = tab; + dispatchEvent(element, shownTabEvent); } + /** + * Executes before hiding the tab. + * @param {Tab} self the `Tab` instance + */ function triggerTabHide(self) { - const { tabContent } = self; + const { + element, content: nextContent, tabContent, nav, + } = self; + const { tab, content } = nav && tabPrivate.get(nav); + let currentHeight = 0; + if (tabContent) { - activeTabContent.style.float = 'left'; - nextTabContent.style.float = 'left'; - tabContainerHeight = activeTabContent.scrollHeight; + [content, nextContent].forEach((c) => addClass(c, 'overflow-hidden')); + currentHeight = content.scrollHeight; } // update relatedTarget and dispatch event - showTabEvent.relatedTarget = activeTab; - hiddenTabEvent.relatedTarget = nextTab; - nextTab.dispatchEvent(showTabEvent); + showTabEvent.relatedTarget = tab; + hiddenTabEvent.relatedTarget = element; + dispatchEvent(element, showTabEvent); if (showTabEvent.defaultPrevented) return; - addClass(nextTabContent, activeClass); - removeClass(activeTabContent, activeClass); + addClass(nextContent, activeClass); + removeClass(content, activeClass); if (tabContent) { - nextTabHeight = nextTabContent.scrollHeight; - tabEqualContents = nextTabHeight === tabContainerHeight; + const nextHeight = nextContent.scrollHeight; + tabPrivate.set(element, { currentHeight, nextHeight }); + addClass(tabContent, collapsingClass); - tabContent.style.height = `${tabContainerHeight}px`; // height animation + // @ts-ignore -- height animation + tabContent.style.height = `${currentHeight}px`; reflow(tabContent); - activeTabContent.style.float = ''; - nextTabContent.style.float = ''; + [content, nextContent].forEach((c) => removeClass(c, 'overflow-hidden')); } - if (hasClass(nextTabContent, fadeClass)) { + if (nextContent && hasClass(nextContent, fadeClass)) { setTimeout(() => { - addClass(nextTabContent, showClass); - emulateTransitionEnd(nextTabContent, () => { + addClass(nextContent, showClass); + emulateTransitionEnd(nextContent, () => { triggerTabShow(self); }); - }, 20); + }, 17); } else { triggerTabShow(self); } - activeTab.dispatchEvent(hiddenTabEvent); + dispatchEvent(tab, hiddenTabEvent); } - function getActiveTab({ nav }) { - const activeTabs = nav.getElementsByClassName(activeClass); + /** + * Returns the current active tab and its target content. + * @param {Tab} self the `Tab` instance + * @returns {Record<string, any>} the query result + */ + function getActiveTab(self) { + const { nav } = self; + // @ts-ignore + const activeTabs = getElementsByClassName(activeClass, nav); + /** @type {(HTMLElement | Element)=} */ + let tab; if (activeTabs.length === 1 - && !dropdownMenuClasses.some((c) => hasClass(activeTabs[0].parentNode, c))) { - [activeTab] = activeTabs; + // @ts-ignore + && !dropdownMenuClasses.some((c) => hasClass(activeTabs[0].parentElement, c))) { + [tab] = activeTabs; } else if (activeTabs.length > 1) { - activeTab = activeTabs[activeTabs.length - 1]; + tab = activeTabs[activeTabs.length - 1]; } - return activeTab; - } - - function getActiveTabContent(self) { - return queryElement(getActiveTab(self).getAttribute('href')); + const content = tab ? getTargetElement(tab) : null; + // @ts-ignore + return { tab, content }; } + /** + * Toggles on/off the `click` event listener. + * @param {Tab} self the `Tab` instance + * @param {boolean=} add when `true`, event listener is added + */ function toggleTabHandler(self, add) { - const action = add ? addEventListener : removeEventListener; - self.element[action]('click', tabClickHandler); + const action = add ? addListener : removeListener; + action(self.element, mouseclickEvent, tabClickHandler); } // TAB EVENT HANDLER // ================= + /** + * Handles the `click` event listener. + * @this {HTMLElement | Element} + * @param {MouseEvent} e the `Event` object + */ function tabClickHandler(e) { - const self = this[tabComponent]; + const self = getTabInstance(this); + if (!self) return; e.preventDefault(); - if (!self.nav.isAnimating) self.show(); + + self.show(); } // TAB DEFINITION // ============== + /** Creates a new `Tab` instance. */ class Tab extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target the target element + */ constructor(target) { - super(tabComponent, target); + super(target); // bind const self = this; // initialization element const { element } = self; + const content = getTargetElement(element); - // event targets - self.nav = element.closest('.nav'); - const { nav } = self; - self.dropdown = nav && queryElement(`.${dropdownMenuClasses[0]}-toggle`, nav); - activeTabContent = getActiveTabContent(self); - self.tabContent = supportTransition && activeTabContent.closest('.tab-content'); - tabContainerHeight = activeTabContent.scrollHeight; + // no point initializing a tab without a corresponding content + if (!content) return; - // set default animation state - nav.isAnimating = false; + const nav = closest(element, '.nav'); + const container = closest(content, '.tab-content'); + + /** @type {(HTMLElement | Element)?} */ + self.nav = nav; + /** @type {HTMLElement | Element} */ + self.content = content; + /** @type {(HTMLElement | Element)?} */ + self.tabContent = container; + + // event targets + /** @type {(HTMLElement | Element)?} */ + self.dropdown = nav && querySelector(`.${dropdownMenuClasses[0]}-toggle`, nav); // add event listener - toggleTabHandler(self, 1); + toggleTabHandler(self, true); } + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return tabComponent; } + /* eslint-enable */ + // TAB PUBLIC METHODS // ================== - show() { // the tab we clicked is now the nextTab tab + /** Shows the tab to the user. */ + show() { const self = this; const { element, nav, dropdown } = self; - nextTab = element; - if (!hasClass(nextTab, activeClass)) { - // this is the actual object, the nextTab tab content to activate - nextTabContent = queryElement(nextTab.getAttribute('href')); - activeTab = getActiveTab({ nav }); - activeTabContent = getActiveTabContent({ nav }); + + if (!(nav && Timer.get(nav)) && !hasClass(element, activeClass)) { + const { tab, content } = getActiveTab(self); + + if (nav) tabPrivate.set(nav, { tab, content }); // update relatedTarget and dispatch - hideTabEvent.relatedTarget = nextTab; - activeTab.dispatchEvent(hideTabEvent); + hideTabEvent.relatedTarget = element; + dispatchEvent(tab, hideTabEvent); if (hideTabEvent.defaultPrevented) return; - nav.isAnimating = true; - removeClass(activeTab, activeClass); - activeTab.setAttribute(ariaSelected, 'false'); - addClass(nextTab, activeClass); - nextTab.setAttribute(ariaSelected, 'true'); + if (nav) Timer.set(nav, () => {}, 17); + removeClass(tab, activeClass); + setAttribute(tab, ariaSelected, 'false'); + addClass(element, activeClass); + setAttribute(element, ariaSelected, 'true'); if (dropdown) { + // @ts-ignore if (!hasClass(element.parentNode, dropdownMenuClass)) { if (hasClass(dropdown, activeClass)) removeClass(dropdown, activeClass); } else if (!hasClass(dropdown, activeClass)) addClass(dropdown, activeClass); } - if (hasClass(activeTabContent, fadeClass)) { - removeClass(activeTabContent, showClass); - emulateTransitionEnd(activeTabContent, () => triggerTabHide(self)); + if (hasClass(content, fadeClass)) { + removeClass(content, showClass); + emulateTransitionEnd(content, () => triggerTabHide(self)); } else { triggerTabHide(self); } } } + /** Removes the `Tab` component from the target element. */ dispose() { toggleTabHandler(this); - super.dispose(tabComponent); + super.dispose(); } } - Tab.init = { - component: tabComponent, + ObjectAssign(Tab, { selector: tabSelector, - constructor: Tab, - }; + init: tabInitCallback, + getInstance: getTabInstance, + }); + + /** @type {string} */ + const toastString = 'toast'; + + /** @type {string} */ + const toastComponent = 'Toast'; /* Native JavaScript for Bootstrap 5 | Toast -------------------------------------------- */ // TOAST PRIVATE GC // ================ - const toastString = 'toast'; - const toastComponent = 'Toast'; const toastSelector = `.${toastString}`; const toastDismissSelector = `[${dataBsDismiss}="${toastString}"]`; const showingClass = 'showing'; - const hideClass = 'hide'; // marked as deprecated - const toastDefaultOptions = { + /** @deprecated */ + const hideClass = 'hide'; + + const toastDefaults = { animation: true, autohide: true, - delay: 500, + delay: 5000, }; + /** + * Static method which returns an existing `Toast` instance associated + * to a target `Element`. + * + * @type {BSN.GetInstance<Toast>} + */ + const getToastInstance = (element) => getInstance(element, toastComponent); + + /** + * A `Toast` initialization callback. + * @type {BSN.InitCallback<Toast>} + */ + const toastInitCallback = (element) => new Toast(element); + // TOAST CUSTOM EVENTS // =================== - const showToastEvent = bootstrapCustomEvent(`show.bs.${toastString}`); - const hideToastEvent = bootstrapCustomEvent(`hide.bs.${toastString}`); - const shownToastEvent = bootstrapCustomEvent(`shown.bs.${toastString}`); - const hiddenToastEvent = bootstrapCustomEvent(`hidden.bs.${toastString}`); + const showToastEvent = OriginalEvent(`show.bs.${toastString}`); + const shownToastEvent = OriginalEvent(`shown.bs.${toastString}`); + const hideToastEvent = OriginalEvent(`hide.bs.${toastString}`); + const hiddenToastEvent = OriginalEvent(`hidden.bs.${toastString}`); // TOAST PRIVATE METHODS // ===================== + /** + * Executes after the toast is shown to the user. + * @param {Toast} self the `Toast` instance + */ function showToastComplete(self) { const { element, options } = self; removeClass(element, showingClass); + Timer.clear(element, showingClass); - element.dispatchEvent(shownToastEvent); - if (options.autohide) self.hide(); + dispatchEvent(element, shownToastEvent); + if (options.autohide) { + Timer.set(element, () => self.hide(), options.delay, toastString); + } } + /** + * Executes after the toast is hidden to the user. + * @param {Toast} self the `Toast` instance + */ function hideToastComplete(self) { const { element } = self; removeClass(element, showingClass); removeClass(element, showClass); addClass(element, hideClass); // B/C - element.dispatchEvent(hiddenToastEvent); + Timer.clear(element, toastString); + dispatchEvent(element, hiddenToastEvent); } + /** + * Executes before hiding the toast. + * @param {Toast} self the `Toast` instance + */ function hideToast(self) { const { element, options } = self; addClass(element, showingClass); @@ -3148,39 +5429,85 @@ } } + /** + * Executes before showing the toast. + * @param {Toast} self the `Toast` instance + */ function showToast(self) { const { element, options } = self; - removeClass(element, hideClass); // B/C - reflow(element); - addClass(element, showClass); - addClass(element, showingClass); + Timer.set(element, () => { + removeClass(element, hideClass); // B/C + reflow(element); + addClass(element, showClass); + addClass(element, showingClass); - if (options.animation) { - emulateTransitionEnd(element, () => showToastComplete(self)); - } else { - showToastComplete(self); - } + if (options.animation) { + emulateTransitionEnd(element, () => showToastComplete(self)); + } else { + showToastComplete(self); + } + }, 17, showingClass); } - function toggleToastHandler(self, add) { - const action = add ? addEventListener : removeEventListener; - if (self.dismiss) { - self.dismiss[action]('click', self.hide); + /** + * Toggles on/off the `click` event listener. + * @param {Toast} self the `Toast` instance + * @param {boolean=} add when `true`, it will add the listener + */ + function toggleToastHandlers(self, add) { + const action = add ? addListener : removeListener; + const { element, dismiss, options } = self; + if (dismiss) { + action(dismiss, mouseclickEvent, self.hide); + } + if (options.autohide) { + [focusinEvent, focusoutEvent, mouseenterEvent, mouseleaveEvent] + .forEach((e) => action(element, e, interactiveToastHandler)); } } // TOAST EVENT HANDLERS // ==================== + /** + * Executes after the instance has been disposed. + * @param {Toast} self the `Toast` instance + */ function completeDisposeToast(self) { - clearTimeout(self.timer); - toggleToastHandler(self); + Timer.clear(self.element, toastString); + toggleToastHandlers(self); + } + + /** + * Executes when user interacts with the toast without closing it, + * usually by hovering or focusing it. + * + * @this {HTMLElement | Element} + * @param {MouseEvent} e the `Toast` instance + */ + function interactiveToastHandler(e) { + const element = this; + const self = getToastInstance(element); + const { type, relatedTarget } = e; + // @ts-ignore + if (!self || (element === relatedTarget || element.contains(relatedTarget))) return; + + if ([mouseenterEvent, focusinEvent].includes(type)) { + Timer.clear(element, toastString); + } else { + Timer.set(element, () => self.hide(), self.options.delay, toastString); + } } // TOAST DEFINITION // ================ + /** Creates a new `Toast` instance. */ class Toast extends BaseComponent { + /** + * @param {HTMLElement | Element | string} target the target `.toast` element + * @param {BSN.Options.Toast=} config the instance options + */ constructor(target, config) { - super(toastComponent, target, toastDefaultOptions, config); + super(target, config); // bind const self = this; const { element, options } = self; @@ -3189,403 +5516,161 @@ if (options.animation && !hasClass(element, fadeClass)) addClass(element, fadeClass); else if (!options.animation && hasClass(element, fadeClass)) removeClass(element, fadeClass); // dismiss button - self.dismiss = queryElement(toastDismissSelector, element); + /** @type {(HTMLElement | Element)?} */ + self.dismiss = querySelector(toastDismissSelector, element); // bind self.show = self.show.bind(self); self.hide = self.hide.bind(self); // add event listener - toggleToastHandler(self, 1); - } + toggleToastHandlers(self, true); + } + + /* eslint-disable */ + /** + * Returns component name string. + * @readonly @static + */ + get name() { return toastComponent; } + /** + * Returns component default options. + * @readonly @static + */ + get defaults() { return toastDefaults; } + /* eslint-enable */ // TOAST PUBLIC METHODS // ==================== + /** Shows the toast. */ show() { const self = this; const { element } = self; if (element && !hasClass(element, showClass)) { - element.dispatchEvent(showToastEvent); + dispatchEvent(element, showToastEvent); if (showToastEvent.defaultPrevented) return; - clearTimeout(self.timer); - self.timer = setTimeout(() => showToast(self), 10); + showToast(self); } } - hide(noTimer) { + /** Hides the toast. */ + hide() { const self = this; - const { element, options } = self; + const { element } = self; if (element && hasClass(element, showClass)) { - element.dispatchEvent(hideToastEvent); + dispatchEvent(element, hideToastEvent); if (hideToastEvent.defaultPrevented) return; - - clearTimeout(self.timer); - self.timer = setTimeout(() => hideToast(self), - noTimer ? 10 : options.delay); + hideToast(self); } } + /** Removes the `Toast` component from the target element. */ dispose() { const self = this; - const { element, options } = self; - self.hide(1); + const { element } = self; + + if (hasClass(element, showClass)) { + removeClass(element, showClass); + } - if (options.animation) emulateTransitionEnd(element, () => completeDisposeToast(self)); - else completeDisposeToast(self); + completeDisposeToast(self); - super.dispose(toastComponent); + super.dispose(); } } - Toast.init = { - component: toastComponent, + ObjectAssign(Toast, { selector: toastSelector, - constructor: Toast, - }; - - const dataOriginalTitle = 'data-original-title'; - - /* Native JavaScript for Bootstrap 5 | Tooltip - ---------------------------------------------- */ - - // TOOLTIP PRIVATE GC - // ================== - const tooltipString = 'tooltip'; - const tooltipComponent = 'Tooltip'; - const tooltipSelector = `[${dataBsToggle}="${tooltipString}"],[data-tip="${tooltipString}"]`; - - const titleAttr = 'title'; - const tooltipInnerClass = `${tooltipString}-inner`; - const tooltipDefaultOptions = { - template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>', - title: null, // string - customClass: null, // string | null - placement: 'top', // string - sanitizeFn: null, // function - animation: true, // bool - html: false, // bool - delay: 200, // number + init: toastInitCallback, + getInstance: getToastInstance, + }); + + /** + * Check if element matches a CSS selector. + * + * @param {HTMLElement | Element} target + * @param {string} selector + * @returns {boolean} + */ + function matches(target, selector) { + return target.matches(selector); + } + + /** @type {Record<string, any>} */ + const componentsList = { + Alert, + Button, + Carousel, + Collapse, + Dropdown, + Modal, + Offcanvas, + Popover, + ScrollSpy, + Tab, + Toast, + Tooltip, }; - // TOOLTIP CUSTOM EVENTS - // ===================== - const showTooltipEvent = bootstrapCustomEvent(`show.bs.${tooltipString}`); - const shownTooltipEvent = bootstrapCustomEvent(`shown.bs.${tooltipString}`); - const hideTooltipEvent = bootstrapCustomEvent(`hide.bs.${tooltipString}`); - const hiddenTooltipEvent = bootstrapCustomEvent(`hidden.bs.${tooltipString}`); - - // TOOLTIP PRIVATE METHODS - // ======================= - function createTooltip(self) { - const { options, id } = self; - const { - title, template, customClass, animation, placement, sanitizeFn, - } = options; - const placementClass = `bs-${tooltipString}-${tipClassPositions[placement]}`; - - if (!title) return; - - // load template - let tooltipTemplate; - if (typeof template === 'object') { - tooltipTemplate = template; - } else { - const htmlMarkup = document.createElement('div'); - setHtml(htmlMarkup, template, sanitizeFn); - tooltipTemplate = htmlMarkup.firstChild; - } - - // create tooltip - self.tooltip = tooltipTemplate.cloneNode(true); - const { tooltip } = self; - // set title - setHtml(queryElement(`.${tooltipInnerClass}`, tooltip), title, sanitizeFn); - // set id & role attribute - tooltip.setAttribute('id', id); - tooltip.setAttribute('role', tooltipString); - - // set arrow - self.arrow = queryElement(`.${tooltipString}-arrow`, tooltip); - - // set classes - if (!hasClass(tooltip, tooltipString)) addClass(tooltip, tooltipString); - if (animation && !hasClass(tooltip, fadeClass)) addClass(tooltip, fadeClass); - if (customClass && !hasClass(tooltip, customClass)) { - addClass(tooltip, customClass); - } - if (!hasClass(tooltip, placementClass)) addClass(tooltip, placementClass); - } - - function removeTooltip(self) { - const { element, tooltip } = self; - element.removeAttribute(ariaDescribedBy); - tooltip.remove(); - self.timer = null; - } - - function disposeTooltipComplete(self) { - const { element } = self; - toggleTooltipHandlers(self); - if (element.hasAttribute(dataOriginalTitle)) toggleTooltipTitle(self); - } - function toggleTooltipAction(self, add) { - const action = add ? addEventListener : removeEventListener; - - document[action]('touchstart', tooltipTouchHandler, passiveHandler); - - if (!isMedia(self.element)) { - window[action]('scroll', self.update, passiveHandler); - window[action]('resize', self.update, passiveHandler); - } - } - function tooltipShownAction(self) { - toggleTooltipAction(self, 1); - self.element.dispatchEvent(shownTooltipEvent); - } - function tooltipHiddenAction(self) { - toggleTooltipAction(self); - removeTooltip(self); - self.element.dispatchEvent(hiddenTooltipEvent); - } - function toggleTooltipHandlers(self, add) { - const action = add ? addEventListener : removeEventListener; - const { element } = self; - - if (isMedia(element)) element[action]('mousemove', self.update, passiveHandler); - element[action]('mousedown', self.show); - element[action]('mouseenter', self.show); - element[action]('mouseleave', self.hide); - } - - function toggleTooltipTitle(self, content) { - // [0 - add, 1 - remove] | [0 - remove, 1 - add] - const titleAtt = [dataOriginalTitle, titleAttr]; - const { element } = self; - - element.setAttribute(titleAtt[content ? 0 : 1], - (content || element.getAttribute(titleAtt[0]))); - element.removeAttribute(titleAtt[content ? 1 : 0]); - } - - // TOOLTIP EVENT HANDLERS - // ====================== - function tooltipTouchHandler({ target }) { - const { tooltip, element } = this; - if (tooltip.contains(target) || target === element || element.contains(target)) ; else { - this.hide(); - } - } - - // TOOLTIP DEFINITION - // ================== - class Tooltip extends BaseComponent { - constructor(target, config) { - // initialization element - const element = queryElement(target); - tooltipDefaultOptions.title = element.getAttribute(titleAttr); - tooltipDefaultOptions.container = getTipContainer(element); - super(tooltipComponent, element, tooltipDefaultOptions, config); - - // bind - const self = this; - - // additional properties - self.tooltip = null; - self.arrow = null; - self.timer = null; - self.enabled = false; - - // instance options - const { options } = self; - - // media elements only work with body as a container - self.options.container = isMedia(element) - ? tooltipDefaultOptions.container - : queryElement(options.container); - - // reset default options - tooltipDefaultOptions.container = null; - tooltipDefaultOptions[titleAttr] = null; - - // invalidate - if (!options.title) return; - - // all functions bind - tooltipTouchHandler.bind(self); - self.update = self.update.bind(self); - - // set title attributes and add event listeners - if (element.hasAttribute(titleAttr)) toggleTooltipTitle(self, options.title); - - // create tooltip here - self.id = `${tooltipString}-${getUID(element)}`; - createTooltip(self); - - // set positions - const { container } = self.options; - const elementPosition = getComputedStyle(element).position; - const containerPosition = getComputedStyle(container).position; - const parentIsBody = container === document.body; - const containerIsStatic = !parentIsBody && containerPosition === 'static'; - const containerIsRelative = !parentIsBody && containerPosition === 'relative'; - const relContainer = containerIsStatic && closestRelative(container); - self.positions = { - elementPosition, - containerIsRelative, - containerIsStatic, - relContainer, - }; - - // attach events - toggleTooltipHandlers(self, 1); - } - - // TOOLTIP PUBLIC METHODS - // ====================== - show(e) { - const self = e ? this[tooltipComponent] : this; - const { - options, tooltip, element, id, - } = self; - const { - container, animation, - } = options; - clearTimeout(self.timer); - if (!isVisibleTip(tooltip, container)) { - element.dispatchEvent(showTooltipEvent); - if (showTooltipEvent.defaultPrevented) return; - - // append to container - container.append(tooltip); - element.setAttribute(ariaDescribedBy, id); - - self.update(e); - if (!hasClass(tooltip, showClass)) addClass(tooltip, showClass); - if (animation) emulateTransitionEnd(tooltip, () => tooltipShownAction(self)); - else tooltipShownAction(self); - } - } - - hide(e) { - const self = e ? this[tooltipComponent] : this; - const { options, tooltip, element } = self; - - clearTimeout(self.timer); - self.timer = setTimeout(() => { - if (isVisibleTip(tooltip, options.container)) { - element.dispatchEvent(hideTooltipEvent); - if (hideTooltipEvent.defaultPrevented) return; - - removeClass(tooltip, showClass); - if (options.animation) emulateTransitionEnd(tooltip, () => tooltipHiddenAction(self)); - else tooltipHiddenAction(self); - } - }, options.delay); - } - - update(e) { - styleTip(this, e); - } - - toggle() { - const self = this; - const { tooltip, options } = self; - if (!isVisibleTip(tooltip, options.container)) self.show(); - else self.hide(); - } - - enable() { - const self = this; - const { enabled } = self; - if (!enabled) { - toggleTooltipHandlers(self, 1); - self.enabled = !enabled; - } - } - - disable() { - const self = this; - const { tooltip, options, enabled } = self; - if (enabled) { - if (!isVisibleTip(tooltip, options.container) && options.animation) { - self.hide(); - - setTimeout( - () => toggleTooltipHandlers(self), - getElementTransitionDuration(tooltip) + options.delay + 17, - ); - } else { - toggleTooltipHandlers(self); - } - self.enabled = !enabled; - } - } - - toggleEnabled() { - const self = this; - if (!self.enabled) self.enable(); - else self.disable(); - } - - dispose() { - const self = this; - const { tooltip, options } = self; - - if (options.animation && isVisibleTip(tooltip, options.container)) { - options.delay = 0; // reset delay - self.hide(); - emulateTransitionEnd(tooltip, () => disposeTooltipComplete(self)); - } else { - disposeTooltipComplete(self); - } - super.dispose(tooltipComponent); + /** + * Initialize all matched `Element`s for one component. + * @param {BSN.InitCallback<any>} callback + * @param {NodeListOf<HTMLElement | Element> | (HTMLElement | Element)[]} collection + */ + function initComponentDataAPI(callback, collection) { + [...collection].forEach((x) => callback(x)); + } + + /** + * Remove one component from a target container element or all in the page. + * @param {string} component the component name + * @param {(Element | HTMLElement | Document)=} context parent `Element` + */ + function removeComponentDataAPI(component, context) { + const compData = Data.getAllFor(component); + + if (compData) { + [...compData].forEach((x) => { + const [element, instance] = x; + if (context && context.contains(element)) instance.dispose(); + }); } } - Tooltip.init = { - component: tooltipComponent, - selector: tooltipSelector, - constructor: Tooltip, - }; - - var version = "4.0.8"; - - const Version = version; - - const componentsInit = { - Alert: Alert.init, - Button: Button.init, - Carousel: Carousel.init, - Collapse: Collapse.init, - Dropdown: Dropdown.init, - Modal: Modal.init, - Offcanvas: Offcanvas.init, - Popover: Popover.init, - ScrollSpy: ScrollSpy.init, - Tab: Tab.init, - Toast: Toast.init, - Tooltip: Tooltip.init, - }; + /** + * Initialize all BSN components for a target container. + * @param {(Element | HTMLElement | Document)=} context parent `Element` + */ + function initCallback(context) { + const lookUp = context && parentNodes.some((x) => context instanceof x) + ? context : undefined; + const elemCollection = [...getElementsByTagName('*', lookUp)]; - function initializeDataAPI(Konstructor, collection) { - Array.from(collection).forEach((x) => new Konstructor(x)); + ObjectKeys(componentsList).forEach((comp) => { + const { init, selector } = componentsList[comp]; + initComponentDataAPI(init, elemCollection.filter((item) => matches(item, selector))); + }); } - function initCallback(context) { - const lookUp = context instanceof Element ? context : document; + /** + * Remove all BSN components for a target container. + * @param {(Element | HTMLElement | Document)=} context parent `Element` + */ + function removeDataAPI(context) { + const lookUp = context && parentNodes.some((x) => context instanceof x) + ? context : undefined; - Object.keys(componentsInit).forEach((comp) => { - const { constructor, selector } = componentsInit[comp]; - initializeDataAPI(constructor, lookUp.querySelectorAll(selector)); + ObjectKeys(componentsList).forEach((comp) => { + removeComponentDataAPI(comp, lookUp); }); } // bulk initialize all components if (document.body) initCallback(); else { - document.addEventListener('DOMContentLoaded', () => initCallback(), { once: true }); + addListener(document, 'DOMContentLoaded', () => initCallback(), { once: true }); } const BSN = { @@ -3603,9 +5688,11 @@ Tooltip, initCallback, + removeDataAPI, Version, + EventListener, }; return BSN; -}));
\ No newline at end of file +})); diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 989e0960..62353e62 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,22 +4,13 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-1.11.3 + * https://datatables.net/download/#bs5/dt-1.11.4 * * Included libraries: - * DataTables 1.11.3 + * DataTables 1.11.4 */ @charset "UTF-8"; -td.dt-control { - background: url("https://www.datatables.net/examples/resources/details_open.png") no-repeat center center; - cursor: pointer; -} - -tr.dt-hasChild td.dt-control { - background: url("https://www.datatables.net/examples/resources/details_close.png") no-repeat center center; -} - table.dataTable th.dt-left, table.dataTable td.dt-left { text-align: left; @@ -91,6 +82,31 @@ table.dataTable tbody th.dt-body-nowrap, table.dataTable tbody td.dt-body-nowrap { white-space: nowrap; } +table.dataTable td.dt-control { + text-align: center; + cursor: pointer; +} +table.dataTable td.dt-control:before { + height: 1em; + width: 1em; + margin-top: -9px; + display: inline-block; + color: white; + border: 0.15em solid white; + border-radius: 1em; + box-shadow: 0 0 0.2em #444; + box-sizing: content-box; + text-align: center; + text-indent: 0 !important; + font-family: "Courier New", Courier, monospace; + line-height: 1em; + content: "+"; + background-color: #31b131; +} +table.dataTable tr.dt-hasChild td.dt-control:before { + content: "-"; + background-color: #d33333; +} /*! Bootstrap 5 integration for DataTables * diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index 0d88756e..2220b3a2 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,20 +4,20 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-1.11.3 + * https://datatables.net/download/#bs5/dt-1.11.4 * * Included libraries: - * DataTables 1.11.3 + * DataTables 1.11.4 */ -/*! DataTables 1.11.3 +/*! DataTables 1.11.4 * ©2008-2021 SpryMedia Ltd - datatables.net/license */ /** * @summary DataTables * @description Paginate, search and order HTML tables - * @version 1.11.3 + * @version 1.11.4 * @file jquery.dataTables.js * @author SpryMedia Ltd * @contact www.datatables.net @@ -3462,6 +3462,9 @@ */ function _fnDraw( oSettings, ajaxComplete ) { + // Allow for state saving and a custom start position + _fnStart( oSettings ); + /* Provide a pre-callback function which can be used to cancel the draw is false is returned */ var aPreDraw = _fnCallbackFire( oSettings, 'aoPreDrawCallback', 'preDraw', [oSettings] ); if ( $.inArray( false, aPreDraw ) !== -1 ) @@ -3470,34 +3473,18 @@ return; } - var i, iLen, n; var anRows = []; var iRowCount = 0; var asStripeClasses = oSettings.asStripeClasses; var iStripes = asStripeClasses.length; - var iOpenRows = oSettings.aoOpenRows.length; var oLang = oSettings.oLanguage; - var iInitDisplayStart = oSettings.iInitDisplayStart; var bServerSide = _fnDataSource( oSettings ) == 'ssp'; var aiDisplay = oSettings.aiDisplay; - - oSettings.bDrawing = true; - - /* Check and see if we have an initial draw position from state saving */ - if ( iInitDisplayStart !== undefined && iInitDisplayStart !== -1 ) - { - oSettings._iDisplayStart = bServerSide ? - iInitDisplayStart : - iInitDisplayStart >= oSettings.fnRecordsDisplay() ? - 0 : - iInitDisplayStart; - - oSettings.iInitDisplayStart = -1; - } - var iDisplayStart = oSettings._iDisplayStart; var iDisplayEnd = oSettings.fnDisplayEnd(); + oSettings.bDrawing = true; + /* Server-side processing draw intercept */ if ( oSettings.bDeferLoading ) { @@ -3900,6 +3887,28 @@ } /** + * Set the start position for draw + * @param {object} oSettings dataTables settings object + */ + function _fnStart( oSettings ) + { + var bServerSide = _fnDataSource( oSettings ) == 'ssp'; + var iInitDisplayStart = oSettings.iInitDisplayStart; + + // Check and see if we have an initial draw position from state saving + if ( iInitDisplayStart !== undefined && iInitDisplayStart !== -1 ) + { + oSettings._iDisplayStart = bServerSide ? + iInitDisplayStart : + iInitDisplayStart >= oSettings.fnRecordsDisplay() ? + 0 : + iInitDisplayStart; + + oSettings.iInitDisplayStart = -1; + } + } + + /** * Create an Ajax call based on the table's settings, taking into account that * parameters can have multiple forms, and backwards compatibility. * @@ -3942,8 +3951,8 @@ var ajax = oSettings.ajax; var instance = oSettings.oInstance; var callback = function ( json ) { - var status = oSettings.jqXhr - ? oSettings.jqXhr.status + var status = oSettings.jqXHR + ? oSettings.jqXHR.status : null; if ( json === null || (typeof status === 'number' && status == 204 ) ) { @@ -5487,7 +5496,7 @@ // Sanity check that the table is of a sensible width. If not then we are going to get // misalignment - try to prevent this by not allowing the table to shrink below its min width - if ( table.outerWidth() < sanityWidth ) + if ( Math.round(table.outerWidth()) < Math.round(sanityWidth) ) { // The min width depends upon if we have a vertical scrollbar visible or not */ correction = ((divBodyEl.scrollHeight > divBodyEl.offsetHeight || @@ -6496,10 +6505,14 @@ // Restore key features - todo - for 1.11 this needs to be done by // subscribed events if ( s.start !== undefined ) { - settings._iDisplayStart = s.start; if(api === null) { + settings._iDisplayStart = s.start; settings.iInitDisplayStart = s.start; } + else { + _fnPageChange(settings, s.start/s.length); + + } } if ( s.length !== undefined ) { settings._iDisplayLength = s.length; @@ -9644,7 +9657,7 @@ * @type string * @default Version number */ - DataTable.version = "1.11.3"; + DataTable.version = "1.11.4"; /** * Private data store, containing all of the settings objects that are @@ -14069,7 +14082,7 @@ * * @type string */ - build:"bs5/dt-1.11.3", + build:"bs5/dt-1.11.4", /** diff --git a/src/util.rs b/src/util.rs index 510c0cf2..de61a354 100644 --- a/src/util.rs +++ b/src/util.rs @@ -616,7 +616,13 @@ where use reqwest::{header, Client, ClientBuilder}; pub fn get_reqwest_client() -> Client { - get_reqwest_client_builder().build().expect("Failed to build client") + match get_reqwest_client_builder().build() { + Ok(client) => client, + Err(e) => { + error!("Possible trust-dns error, trying with trust-dns disabled: '{e}'"); + get_reqwest_client_builder().trust_dns(false).build().expect("Failed to build client") + } + } } pub fn get_reqwest_client_builder() -> ClientBuilder { |