aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBlackDex <[email protected]>2022-02-22 20:48:00 +0100
committerBlackDex <[email protected]>2022-02-26 13:56:42 +0100
commit42136a70973f60086749c62439c6a965d4589c02 (patch)
tree35979fc1ad0c6e6649271fa24ed0ba24b3135ef9
parent5f01db69ffdb3d37e24e30a7003792ed72882973 (diff)
downloadvaultwarden-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.template3
-rw-r--r--.pre-commit-config.yaml2
-rw-r--r--Cargo.lock356
-rw-r--r--Cargo.toml14
-rw-r--r--docker/Dockerfile.j26
-rw-r--r--docker/armv7/Dockerfile.alpine2
-rw-r--r--docker/armv7/Dockerfile.buildx.alpine2
-rw-r--r--src/api/admin.rs4
-rw-r--r--src/api/core/organizations.rs4
-rw-r--r--src/api/icons.rs530
-rw-r--r--src/config.rs33
-rw-r--r--src/mail.rs4
-rw-r--r--src/static/scripts/bootstrap-native.js5489
-rw-r--r--src/static/scripts/datatables.css38
-rw-r--r--src/static/scripts/datatables.js69
-rw-r--r--src/util.rs8
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
diff --git a/Cargo.lock b/Cargo.lock
index 9516efe5..9de3455d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/Cargo.toml b/Cargo.toml
index d67464f1..1f76c0ed 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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 {