summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2023-01-24 12:33:51 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2023-01-24 12:33:51 +0000
commit3ea39841c8049525e31e9f4d6300f0c60cdb42de (patch)
tree855de60a8872eafb5911acd303aedcdbfe713a73
parentInital commit. (diff)
downloadbootstrap-html-3ea39841c8049525e31e9f4d6300f0c60cdb42de.tar.xz
bootstrap-html-3ea39841c8049525e31e9f4d6300f0c60cdb42de.zip
Adding upstream version 5.2.3+dfsg.upstream/5.2.3+dfsgupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.babelrc.js12
-rw-r--r--.browserslistrc11
-rw-r--r--.bundlewatch.config.json66
-rw-r--r--.cspell.json130
-rw-r--r--.editorconfig11
-rw-r--r--.eslintignore8
-rw-r--r--.eslintrc.json65
-rw-r--r--.gitattributes8
-rw-r--r--.github/CODEOWNERS3
-rw-r--r--.github/CONTRIBUTING.md237
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.yml62
-rw-r--r--.github/ISSUE_TEMPLATE/config.yml4
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.yml29
-rw-r--r--.github/PULL_REQUEST_TEMPLATE.md38
-rw-r--r--.github/SUPPORT.md11
-rw-r--r--.github/dependabot.yml24
-rw-r--r--.github/release-drafter.yml60
-rw-r--r--.github/workflows/browserstack.yml38
-rw-r--r--.github/workflows/bundlewatch.yml38
-rw-r--r--.github/workflows/calibreapp-image-actions.yml24
-rw-r--r--.github/workflows/codeql.yml38
-rw-r--r--.github/workflows/cspell.yml28
-rw-r--r--.github/workflows/css.yml32
-rw-r--r--.github/workflows/docs.yml45
-rw-r--r--.github/workflows/issue-close-require.yml19
-rw-r--r--.github/workflows/issue-labeled.yml19
-rw-r--r--.github/workflows/js.yml42
-rw-r--r--.github/workflows/lint.yml32
-rw-r--r--.github/workflows/node-sass.yml31
-rw-r--r--.github/workflows/release-notes.yml16
-rw-r--r--.gitignore42
-rw-r--r--.stylelintignore5
-rw-r--r--.stylelintrc31
-rw-r--r--CODE_OF_CONDUCT.md132
-rw-r--r--LICENSE22
-rw-r--r--README.md246
-rw-r--r--SECURITY.md7
-rw-r--r--build/.eslintrc.json15
-rw-r--r--build/banner.js14
-rw-r--r--build/build-plugins.js104
-rw-r--r--build/change-version.js81
-rw-r--r--build/generate-sri.js64
-rw-r--r--build/postcss.config.js19
-rw-r--r--build/rollup.config.js57
-rw-r--r--build/vnu-jar.js57
-rw-r--r--build/zip-examples.js90
-rw-r--r--composer.json32
-rw-r--r--config.yml88
-rw-r--r--js/index.esm.js19
-rw-r--r--js/index.umd.js34
-rw-r--r--js/src/alert.js87
-rw-r--r--js/src/base-component.js85
-rw-r--r--js/src/button.js72
-rw-r--r--js/src/carousel.js475
-rw-r--r--js/src/collapse.js302
-rw-r--r--js/src/dom/data.js55
-rw-r--r--js/src/dom/event-handler.js320
-rw-r--r--js/src/dom/manipulator.js71
-rw-r--r--js/src/dom/selector-engine.js83
-rw-r--r--js/src/dropdown.js454
-rw-r--r--js/src/modal.js377
-rw-r--r--js/src/offcanvas.js283
-rw-r--r--js/src/popover.js97
-rw-r--r--js/src/scrollspy.js294
-rw-r--r--js/src/tab.js305
-rw-r--r--js/src/toast.js225
-rw-r--r--js/src/tooltip.js633
-rw-r--r--js/src/util/backdrop.js149
-rw-r--r--js/src/util/component-functions.js34
-rw-r--r--js/src/util/config.js66
-rw-r--r--js/src/util/focustrap.js115
-rw-r--r--js/src/util/index.js336
-rw-r--r--js/src/util/sanitizer.js118
-rw-r--r--js/src/util/scrollbar.js114
-rw-r--r--js/src/util/swipe.js146
-rw-r--r--js/src/util/template-factory.js160
-rw-r--r--js/tests/README.md73
-rw-r--r--js/tests/browsers.js79
-rw-r--r--js/tests/helpers/fixture.js47
-rw-r--r--js/tests/integration/bundle-modularity.js7
-rw-r--r--js/tests/integration/bundle.js6
-rw-r--r--js/tests/integration/index.html67
-rw-r--r--js/tests/integration/rollup.bundle-modularity.js17
-rw-r--r--js/tests/integration/rollup.bundle.js24
-rw-r--r--js/tests/karma.conf.js171
-rw-r--r--js/tests/unit/.eslintrc.json13
-rw-r--r--js/tests/unit/alert.spec.js259
-rw-r--r--js/tests/unit/base-component.spec.js168
-rw-r--r--js/tests/unit/button.spec.js183
-rw-r--r--js/tests/unit/carousel.spec.js1570
-rw-r--r--js/tests/unit/collapse.spec.js1062
-rw-r--r--js/tests/unit/dom/data.spec.js106
-rw-r--r--js/tests/unit/dom/event-handler.spec.js480
-rw-r--r--js/tests/unit/dom/manipulator.spec.js135
-rw-r--r--js/tests/unit/dom/selector-engine.spec.js236
-rw-r--r--js/tests/unit/dropdown.spec.js2430
-rw-r--r--js/tests/unit/jquery.spec.js60
-rw-r--r--js/tests/unit/modal.spec.js1298
-rw-r--r--js/tests/unit/offcanvas.spec.js912
-rw-r--r--js/tests/unit/popover.spec.js413
-rw-r--r--js/tests/unit/scrollspy.spec.js946
-rw-r--r--js/tests/unit/tab.spec.js1101
-rw-r--r--js/tests/unit/toast.spec.js670
-rw-r--r--js/tests/unit/tooltip.spec.js1551
-rw-r--r--js/tests/unit/util/backdrop.spec.js321
-rw-r--r--js/tests/unit/util/component-functions.spec.js108
-rw-r--r--js/tests/unit/util/config.spec.js166
-rw-r--r--js/tests/unit/util/focustrap.spec.js218
-rw-r--r--js/tests/unit/util/index.spec.js814
-rw-r--r--js/tests/unit/util/sanitizer.spec.js105
-rw-r--r--js/tests/unit/util/scrollbar.spec.js363
-rw-r--r--js/tests/unit/util/swipe.spec.js291
-rw-r--r--js/tests/unit/util/template-factory.spec.js306
-rw-r--r--js/tests/visual/.eslintrc.json19
-rw-r--r--js/tests/visual/alert.html48
-rw-r--r--js/tests/visual/button.html49
-rw-r--r--js/tests/visual/carousel.html65
-rw-r--r--js/tests/visual/collapse.html76
-rw-r--r--js/tests/visual/dropdown.html205
-rw-r--r--js/tests/visual/modal.html275
-rw-r--r--js/tests/visual/popover.html41
-rw-r--r--js/tests/visual/scrollspy.html91
-rw-r--r--js/tests/visual/tab.html223
-rw-r--r--js/tests/visual/toast.html70
-rw-r--r--js/tests/visual/tooltip.html138
-rw-r--r--nuget/MyGet.ps117
-rw-r--r--nuget/bootstrap.nuspec35
-rw-r--r--nuget/bootstrap.pngbin0 -> 6636 bytes
-rw-r--r--nuget/bootstrap.sass.nuspec35
-rw-r--r--package-lock.json18333
-rw-r--r--package.js18
-rw-r--r--package.json181
-rw-r--r--scss/_accordion.scss149
-rw-r--r--scss/_alert.scss71
-rw-r--r--scss/_badge.scss38
-rw-r--r--scss/_breadcrumb.scss40
-rw-r--r--scss/_button-group.scss142
-rw-r--r--scss/_buttons.scss207
-rw-r--r--scss/_card.scss234
-rw-r--r--scss/_carousel.scss226
-rw-r--r--scss/_close.scss40
-rw-r--r--scss/_containers.scss41
-rw-r--r--scss/_dropdown.scss249
-rw-r--r--scss/_forms.scss9
-rw-r--r--scss/_functions.scss302
-rw-r--r--scss/_grid.scss33
-rw-r--r--scss/_helpers.scss10
-rw-r--r--scss/_images.scss42
-rw-r--r--scss/_list-group.scss192
-rw-r--r--scss/_maps.scss54
-rw-r--r--scss/_mixins.scss43
-rw-r--r--scss/_modal.scss237
-rw-r--r--scss/_nav.scss172
-rw-r--r--scss/_navbar.scss278
-rw-r--r--scss/_offcanvas.scss144
-rw-r--r--scss/_pagination.scss109
-rw-r--r--scss/_placeholders.scss51
-rw-r--r--scss/_popover.scss196
-rw-r--r--scss/_progress.scss59
-rw-r--r--scss/_reboot.scss610
-rw-r--r--scss/_root.scss73
-rw-r--r--scss/_spinners.scss85
-rw-r--r--scss/_tables.scss164
-rw-r--r--scss/_toasts.scss73
-rw-r--r--scss/_tooltip.scss120
-rw-r--r--scss/_transitions.scss27
-rw-r--r--scss/_type.scss106
-rw-r--r--scss/_utilities.scss647
-rw-r--r--scss/_variables.scss1634
-rw-r--r--scss/bootstrap-grid.scss64
-rw-r--r--scss/bootstrap-reboot.scss9
-rw-r--r--scss/bootstrap-utilities.scss18
-rw-r--r--scss/bootstrap.scss51
-rw-r--r--scss/forms/_floating-labels.scss75
-rw-r--r--scss/forms/_form-check.scss175
-rw-r--r--scss/forms/_form-control.scss194
-rw-r--r--scss/forms/_form-range.scss91
-rw-r--r--scss/forms/_form-select.scss71
-rw-r--r--scss/forms/_form-text.scss11
-rw-r--r--scss/forms/_input-group.scss132
-rw-r--r--scss/forms/_labels.scss36
-rw-r--r--scss/forms/_validation.scss12
-rw-r--r--scss/helpers/_clearfix.scss3
-rw-r--r--scss/helpers/_color-bg.scss10
-rw-r--r--scss/helpers/_colored-links.scss12
-rw-r--r--scss/helpers/_position.scss36
-rw-r--r--scss/helpers/_ratio.scss26
-rw-r--r--scss/helpers/_stacks.scss15
-rw-r--r--scss/helpers/_stretched-link.scss15
-rw-r--r--scss/helpers/_text-truncation.scss7
-rw-r--r--scss/helpers/_visually-hidden.scss8
-rw-r--r--scss/helpers/_vr.scss8
-rw-r--r--scss/mixins/_alert.scss15
-rw-r--r--scss/mixins/_backdrop.scss14
-rw-r--r--scss/mixins/_banner.scss9
-rw-r--r--scss/mixins/_border-radius.scss78
-rw-r--r--scss/mixins/_box-shadow.scss18
-rw-r--r--scss/mixins/_breakpoints.scss127
-rw-r--r--scss/mixins/_buttons.scss70
-rw-r--r--scss/mixins/_caret.scss64
-rw-r--r--scss/mixins/_clearfix.scss9
-rw-r--r--scss/mixins/_color-scheme.scss7
-rw-r--r--scss/mixins/_container.scss11
-rw-r--r--scss/mixins/_deprecate.scss10
-rw-r--r--scss/mixins/_forms.scss152
-rw-r--r--scss/mixins/_gradients.scss47
-rw-r--r--scss/mixins/_grid.scss151
-rw-r--r--scss/mixins/_image.scss16
-rw-r--r--scss/mixins/_list-group.scss24
-rw-r--r--scss/mixins/_lists.scss7
-rw-r--r--scss/mixins/_pagination.scss10
-rw-r--r--scss/mixins/_reset-text.scss17
-rw-r--r--scss/mixins/_resize.scss6
-rw-r--r--scss/mixins/_table-variants.scss24
-rw-r--r--scss/mixins/_text-truncate.scss8
-rw-r--r--scss/mixins/_transition.scss26
-rw-r--r--scss/mixins/_utilities.scss97
-rw-r--r--scss/mixins/_visually-hidden.scss29
-rw-r--r--scss/utilities/_api.scss47
-rw-r--r--scss/vendor/_rfs.scss354
-rw-r--r--site/.eslintrc.json54
-rw-r--r--site/assets/js/application.js30
-rw-r--r--site/assets/js/code-examples.js88
-rw-r--r--site/assets/js/search.js47
-rw-r--r--site/assets/js/snippets.js154
-rw-r--r--site/assets/scss/_ads.scss38
-rw-r--r--site/assets/scss/_anchor.scss21
-rw-r--r--site/assets/scss/_brand.scss60
-rw-r--r--site/assets/scss/_buttons.scss45
-rw-r--r--site/assets/scss/_callouts.scss35
-rw-r--r--site/assets/scss/_clipboard-js.scss44
-rw-r--r--site/assets/scss/_colors.scss156
-rw-r--r--site/assets/scss/_component-examples.scss359
-rw-r--r--site/assets/scss/_content.scss126
-rw-r--r--site/assets/scss/_footer.scss16
-rw-r--r--site/assets/scss/_layout.scss57
-rw-r--r--site/assets/scss/_masthead.scss96
-rw-r--r--site/assets/scss/_navbar.scss91
-rw-r--r--site/assets/scss/_placeholder-img.scss15
-rw-r--r--site/assets/scss/_search.scss141
-rw-r--r--site/assets/scss/_sidebar.scss53
-rw-r--r--site/assets/scss/_skippy.scss7
-rw-r--r--site/assets/scss/_syntax.scss115
-rw-r--r--site/assets/scss/_toc.scss87
-rw-r--r--site/assets/scss/_variables.scss23
-rw-r--r--site/assets/scss/docs.scss59
-rw-r--r--site/content/404.md13
-rw-r--r--site/content/docs/5.2/_index.html5
-rw-r--r--site/content/docs/5.2/about/brand.md47
-rw-r--r--site/content/docs/5.2/about/license.md34
-rw-r--r--site/content/docs/5.2/about/overview.md29
-rw-r--r--site/content/docs/5.2/about/team.md23
-rw-r--r--site/content/docs/5.2/about/translations.md20
-rw-r--r--site/content/docs/5.2/components/accordion.md165
-rw-r--r--site/content/docs/5.2/components/alerts.md257
-rw-r--r--site/content/docs/5.2/components/badge.md103
-rw-r--r--site/content/docs/5.2/components/breadcrumb.md111
-rw-r--r--site/content/docs/5.2/components/button-group.md256
-rw-r--r--site/content/docs/5.2/components/buttons.md242
-rw-r--r--site/content/docs/5.2/components/card.md739
-rw-r--r--site/content/docs/5.2/components/carousel.md385
-rw-r--r--site/content/docs/5.2/components/close-button.md38
-rw-r--r--site/content/docs/5.2/components/collapse.md198
-rw-r--r--site/content/docs/5.2/components/dropdowns.md1124
-rw-r--r--site/content/docs/5.2/components/list-group.md494
-rw-r--r--site/content/docs/5.2/components/modal.md888
-rw-r--r--site/content/docs/5.2/components/navbar.md813
-rw-r--r--site/content/docs/5.2/components/navs-tabs.md671
-rw-r--r--site/content/docs/5.2/components/offcanvas.md353
-rw-r--r--site/content/docs/5.2/components/pagination.md177
-rw-r--r--site/content/docs/5.2/components/placeholders.md145
-rw-r--r--site/content/docs/5.2/components/popovers.md284
-rw-r--r--site/content/docs/5.2/components/progress.md154
-rw-r--r--site/content/docs/5.2/components/scrollspy.md427
-rw-r--r--site/content/docs/5.2/components/spinners.md213
-rw-r--r--site/content/docs/5.2/components/toasts.md401
-rw-r--r--site/content/docs/5.2/components/tooltips.md301
-rw-r--r--site/content/docs/5.2/content/figures.md33
-rw-r--r--site/content/docs/5.2/content/images.md63
-rw-r--r--site/content/docs/5.2/content/reboot.md446
-rw-r--r--site/content/docs/5.2/content/tables.md835
-rw-r--r--site/content/docs/5.2/content/typography.md286
-rw-r--r--site/content/docs/5.2/customize/color.md151
-rw-r--r--site/content/docs/5.2/customize/components.md77
-rw-r--r--site/content/docs/5.2/customize/css-variables.md60
-rw-r--r--site/content/docs/5.2/customize/optimize.md95
-rw-r--r--site/content/docs/5.2/customize/options.md31
-rw-r--r--site/content/docs/5.2/customize/overview.md51
-rw-r--r--site/content/docs/5.2/customize/sass.md311
-rw-r--r--site/content/docs/5.2/examples/.stylelintrc15
-rw-r--r--site/content/docs/5.2/examples/_index.md45
-rw-r--r--site/content/docs/5.2/examples/album-rtl/index.html209
-rw-r--r--site/content/docs/5.2/examples/album/index.html208
-rw-r--r--site/content/docs/5.2/examples/blog-rtl/index.html206
-rw-r--r--site/content/docs/5.2/examples/blog/blog.css69
-rw-r--r--site/content/docs/5.2/examples/blog/blog.rtl.css69
-rw-r--r--site/content/docs/5.2/examples/blog/index.html258
-rw-r--r--site/content/docs/5.2/examples/carousel-rtl/index.html167
-rw-r--r--site/content/docs/5.2/examples/carousel/carousel.css82
-rw-r--r--site/content/docs/5.2/examples/carousel/carousel.rtl.css74
-rw-r--r--site/content/docs/5.2/examples/carousel/index.html166
-rw-r--r--site/content/docs/5.2/examples/cheatsheet-rtl/index.html1618
-rw-r--r--site/content/docs/5.2/examples/cheatsheet/cheatsheet.css164
-rw-r--r--site/content/docs/5.2/examples/cheatsheet/cheatsheet.js73
-rw-r--r--site/content/docs/5.2/examples/cheatsheet/cheatsheet.rtl.css157
-rw-r--r--site/content/docs/5.2/examples/cheatsheet/index.html1602
-rw-r--r--site/content/docs/5.2/examples/checkout-rtl/index.html232
-rw-r--r--site/content/docs/5.2/examples/checkout/form-validation.css3
-rw-r--r--site/content/docs/5.2/examples/checkout/form-validation.js19
-rw-r--r--site/content/docs/5.2/examples/checkout/index.html232
-rw-r--r--site/content/docs/5.2/examples/cover/cover.css50
-rw-r--r--site/content/docs/5.2/examples/cover/index.html34
-rw-r--r--site/content/docs/5.2/examples/dashboard-rtl/dashboard.js53
-rw-r--r--site/content/docs/5.2/examples/dashboard-rtl/index.html253
-rw-r--r--site/content/docs/5.2/examples/dashboard/dashboard.css92
-rw-r--r--site/content/docs/5.2/examples/dashboard/dashboard.js53
-rw-r--r--site/content/docs/5.2/examples/dashboard/dashboard.rtl.css88
-rw-r--r--site/content/docs/5.2/examples/dashboard/index.html252
-rw-r--r--site/content/docs/5.2/examples/dropdowns/dropdowns.css79
-rw-r--r--site/content/docs/5.2/examples/dropdowns/index.html338
-rw-r--r--site/content/docs/5.2/examples/features/features.css36
-rw-r--r--site/content/docs/5.2/examples/features/index.html347
-rw-r--r--site/content/docs/5.2/examples/features/unsplash-photo-1.jpgbin0 -> 10451 bytes
-rw-r--r--site/content/docs/5.2/examples/features/unsplash-photo-2.jpgbin0 -> 113018 bytes
-rw-r--r--site/content/docs/5.2/examples/features/unsplash-photo-3.jpgbin0 -> 40607 bytes
-rw-r--r--site/content/docs/5.2/examples/footers/index.html186
-rw-r--r--site/content/docs/5.2/examples/grid/grid.css13
-rw-r--r--site/content/docs/5.2/examples/grid/index.html188
-rw-r--r--site/content/docs/5.2/examples/headers/headers.css15
-rw-r--r--site/content/docs/5.2/examples/headers/index.html295
-rw-r--r--site/content/docs/5.2/examples/heroes/bootstrap-docs.pngbin0 -> 371399 bytes
-rw-r--r--site/content/docs/5.2/examples/heroes/bootstrap-themes.pngbin0 -> 278159 bytes
-rw-r--r--site/content/docs/5.2/examples/heroes/heroes.css3
-rw-r--r--site/content/docs/5.2/examples/heroes/index.html125
-rw-r--r--site/content/docs/5.2/examples/jumbotron/index.html45
-rw-r--r--site/content/docs/5.2/examples/list-groups/index.html222
-rw-r--r--site/content/docs/5.2/examples/list-groups/list-groups.css61
-rw-r--r--site/content/docs/5.2/examples/masonry/index.html105
-rw-r--r--site/content/docs/5.2/examples/modals/index.html173
-rw-r--r--site/content/docs/5.2/examples/modals/modals.css15
-rw-r--r--site/content/docs/5.2/examples/navbar-bottom/index.html41
-rw-r--r--site/content/docs/5.2/examples/navbar-fixed/index.html40
-rw-r--r--site/content/docs/5.2/examples/navbar-fixed/navbar-top-fixed.css5
-rw-r--r--site/content/docs/5.2/examples/navbar-static/index.html40
-rw-r--r--site/content/docs/5.2/examples/navbar-static/navbar-top.css4
-rw-r--r--site/content/docs/5.2/examples/navbars-offcanvas/index.html147
-rw-r--r--site/content/docs/5.2/examples/navbars-offcanvas/navbar.css7
-rw-r--r--site/content/docs/5.2/examples/navbars/index.html416
-rw-r--r--site/content/docs/5.2/examples/navbars/navbar.css7
-rw-r--r--site/content/docs/5.2/examples/offcanvas-navbar/index.html140
-rw-r--r--site/content/docs/5.2/examples/offcanvas-navbar/offcanvas.css52
-rw-r--r--site/content/docs/5.2/examples/offcanvas-navbar/offcanvas.js7
-rw-r--r--site/content/docs/5.2/examples/pricing/index.html187
-rw-r--r--site/content/docs/5.2/examples/pricing/pricing.css11
-rw-r--r--site/content/docs/5.2/examples/product/index.html148
-rw-r--r--site/content/docs/5.2/examples/product/product.css69
-rw-r--r--site/content/docs/5.2/examples/sidebars/index.html391
-rw-r--r--site/content/docs/5.2/examples/sidebars/sidebars.css59
-rw-r--r--site/content/docs/5.2/examples/sidebars/sidebars.js8
-rw-r--r--site/content/docs/5.2/examples/sign-in/index.html32
-rw-r--r--site/content/docs/5.2/examples/sign-in/signin.css33
-rw-r--r--site/content/docs/5.2/examples/starter-template/index.html52
-rw-r--r--site/content/docs/5.2/examples/starter-template/starter-template.css9
-rw-r--r--site/content/docs/5.2/examples/sticky-footer-navbar/index.html52
-rw-r--r--site/content/docs/5.2/examples/sticky-footer-navbar/sticky-footer-navbar.css7
-rw-r--r--site/content/docs/5.2/examples/sticky-footer/index.html24
-rw-r--r--site/content/docs/5.2/examples/sticky-footer/sticky-footer.css9
-rw-r--r--site/content/docs/5.2/extend/approach.md86
-rw-r--r--site/content/docs/5.2/extend/icons.md40
-rw-r--r--site/content/docs/5.2/forms/checks-radios.md307
-rw-r--r--site/content/docs/5.2/forms/floating-labels.md152
-rw-r--r--site/content/docs/5.2/forms/form-control.md152
-rw-r--r--site/content/docs/5.2/forms/input-group.md316
-rw-r--r--site/content/docs/5.2/forms/layout.md329
-rw-r--r--site/content/docs/5.2/forms/overview.md154
-rw-r--r--site/content/docs/5.2/forms/range.md49
-rw-r--r--site/content/docs/5.2/forms/select.md81
-rw-r--r--site/content/docs/5.2/forms/validation.md380
-rw-r--r--site/content/docs/5.2/getting-started/accessibility.md62
-rw-r--r--site/content/docs/5.2/getting-started/best-practices.md20
-rw-r--r--site/content/docs/5.2/getting-started/browsers-devices.md79
-rw-r--r--site/content/docs/5.2/getting-started/contents.md111
-rw-r--r--site/content/docs/5.2/getting-started/contribute.md67
-rw-r--r--site/content/docs/5.2/getting-started/download.md119
-rw-r--r--site/content/docs/5.2/getting-started/introduction.md162
-rw-r--r--site/content/docs/5.2/getting-started/javascript.md337
-rw-r--r--site/content/docs/5.2/getting-started/parcel.md159
-rw-r--r--site/content/docs/5.2/getting-started/rfs.md86
-rw-r--r--site/content/docs/5.2/getting-started/rtl.md182
-rw-r--r--site/content/docs/5.2/getting-started/vite.md198
-rw-r--r--site/content/docs/5.2/getting-started/webpack.md322
-rw-r--r--site/content/docs/5.2/helpers/clearfix.md36
-rw-r--r--site/content/docs/5.2/helpers/color-background.md52
-rw-r--r--site/content/docs/5.2/helpers/colored-links.md21
-rw-r--r--site/content/docs/5.2/helpers/position.md63
-rw-r--r--site/content/docs/5.2/helpers/ratio.md81
-rw-r--r--site/content/docs/5.2/helpers/stacks.md85
-rw-r--r--site/content/docs/5.2/helpers/stretched-link.md74
-rw-r--r--site/content/docs/5.2/helpers/text-truncation.md23
-rw-r--r--site/content/docs/5.2/helpers/vertical-rule.md45
-rw-r--r--site/content/docs/5.2/helpers/visually-hidden.md29
-rw-r--r--site/content/docs/5.2/layout/breakpoints.md174
-rw-r--r--site/content/docs/5.2/layout/columns.md317
-rw-r--r--site/content/docs/5.2/layout/containers.md91
-rw-r--r--site/content/docs/5.2/layout/css-grid.md267
-rw-r--r--site/content/docs/5.2/layout/grid.md530
-rw-r--r--site/content/docs/5.2/layout/gutters.md165
-rw-r--r--site/content/docs/5.2/layout/utilities.md25
-rw-r--r--site/content/docs/5.2/layout/z-index.md16
-rw-r--r--site/content/docs/5.2/migration.md504
-rw-r--r--site/content/docs/5.2/utilities/api.md616
-rw-r--r--site/content/docs/5.2/utilities/background.md124
-rw-r--r--site/content/docs/5.2/utilities/borders.md166
-rw-r--r--site/content/docs/5.2/utilities/colors.md113
-rw-r--r--site/content/docs/5.2/utilities/display.md112
-rw-r--r--site/content/docs/5.2/utilities/flex.md666
-rw-r--r--site/content/docs/5.2/utilities/float.md48
-rw-r--r--site/content/docs/5.2/utilities/interactions.md42
-rw-r--r--site/content/docs/5.2/utilities/opacity.md31
-rw-r--r--site/content/docs/5.2/utilities/overflow.md40
-rw-r--r--site/content/docs/5.2/utilities/position.md130
-rw-r--r--site/content/docs/5.2/utilities/shadows.md30
-rw-r--r--site/content/docs/5.2/utilities/sizing.md60
-rw-r--r--site/content/docs/5.2/utilities/spacing.md127
-rw-r--r--site/content/docs/5.2/utilities/text.md155
-rw-r--r--site/content/docs/5.2/utilities/vertical-align.md48
-rw-r--r--site/content/docs/5.2/utilities/visibility.md37
-rw-r--r--site/content/docs/_index.html5
-rw-r--r--site/content/docs/versions.md27
-rw-r--r--site/data/breakpoints.yml35
-rw-r--r--site/data/colors.yml26
-rw-r--r--site/data/core-team.yml32
-rw-r--r--site/data/docs-versions.yml55
-rw-r--r--site/data/examples.yml95
-rw-r--r--site/data/grays.yml18
-rw-r--r--site/data/icons.yml27
-rw-r--r--site/data/plugins.yml47
-rw-r--r--site/data/sidebar.yml153
-rw-r--r--site/data/theme-colors.yml19
-rw-r--r--site/data/translations.yml44
-rw-r--r--site/layouts/_default/404.html6
-rw-r--r--site/layouts/_default/_markup/render-heading.html5
-rw-r--r--site/layouts/_default/baseof.html21
-rw-r--r--site/layouts/_default/docs.html66
-rw-r--r--site/layouts/_default/examples.html93
-rw-r--r--site/layouts/_default/home.html8
-rw-r--r--site/layouts/_default/redirect.html1
-rw-r--r--site/layouts/_default/single.html52
-rw-r--r--site/layouts/alias.html1
-rw-r--r--site/layouts/partials/ads.html1
-rw-r--r--site/layouts/partials/analytics.html9
-rw-r--r--site/layouts/partials/callout-danger-async-methods.md5
-rw-r--r--site/layouts/partials/callout-info-mediaqueries-breakpoints.md1
-rw-r--r--site/layouts/partials/callout-info-npm-starter.md1
-rw-r--r--site/layouts/partials/callout-info-prefersreducedmotion.md1
-rw-r--r--site/layouts/partials/callout-info-sanitizer.md1
-rw-r--r--site/layouts/partials/callout-warning-color-assistive-technologies.md3
-rw-r--r--site/layouts/partials/callout-warning-data-bs-title-vs-title.md1
-rw-r--r--site/layouts/partials/callout-warning-input-support.md3
-rw-r--r--site/layouts/partials/docs-navbar.html84
-rw-r--r--site/layouts/partials/docs-sidebar.html46
-rw-r--r--site/layouts/partials/docs-versions.html46
-rw-r--r--site/layouts/partials/favicons.html8
-rw-r--r--site/layouts/partials/footer.html59
-rw-r--r--site/layouts/partials/guide-footer.md3
-rw-r--r--site/layouts/partials/header.html25
-rw-r--r--site/layouts/partials/home/masthead-followup.html356
-rw-r--r--site/layouts/partials/home/masthead.html34
-rw-r--r--site/layouts/partials/icons.html72
-rw-r--r--site/layouts/partials/icons/bootstrap-logo-solid.svg1
-rw-r--r--site/layouts/partials/icons/bootstrap-white-fill.svg1
-rw-r--r--site/layouts/partials/icons/bootstrap.svg1
-rw-r--r--site/layouts/partials/icons/circle-square.svg4
-rw-r--r--site/layouts/partials/icons/cloud-fill.svg3
-rw-r--r--site/layouts/partials/icons/code.svg3
-rw-r--r--site/layouts/partials/icons/collapse.svg4
-rw-r--r--site/layouts/partials/icons/droplet-fill.svg3
-rw-r--r--site/layouts/partials/icons/expand.svg4
-rw-r--r--site/layouts/partials/icons/github.svg1
-rw-r--r--site/layouts/partials/icons/hamburger.svg3
-rw-r--r--site/layouts/partials/icons/homepage-hero.svg1
-rw-r--r--site/layouts/partials/icons/list.svg3
-rw-r--r--site/layouts/partials/icons/menu.svg1
-rw-r--r--site/layouts/partials/icons/opencollective.svg1
-rw-r--r--site/layouts/partials/icons/twitter.svg1
-rw-r--r--site/layouts/partials/js-data-attributes.md3
-rw-r--r--site/layouts/partials/redirect.html11
-rw-r--r--site/layouts/partials/scripts.html72
-rw-r--r--site/layouts/partials/skippy.html8
-rw-r--r--site/layouts/partials/social.html15
-rw-r--r--site/layouts/partials/stylesheet.html27
-rw-r--r--site/layouts/partials/table-content.html27
-rw-r--r--site/layouts/robots.txt12
-rw-r--r--site/layouts/shortcodes/added-in.html5
-rw-r--r--site/layouts/shortcodes/bs-table.html9
-rw-r--r--site/layouts/shortcodes/callout.html9
-rw-r--r--site/layouts/shortcodes/docsref.html1
-rw-r--r--site/layouts/shortcodes/example.html47
-rw-r--r--site/layouts/shortcodes/js-dismiss.html15
-rw-r--r--site/layouts/shortcodes/markdown.html1
-rw-r--r--site/layouts/shortcodes/param.html14
-rw-r--r--site/layouts/shortcodes/partial.html1
-rw-r--r--site/layouts/shortcodes/placeholder.html33
-rw-r--r--site/layouts/shortcodes/scss-docs.html43
-rw-r--r--site/layouts/shortcodes/table.html31
-rw-r--r--site/layouts/shortcodes/year.html3
-rw-r--r--site/layouts/sitemap.xml12
-rw-r--r--site/static/CNAME1
-rw-r--r--site/static/docs/5.2/assets/brand/bootstrap-logo-black.svg1
-rw-r--r--site/static/docs/5.2/assets/brand/bootstrap-logo-shadow.pngbin0 -> 48625 bytes
-rw-r--r--site/static/docs/5.2/assets/brand/bootstrap-logo-white.svg1
-rw-r--r--site/static/docs/5.2/assets/brand/bootstrap-logo.svg1
-rw-r--r--site/static/docs/5.2/assets/brand/bootstrap-social-logo.pngbin0 -> 145590 bytes
-rw-r--r--site/static/docs/5.2/assets/brand/bootstrap-social.pngbin0 -> 724582 bytes
-rw-r--r--site/static/docs/5.2/assets/img/bootstrap-icons.pngbin0 -> 40798 bytes
-rw-r--r--site/static/docs/5.2/assets/img/bootstrap-icons@2x.pngbin0 -> 125442 bytes
-rw-r--r--site/static/docs/5.2/assets/img/bootstrap-themes-collage.pngbin0 -> 74829 bytes
-rw-r--r--site/static/docs/5.2/assets/img/bootstrap-themes-collage@2x.pngbin0 -> 244640 bytes
-rw-r--r--site/static/docs/5.2/assets/img/bootstrap-themes.pngbin0 -> 88695 bytes
-rw-r--r--site/static/docs/5.2/assets/img/bootstrap-themes@2x.pngbin0 -> 278159 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/album-rtl.pngbin0 -> 6392 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/album-rtl@2x.pngbin0 -> 15450 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/album.pngbin0 -> 10760 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/album@2x.pngbin0 -> 25026 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/blog-rtl.pngbin0 -> 12545 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/blog-rtl@2x.pngbin0 -> 31035 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/blog.pngbin0 -> 15245 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/blog@2x.pngbin0 -> 36944 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/carousel-rtl.pngbin0 -> 10344 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/carousel-rtl@2x.pngbin0 -> 24535 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/carousel.pngbin0 -> 13314 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/carousel@2x.pngbin0 -> 31465 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/cheatsheet-rtl.pngbin0 -> 6089 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/cheatsheet-rtl@2x.pngbin0 -> 13863 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/cheatsheet.pngbin0 -> 8132 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/cheatsheet@2x.pngbin0 -> 19324 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/checkout-rtl.pngbin0 -> 8848 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/checkout-rtl@2x.pngbin0 -> 21965 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/checkout.pngbin0 -> 7639 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/checkout@2x.pngbin0 -> 19105 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/cover.pngbin0 -> 7240 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/cover@2x.pngbin0 -> 17927 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/dashboard-rtl.pngbin0 -> 8261 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/dashboard-rtl@2x.pngbin0 -> 19399 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/dashboard.pngbin0 -> 11914 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/dashboard@2x.pngbin0 -> 26556 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/dropdowns.pngbin0 -> 6146 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/dropdowns@2x.pngbin0 -> 15203 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/features.pngbin0 -> 6067 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/features@2x.pngbin0 -> 15002 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/footers.pngbin0 -> 4324 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/footers@2x.pngbin0 -> 10238 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/grid.pngbin0 -> 14485 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/grid@2x.pngbin0 -> 34834 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/headers.pngbin0 -> 5197 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/headers@2x.pngbin0 -> 12639 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/heroes.pngbin0 -> 9017 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/heroes@2x.pngbin0 -> 23433 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/jumbotron.pngbin0 -> 9155 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/jumbotron@2x.pngbin0 -> 23316 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/list-groups.pngbin0 -> 7134 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/list-groups@2x.pngbin0 -> 17804 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/masonry.pngbin0 -> 15253 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/masonry@2x.pngbin0 -> 37733 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/modals.pngbin0 -> 4814 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/modals@2x.pngbin0 -> 11689 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbar-bottom.pngbin0 -> 4873 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbar-bottom@2x.pngbin0 -> 11666 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbar-fixed.pngbin0 -> 5911 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbar-fixed@2x.pngbin0 -> 14103 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbar-static.pngbin0 -> 6624 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbar-static@2x.pngbin0 -> 15155 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbars-offcanvas.pngbin0 -> 6864 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbars-offcanvas@2x.pngbin0 -> 17070 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbars.pngbin0 -> 13124 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/navbars@2x.pngbin0 -> 31168 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/offcanvas-navbar.pngbin0 -> 9691 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/offcanvas-navbar@2x.pngbin0 -> 23975 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/pricing.pngbin0 -> 11621 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/pricing@2x.pngbin0 -> 29088 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/product.pngbin0 -> 12906 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/product@2x.pngbin0 -> 27953 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sidebars.pngbin0 -> 12287 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sidebars@2x.pngbin0 -> 33499 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sign-in.pngbin0 -> 2199 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sign-in@2x.pngbin0 -> 4568 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/starter-template.pngbin0 -> 7753 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/starter-template@2x.pngbin0 -> 20134 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sticky-footer-navbar.pngbin0 -> 6979 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sticky-footer-navbar@2x.pngbin0 -> 15836 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sticky-footer.pngbin0 -> 4280 bytes
-rw-r--r--site/static/docs/5.2/assets/img/examples/sticky-footer@2x.pngbin0 -> 9665 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/android-chrome-192x192.pngbin0 -> 8364 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/android-chrome-512x512.pngbin0 -> 23832 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/apple-touch-icon.pngbin0 -> 7650 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/favicon-16x16.pngbin0 -> 525 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/favicon-32x32.pngbin0 -> 1159 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/favicon.icobin0 -> 15086 bytes
-rw-r--r--site/static/docs/5.2/assets/img/favicons/manifest.json20
-rw-r--r--site/static/docs/5.2/assets/img/favicons/safari-pinned-tab.svg1
-rw-r--r--site/static/docs/5.2/assets/img/guides/bootstrap-parcel.pngbin0 -> 161826 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/bootstrap-parcel@2x.pngbin0 -> 564766 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/bootstrap-vite.pngbin0 -> 169189 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/bootstrap-vite@2x.pngbin0 -> 558538 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/bootstrap-webpack.pngbin0 -> 169872 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/bootstrap-webpack@2x.pngbin0 -> 572482 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/parcel-dev-server-bootstrap.pngbin0 -> 102674 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/parcel-dev-server.pngbin0 -> 75744 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/vite-dev-server-bootstrap.pngbin0 -> 75894 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/vite-dev-server.pngbin0 -> 74851 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/webpack-dev-server-bootstrap.pngbin0 -> 77318 bytes
-rw-r--r--site/static/docs/5.2/assets/img/guides/webpack-dev-server.pngbin0 -> 76154 bytes
-rw-r--r--site/static/docs/5.2/assets/img/parcel.pngbin0 -> 6042 bytes
-rw-r--r--site/static/docs/5.2/assets/img/vite.svg1
-rw-r--r--site/static/docs/5.2/assets/img/webpack.svg1
-rw-r--r--site/static/docs/5.2/assets/js/validate-forms.js19
-rw-r--r--site/static/sw.js27
617 files changed, 89471 insertions, 0 deletions
diff --git a/.babelrc.js b/.babelrc.js
new file mode 100644
index 0000000..5139969
--- /dev/null
+++ b/.babelrc.js
@@ -0,0 +1,12 @@
+module.exports = {
+ presets: [
+ [
+ '@babel/preset-env',
+ {
+ loose: true,
+ bugfixes: true,
+ modules: false
+ }
+ ]
+ ]
+};
diff --git a/.browserslistrc b/.browserslistrc
new file mode 100644
index 0000000..c71c8b9
--- /dev/null
+++ b/.browserslistrc
@@ -0,0 +1,11 @@
+# https://github.com/browserslist/browserslist#readme
+
+>= 0.5%
+last 2 major versions
+not dead
+Chrome >= 60
+Firefox >= 60
+Firefox ESR
+iOS >= 12
+Safari >= 12
+not Explorer <= 11
diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json
new file mode 100644
index 0000000..143753b
--- /dev/null
+++ b/.bundlewatch.config.json
@@ -0,0 +1,66 @@
+{
+ "files": [
+ {
+ "path": "./dist/css/bootstrap-grid.css",
+ "maxSize": "7.5 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap-grid.min.css",
+ "maxSize": "6.55 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap-reboot.css",
+ "maxSize": "2.75 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap-reboot.min.css",
+ "maxSize": "2.5 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap-utilities.css",
+ "maxSize": "9.25 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap-utilities.min.css",
+ "maxSize": "8.5 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap.css",
+ "maxSize": "28.75 kB"
+ },
+ {
+ "path": "./dist/css/bootstrap.min.css",
+ "maxSize": "26.75 kB"
+ },
+ {
+ "path": "./dist/js/bootstrap.bundle.js",
+ "maxSize": "43.25 kB"
+ },
+ {
+ "path": "./dist/js/bootstrap.bundle.min.js",
+ "maxSize": "22.75 kB"
+ },
+ {
+ "path": "./dist/js/bootstrap.esm.js",
+ "maxSize": "28.0 kB"
+ },
+ {
+ "path": "./dist/js/bootstrap.esm.min.js",
+ "maxSize": "18.5 kB"
+ },
+ {
+ "path": "./dist/js/bootstrap.js",
+ "maxSize": "28.75 kB"
+ },
+ {
+ "path": "./dist/js/bootstrap.min.js",
+ "maxSize": "16.25 kB"
+ }
+ ],
+ "ci": {
+ "trackBranches": [
+ "main",
+ "v4-dev"
+ ]
+ }
+}
diff --git a/.cspell.json b/.cspell.json
new file mode 100644
index 0000000..d528823
--- /dev/null
+++ b/.cspell.json
@@ -0,0 +1,130 @@
+{
+ "version": "0.2",
+ "words": [
+ "affordance",
+ "allowfullscreen",
+ "Analyser",
+ "autohide",
+ "autohiding",
+ "autoplay",
+ "autoplays",
+ "blazingly",
+ "Blockquotes",
+ "Bootstrappers",
+ "borderless",
+ "Brotli",
+ "browserslist",
+ "browserslistrc",
+ "btncheck",
+ "btnradio",
+ "callout",
+ "callouts",
+ "camelCase",
+ "clearfix",
+ "Codesniffer",
+ "combinator",
+ "Contentful",
+ "Cpath",
+ "Crossfade",
+ "crossfading",
+ "cssgrid",
+ "Csvg",
+ "Datalists",
+ "Deque",
+ "discoverability",
+ "docsearch",
+ "docsref",
+ "dropend",
+ "dropleft",
+ "dropright",
+ "dropstart",
+ "dropup",
+ "errorf",
+ "favicon",
+ "favicons",
+ "fieldsets",
+ "flexbox",
+ "fullscreen",
+ "getbootstrap",
+ "Grayscale",
+ "Hoverable",
+ "hreflang",
+ "hstack",
+ "importmap",
+ "jsdelivr",
+ "Jumpstart",
+ "keyframes",
+ "libera",
+ "libman",
+ "Libsass",
+ "lightboxes",
+ "Lowercased",
+ "markdownify",
+ "mediaqueries",
+ "minifiers",
+ "misfunction",
+ "mkdir",
+ "monospace",
+ "mouseleave",
+ "navbars",
+ "navs",
+ "Neue",
+ "noindex",
+ "Noto",
+ "offcanvas",
+ "offcanvases",
+ "Packagist",
+ "popperjs",
+ "prebuild",
+ "prefersreducedmotion",
+ "prepended",
+ "printf",
+ "rects",
+ "relref",
+ "rgba",
+ "roboto",
+ "RTLCSS",
+ "ruleset",
+ "screenreaders",
+ "scrollbars",
+ "scrollspy",
+ "Segoe",
+ "semibold",
+ "socio",
+ "srcset",
+ "stackblitz",
+ "stickied",
+ "Stylelint",
+ "subnav",
+ "tabbable",
+ "textareas",
+ "toggleable",
+ "topbar",
+ "touchend",
+ "twbs",
+ "unitless",
+ "unstylable",
+ "unstyled",
+ "Uppercased",
+ "urlize",
+ "vbtn",
+ "viewports",
+ "Vite",
+ "vstack",
+ "walkthroughs",
+ "WCAG",
+ "zindex"
+ ],
+ "language": "en-US",
+ "files": [
+ "**/*.md"
+ ],
+ "ignorePaths": [
+ ".cspell.json",
+ "dist/",
+ "*.min.*",
+ "**/*rtl*",
+ "**/tests/**"
+ ],
+ "useGitignore": true
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..f29d257
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+# editorconfig.org
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..04bae15
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,8 @@
+**/*.min.js
+**/dist/
+**/vendor/
+/_site/
+/js/coverage/
+/js/tests/integration/
+/site/static/sw.js
+/site/layouts/
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..d8e83a8
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,65 @@
+{
+ "root": true,
+ "extends": [
+ "plugin:import/errors",
+ "plugin:import/warnings",
+ "plugin:unicorn/recommended",
+ "xo",
+ "xo/browser"
+ ],
+ "rules": {
+ "arrow-body-style": "off",
+ "capitalized-comments": "off",
+ "comma-dangle": [
+ "error",
+ "never"
+ ],
+ "indent": [
+ "error",
+ 2,
+ {
+ "MemberExpression": "off",
+ "SwitchCase": 1
+ }
+ ],
+ "max-params": [
+ "warn",
+ 5
+ ],
+ "multiline-ternary": [
+ "error",
+ "always-multiline"
+ ],
+ "new-cap": [
+ "error",
+ {
+ "properties": false
+ }
+ ],
+ "no-console": "error",
+ "no-negated-condition": "off",
+ "object-curly-spacing": [
+ "error",
+ "always"
+ ],
+ "operator-linebreak": [
+ "error",
+ "after"
+ ],
+ "semi": [
+ "error",
+ "never"
+ ],
+ "unicorn/explicit-length-check": "off",
+ "unicorn/no-array-callback-reference": "off",
+ "unicorn/no-array-method-this-argument": "off",
+ "unicorn/no-null": "off",
+ "unicorn/no-unused-properties": "error",
+ "unicorn/prefer-array-flat": "off",
+ "unicorn/prefer-dom-node-dataset": "off",
+ "unicorn/prefer-module": "off",
+ "unicorn/prefer-query-selector": "off",
+ "unicorn/prefer-spread": "off",
+ "unicorn/prevent-abbreviations": "off"
+ }
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..40b1c37
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,8 @@
+# Enforce Unix newlines
+* text=auto eol=lf
+
+# Don't diff or textually merge source maps
+*.map binary
+
+bootstrap.css linguist-vendored=false
+bootstrap.js linguist-vendored=false
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..7d3fa99
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,3 @@
+*.js @twbs/js-review
+*.css @twbs/css-review
+*.scss @twbs/css-review
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..c7211e6
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,237 @@
+# Contributing to Bootstrap
+
+Looking to contribute something to Bootstrap? **Here's how you can help.**
+
+Please take a moment to review this document in order to make the contribution
+process easy and effective for everyone involved.
+
+Following these guidelines helps to communicate that you respect the time of
+the developers managing and developing this open source project. In return,
+they should reciprocate that respect in addressing your issue or assessing
+patches and features.
+
+
+## Using the issue tracker
+
+The [issue tracker](https://github.com/twbs/bootstrap/issues) is
+the preferred channel for [bug reports](#bug-reports), [features requests](#feature-requests)
+and [submitting pull requests](#pull-requests), but please respect the following
+restrictions:
+
+* Please **do not** use the issue tracker for personal support requests. Stack Overflow ([`bootstrap-5`](https://stackoverflow.com/questions/tagged/bootstrap-5) tag), [our GitHub Discussions](https://github.com/twbs/bootstrap/discussions) or [IRC](/README.md#community) are better places to get help.
+
+* Please **do not** derail or troll issues. Keep the discussion on topic and
+ respect the opinions of others.
+
+* Please **do not** post comments consisting solely of "+1" or ":thumbsup:".
+ Use [GitHub's "reactions" feature](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/)
+ instead. We reserve the right to delete comments which violate this rule.
+
+* Please **do not** open issues regarding the official themes offered on <https://themes.getbootstrap.com/>.
+ Instead, please email any questions or feedback regarding those themes to `themes AT getbootstrap DOT com`.
+
+
+## Issues and labels
+
+Our bug tracker utilizes several labels to help organize and identify issues. Here's what they represent and how we use them:
+
+- `browser bug` - Issues that are reported to us, but actually are the result of a browser-specific bug. These are diagnosed with reduced test cases and result in an issue opened on that browser's own bug tracker.
+- `confirmed` - Issues that have been confirmed with a reduced test case and identify a bug in Bootstrap.
+- `css` - Issues stemming from our compiled CSS or source Sass files.
+- `docs` - Issues for improving or updating our documentation.
+- `examples` - Issues involving the example templates included in our docs.
+- `feature` - Issues asking for a new feature to be added, or an existing one to be extended or modified. New features require a minor version bump (e.g., `v3.0.0` to `v3.1.0`).
+- `build` - Issues with our build system, which is used to run all our tests, concatenate and compile source files, and more.
+- `help wanted` - Issues we need or would love help from the community to resolve.
+- `js` - Issues stemming from our compiled or source JavaScript files.
+- `meta` - Issues with the project itself or our GitHub repository.
+
+For a complete look at our labels, see the [project labels page](https://github.com/twbs/bootstrap/labels).
+
+
+## Bug reports
+
+A bug is a _demonstrable problem_ that is caused by the code in the repository.
+Good bug reports are extremely helpful, so thanks!
+
+Guidelines for bug reports:
+
+0. **[Validate your HTML](https://html5.validator.nu/)** to ensure your
+ problem isn't caused by a simple error in your own code.
+
+1. **Use the GitHub issue search** &mdash; check if the issue has already been
+ reported.
+
+2. **Check if the issue has been fixed** &mdash; try to reproduce it using the
+ latest `main` (or `v4-dev` branch if the issue is about v4) in the repository.
+
+3. **Isolate the problem** &mdash; ideally create a [reduced test
+ case](https://css-tricks.com/reduced-test-cases/) and a live example.
+ These [v4 CodePen](https://codepen.io/team/bootstrap/pen/yLabNQL) and [v5 CodePen](https://codepen.io/team/bootstrap/pen/qBamdLj) are helpful templates.
+
+
+A good bug report shouldn't leave others needing to chase you up for more
+information. Please try to be as detailed as possible in your report. What is
+your environment? What steps will reproduce the issue? What browser(s) and OS
+experience the problem? Do other browsers show the bug differently? What
+would you expect to be the outcome? All these details will help people to fix
+any potential bugs.
+
+Example:
+
+> Short and descriptive example bug report title
+>
+> A summary of the issue and the browser/OS environment in which it occurs. If
+> suitable, include the steps required to reproduce the bug.
+>
+> 1. This is the first step
+> 2. This is the second step
+> 3. Further steps, etc.
+>
+> `<url>` - a link to the reduced test case
+>
+> Any other information you want to share that is relevant to the issue being
+> reported. This might include the lines of code that you have identified as
+> causing the bug, and potential solutions (and your opinions on their
+> merits).
+
+### Reporting upstream browser bugs
+
+Sometimes bugs reported to us are actually caused by bugs in the browser(s) themselves, not bugs in Bootstrap per se.
+
+| Vendor(s) | Browser(s) | Rendering engine | Bug reporting website(s) | Notes |
+| ------------- | ---------------------------- | ---------------- | ------------------------------------------------------ | -------------------------------------------------------- |
+| Mozilla | Firefox | Gecko | https://bugzilla.mozilla.org/enter_bug.cgi | "Core" is normally the right product option to choose. |
+| Apple | Safari | WebKit | https://bugs.webkit.org/enter_bug.cgi?product=WebKit | In Apple's bug reporter, choose "Safari" as the product. |
+| Google, Opera | Chrome, Chromium, Opera v15+ | Blink | https://bugs.chromium.org/p/chromium/issues/list | Click the "New issue" button. |
+| Microsoft | Edge | Blink | https://developer.microsoft.com/en-us/microsoft-edge/ | Go to "Help > Send Feedback" from the browser |
+
+
+## Feature requests
+
+Feature requests are welcome. But take a moment to find out whether your idea
+fits with the scope and aims of the project. It's up to *you* to make a strong
+case to convince the project's developers of the merits of this feature. Please
+provide as much detail and context as possible.
+
+
+## Pull requests
+
+Good pull requestsβ€”patches, improvements, new featuresβ€”are a fantastic
+help. They should remain focused in scope and avoid containing unrelated
+commits.
+
+**Please ask first** before embarking on any **significant** pull request (e.g.
+implementing features, refactoring code, porting to a different language),
+otherwise you risk spending a lot of time working on something that the
+project's developers might not want to merge into the project. For trivial
+things, or things that don't require a lot of your time, you can go ahead and
+make a PR.
+
+Please adhere to the [coding guidelines](#code-guidelines) used throughout the
+project (indentation, accurate comments, etc.) and any other requirements
+(such as test coverage).
+
+**Do not edit `bootstrap.css` or `bootstrap.js`, and do not commit
+any dist files (`dist/` or `js/dist`).** Those files are automatically generated by our build tools. You should
+edit the source files in [`/bootstrap/scss/`](https://github.com/twbs/bootstrap/tree/main/scss)
+and/or [`/bootstrap/js/src/`](https://github.com/twbs/bootstrap/tree/main/js/src) instead.
+
+Similarly, when contributing to Bootstrap's documentation, you should edit the
+documentation source files in
+[the `/bootstrap/site/content/docs/` directory of the `main` branch](https://github.com/twbs/bootstrap/tree/main/site/content/docs).
+**Do not edit the `gh-pages` branch.** That branch is generated from the
+documentation source files and is managed separately by the Bootstrap Core Team.
+
+Adhering to the following process is the best way to get your work
+included in the project:
+
+1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork,
+ and configure the remotes:
+
+ ```bash
+ # Clone your fork of the repo into the current directory
+ git clone https://github.com/<your-username>/bootstrap.git
+ # Navigate to the newly cloned directory
+ cd bootstrap
+ # Assign the original repo to a remote called "upstream"
+ git remote add upstream https://github.com/twbs/bootstrap.git
+ ```
+
+2. If you cloned a while ago, get the latest changes from upstream:
+
+ ```bash
+ git checkout main
+ git pull upstream main
+ ```
+
+3. Create a new topic branch (off the main project development branch) to
+ contain your feature, change, or fix:
+
+ ```bash
+ git checkout -b <topic-branch-name>
+ ```
+
+4. Commit your changes in logical chunks. Please adhere to these [git commit
+ message guidelines](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html)
+ or your code is unlikely be merged into the main project. Use Git's
+ [interactive rebase](https://help.github.com/articles/about-git-rebase/)
+ feature to tidy up your commits before making them public.
+
+5. Locally merge (or rebase) the upstream development branch into your topic branch:
+
+ ```bash
+ git pull [--rebase] upstream main
+ ```
+
+6. Push your topic branch up to your fork:
+
+ ```bash
+ git push origin <topic-branch-name>
+ ```
+
+7. [Open a Pull Request](https://help.github.com/articles/about-pull-requests/)
+ with a clear title and description against the `main` branch.
+
+**IMPORTANT**: By submitting a patch, you agree to allow the project owners to
+license your work under the terms of the [MIT License](../LICENSE) (if it
+includes code changes) and under the terms of the
+[Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/)
+(if it includes documentation changes).
+
+
+## Code guidelines
+
+### HTML
+
+[Adhere to the Code Guide.](https://codeguide.co/#html)
+
+- Use tags and elements appropriate for an HTML5 doctype (e.g., self-closing tags).
+- Use CDNs and HTTPS for third-party JS when possible. We don't use protocol-relative URLs in this case because they break when viewing the page locally via `file://`.
+- Use [WAI-ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) attributes in documentation examples to promote accessibility.
+
+### CSS
+
+[Adhere to the Code Guide.](https://codeguide.co/#css)
+
+- When feasible, default color palettes should comply with [WCAG color contrast guidelines](https://www.w3.org/TR/WCAG20/#visual-audio-contrast).
+- Except in rare cases, don't remove default `:focus` styles (via e.g. `outline: none;`) without providing alternative styles. See [this A11Y Project post](https://www.a11yproject.com/posts/2013-01-25-never-remove-css-outlines/) for more details.
+
+### JS
+
+- No semicolons (in client-side JS)
+- 2 spaces (no tabs)
+- strict mode
+- "Attractive"
+
+### Checking coding style
+
+Run `npm run test` before committing to ensure your changes follow our coding standards.
+
+
+## License
+
+By contributing your code, you agree to license your contribution under the [MIT License](../LICENSE).
+By contributing to the documentation, you agree to license your contribution under the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/).
+
+Prior to v3.1.0, Bootstrap's code was released under the Apache License v2.0.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..3e3d6b9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,62 @@
+name: Report a bug
+description: Tell us about a bug or issue you may have identified in Bootstrap.
+title: "Provide a general summary of the issue"
+labels: [bug]
+assignees: "-"
+body:
+ - type: checkboxes
+ attributes:
+ label: Prerequisites
+ description: Take a couple minutes to help our maintainers work faster.
+ options:
+ - label: I have [searched](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue) for duplicate or closed issues
+ required: true
+ - label: I have [validated](https://html5.validator.nu/) any HTML to avoid common problems
+ required: true
+ - label: I have read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md)
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: Describe the issue
+ description: Provide a summary of the issue and what you expected to happen, including specific steps to reproduce.
+ validations:
+ required: true
+ - type: textarea
+ id: reduced-test-case
+ attributes:
+ label: Reduced test cases
+ description: Include links [reduced test case](https://css-tricks.com/reduced-test-cases/) links or suggested fixes using CodePen ([v4 template](https://codepen.io/team/bootstrap/pen/yLabNQL) or [v5 template](https://codepen.io/team/bootstrap/pen/qBamdLj)).
+ validations:
+ required: true
+ - type: dropdown
+ id: os
+ attributes:
+ label: What operating system(s) are you seeing the problem on?
+ multiple: true
+ options:
+ - Windows
+ - macOS
+ - Android
+ - iOS
+ - Linux
+ validations:
+ required: true
+ - type: dropdown
+ id: browser
+ attributes:
+ label: What browser(s) are you seeing the problem on?
+ multiple: true
+ options:
+ - Chrome
+ - Safari
+ - Firefox
+ - Microsoft Edge
+ - Opera
+ - type: input
+ id: version
+ attributes:
+ label: What version of Bootstrap are you using?
+ placeholder: "e.g., v5.1.0 or v4.5.2"
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..f152071
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,4 @@
+contact_links:
+ - name: Ask the community
+ url: https://github.com/twbs/bootstrap/discussions/new
+ about: Ask and discuss questions with other Bootstrap community members.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..4b757b1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,29 @@
+name: Feature request
+description: Suggest new or updated features to include in Bootstrap.
+title: "Suggest a new feature"
+labels: [feature]
+assignees: []
+body:
+ - type: checkboxes
+ attributes:
+ label: Prerequisites
+ description: Take a couple minutes to help our maintainers work faster.
+ options:
+ - label: I have [searched](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue) for duplicate or closed feature requests
+ required: true
+ - label: I have read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md)
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposal
+ description: Provide detailed information for what we should add, including relevant links to prior art, screenshots, or live demos whenever possible.
+ validations:
+ required: true
+ - type: textarea
+ id: motivation
+ attributes:
+ label: Motivation and context
+ description: Tell us why this change is needed or helpful, and what problems it may help solve.
+ validations:
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..4675f70
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,38 @@
+### Description
+
+<!-- Describe your changes in detail -->
+
+### Motivation & Context
+
+<!-- Why is this change required? What problem does it solve? -->
+
+### Type of changes
+
+<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Refactoring (non-breaking change)
+- [ ] Breaking change (fix or feature that would change existing functionality)
+
+### Checklist
+
+<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
+<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
+
+- [ ] I have read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md)
+- [ ] My code follows the code style of the project _(using `npm run lint`)_
+- [ ] My change introduces changes to the documentation
+- [ ] I have updated the documentation accordingly
+- [ ] I have added tests to cover my changes
+- [ ] All new and existing tests passed
+
+#### Live previews
+
+<!-- Please add direct links where your modifications can be seen in the documentation -->
+
+* https://deploy-preview-{your pr number}--twbs-bootstrap.netlify.app/
+
+### Related issues
+
+<!-- Please link any related issues here. -->
diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md
new file mode 100644
index 0000000..26b3be4
--- /dev/null
+++ b/.github/SUPPORT.md
@@ -0,0 +1,11 @@
+### Bug reports
+
+See the [contributing guidelines](CONTRIBUTING.md) for sharing bug reports.
+
+### How-to
+
+For general troubleshooting or help getting started:
+
+- Ask and explore [our GitHub Discussions](https://github.com/twbs/bootstrap/discussions).
+- Chat with fellow Bootstrappers in IRC. On the `irc.libera.chat` server, in the `#bootstrap` channel.
+- Ask and explore Stack Overflow with the [`bootstrap-5`](https://stackoverflow.com/questions/tagged/bootstrap-5) tag.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..29135b4
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,24 @@
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: tuesday
+ time: "12:00"
+ timezone: Europe/Athens
+ open-pull-requests-limit: 10
+ reviewers:
+ - XhmikosR
+ labels:
+ - dependencies
+ - v5
+ versioning-strategy: increase
+ rebase-strategy: disabled
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: tuesday
+ time: "12:00"
+ timezone: Europe/Athens
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
new file mode 100644
index 0000000..0289984
--- /dev/null
+++ b/.github/release-drafter.yml
@@ -0,0 +1,60 @@
+name-template: 'v$NEXT_MAJOR_VERSION'
+tag-template: 'v$NEXT_MAJOR_VERSION'
+prerelease: true
+exclude-labels:
+ - 'skip-changelog'
+categories:
+ - title: '❗ Breaking Changes'
+ labels:
+ - 'breaking-change'
+ - title: 'πŸš€ Highlights'
+ labels:
+ - 'release-highlight'
+ - title: 'πŸš€ Features'
+ labels:
+ - 'new-feature'
+ - 'feature'
+ - 'enhancement'
+ - title: 'πŸ› Bug fixes'
+ labels:
+ - 'fix'
+ - 'bugfix'
+ - 'bug'
+ - title: '⚑ Performance improvements'
+ labels:
+ - 'performance'
+ - title: '🎨 CSS'
+ labels:
+ - 'css'
+ - title: 'β˜•οΈ JavaScript'
+ labels:
+ - 'js'
+ - title: 'πŸ“– Docs'
+ labels:
+ - 'docs'
+ - title: 'πŸ›  Examples'
+ labels:
+ - 'examples'
+ - title: '🌎 Accessibility'
+ labels:
+ - 'accessibility'
+ - title: 'πŸ”§ Utility API'
+ labels:
+ - 'utility API'
+ - 'utilities'
+ - title: '🏭 Tests'
+ labels:
+ - 'tests'
+ - title: '🧰 Misc'
+ labels:
+ - 'build'
+ - 'meta'
+ - 'chore'
+ - 'CI'
+ - title: 'πŸ“¦ Dependencies'
+ labels:
+ - 'dependencies'
+change-template: '- #$NUMBER: $TITLE'
+template: |
+ ## Changes
+ $CHANGES
diff --git a/.github/workflows/browserstack.yml b/.github/workflows/browserstack.yml
new file mode 100644
index 0000000..425c566
--- /dev/null
+++ b/.github/workflows/browserstack.yml
@@ -0,0 +1,38 @@
+name: BrowserStack
+
+on:
+ push:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ browserstack:
+ runs-on: ubuntu-latest
+ if: github.repository == 'twbs/bootstrap' && (!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]'))
+ timeout-minutes: 30
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "${{ env.NODE }}"
+ cache: npm
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Run dist
+ run: npm run dist
+
+ - name: Run BrowserStack tests
+ run: npm run js-test-cloud
+ env:
+ BROWSER_STACK_ACCESS_KEY: "${{ secrets.BROWSER_STACK_ACCESS_KEY }}"
+ BROWSER_STACK_USERNAME: "${{ secrets.BROWSER_STACK_USERNAME }}"
+ GITHUB_SHA: "${{ github.sha }}"
diff --git a/.github/workflows/bundlewatch.yml b/.github/workflows/bundlewatch.yml
new file mode 100644
index 0000000..d1a1747
--- /dev/null
+++ b/.github/workflows/bundlewatch.yml
@@ -0,0 +1,38 @@
+name: Bundlewatch
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ bundlewatch:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "${{ env.NODE }}"
+ cache: npm
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Run dist
+ run: npm run dist
+
+ - name: Run bundlewatch
+ run: npm run bundlewatch
+ env:
+ BUNDLEWATCH_GITHUB_TOKEN: "${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}"
+ CI_BRANCH_BASE: main
diff --git a/.github/workflows/calibreapp-image-actions.yml b/.github/workflows/calibreapp-image-actions.yml
new file mode 100644
index 0000000..e23f562
--- /dev/null
+++ b/.github/workflows/calibreapp-image-actions.yml
@@ -0,0 +1,24 @@
+name: Compress Images
+
+on:
+ pull_request:
+ paths:
+ - '**.jpg'
+ - '**.jpeg'
+ - '**.png'
+ - '**.webp'
+
+jobs:
+ build:
+ # Only run on Pull Requests within the same repository, and not from forks.
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ name: calibreapp/image-actions
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Repo
+ uses: actions/checkout@v3
+
+ - name: Compress Images
+ uses: calibreapp/image-actions@1.1.0
+ with:
+ githubToken: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..70be056
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,38 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches:
+ - main
+ - v4-dev
+ - "!dependabot/**"
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches:
+ - main
+ - v4-dev
+ - "!dependabot/**"
+ schedule:
+ - cron: "0 2 * * 5"
+ workflow_dispatch:
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v2
+ with:
+ languages: "javascript"
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v2
diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml
new file mode 100644
index 0000000..3751ad3
--- /dev/null
+++ b/.github/workflows/cspell.yml
@@ -0,0 +1,28 @@
+name: cspell
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ cspell:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Run cspell
+ uses: streetsidesoftware/cspell-action@v2
+ with:
+ config: ".cspell.json"
+ files: "**/*.md"
+ inline: error
+ incremental_files_only: false
diff --git a/.github/workflows/css.yml b/.github/workflows/css.yml
new file mode 100644
index 0000000..857a567
--- /dev/null
+++ b/.github/workflows/css.yml
@@ -0,0 +1,32 @@
+name: CSS
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ css:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "${{ env.NODE }}"
+ cache: npm
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Build CSS
+ run: npm run css
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..f33413e
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,45 @@
+name: Docs
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "${{ env.NODE }}"
+ cache: npm
+
+ - run: java -version
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Build docs
+ run: npm run docs-build
+
+ - name: Validate HTML
+ run: npm run docs-vnu
+
+ - name: Run linkinator
+ uses: JustinBeckwith/linkinator-action@v1
+ with:
+ paths: _site
+ recurse: true
+ verbosity: error
+ skip: "^(?!http://localhost)"
diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml
new file mode 100644
index 0000000..b251cd7
--- /dev/null
+++ b/.github/workflows/issue-close-require.yml
@@ -0,0 +1,19 @@
+name: Close Issue Awaiting Reply
+
+on:
+ schedule:
+ - cron: "0 0 * * *"
+
+jobs:
+ issue-close-require:
+ runs-on: ubuntu-latest
+ if: github.repository == 'twbs/bootstrap'
+ steps:
+ - name: awaiting reply
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: "close-issues"
+ labels: "awaiting-reply"
+ inactive-day: 14
+ body: |
+ As the issue was labeled with `awaiting-reply`, but there has been no response in 14 days, this issue will be closed. If you have any questions, you can comment/reply.
diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml
new file mode 100644
index 0000000..fac5849
--- /dev/null
+++ b/.github/workflows/issue-labeled.yml
@@ -0,0 +1,19 @@
+name: Issue Labeled
+
+on:
+ issues:
+ types: [labeled]
+
+jobs:
+ issue-labeled:
+ if: github.repository == 'twbs/bootstrap'
+ runs-on: ubuntu-latest
+ steps:
+ - name: awaiting reply
+ if: github.event.label.name == 'needs-example'
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: "create-comment"
+ token: ${{ secrets.GITHUB_TOKEN }}
+ body: |
+ Hello @${{ github.event.issue.user.login }}. Bug reports must include a **live demo** of the issue. Per our [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md), please create a reduced test case on [CodePen](https://codepen.io/) or [StackBlitz](https://stackblitz.com/) and report back with your link, Bootstrap version, and specific browser and Operating System details.
diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml
new file mode 100644
index 0000000..82616c5
--- /dev/null
+++ b/.github/workflows/js.yml
@@ -0,0 +1,42 @@
+name: JS Tests
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ run:
+ name: JS Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ env.NODE }}
+ cache: npm
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Run dist
+ run: npm run js
+
+ - name: Run JS tests
+ run: npm run js-test
+
+ - name: Run Coveralls
+ uses: coverallsapp/github-action@1.1.3
+ with:
+ github-token: "${{ secrets.GITHUB_TOKEN }}"
+ path-to-lcov: "./js/coverage/lcov.info"
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..816694e
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,32 @@
+name: Lint
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "${{ env.NODE }}"
+ cache: npm
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint
diff --git a/.github/workflows/node-sass.yml b/.github/workflows/node-sass.yml
new file mode 100644
index 0000000..465cee4
--- /dev/null
+++ b/.github/workflows/node-sass.yml
@@ -0,0 +1,31 @@
+name: CSS (node-sass)
+
+on:
+ push:
+ branches-ignore:
+ - "dependabot/**"
+ pull_request:
+ workflow_dispatch:
+
+env:
+ FORCE_COLOR: 2
+ NODE: 16
+
+jobs:
+ css:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Clone repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "${{ env.NODE }}"
+
+ - name: Build CSS with node-sass
+ run: |
+ npx --package node-sass@latest node-sass --version
+ npx --package node-sass@latest node-sass --output-style expanded --source-map true --source-map-contents true --precision 6 scss/ -o dist-sass/css/
+ ls -Al dist-sass/css
diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml
new file mode 100644
index 0000000..bbd0a24
--- /dev/null
+++ b/.github/workflows/release-notes.yml
@@ -0,0 +1,16 @@
+name: Release notes
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ update_release_draft:
+ runs-on: ubuntu-latest
+ if: github.repository == 'twbs/bootstrap'
+ steps:
+ - uses: release-drafter/release-drafter@v5
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2215d63
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,42 @@
+# Ignore docs files
+/_site/
+# Hugo files
+/resources/
+/.hugo_build.lock
+
+# Numerous always-ignore extensions
+*.diff
+*.err
+*.log
+*.orig
+*.rej
+*.swo
+*.swp
+*.vi
+*.zip
+*~
+
+# OS or Editor folders
+._*
+.cache
+.DS_Store
+.idea
+.project
+.settings
+.tmproj
+*.esproj
+*.sublime-project
+*.sublime-workspace
+nbproject
+Thumbs.db
+/.vscode/
+# Local Netlify folder
+.netlify
+
+# Komodo
+.komodotools
+*.komodoproject
+
+# Folders to ignore
+/js/coverage/
+/node_modules/
diff --git a/.stylelintignore b/.stylelintignore
new file mode 100644
index 0000000..0759a69
--- /dev/null
+++ b/.stylelintignore
@@ -0,0 +1,5 @@
+**/*.min.css
+**/dist/
+**/vendor/
+/_site/
+/js/coverage/
diff --git a/.stylelintrc b/.stylelintrc
new file mode 100644
index 0000000..94c8ec1
--- /dev/null
+++ b/.stylelintrc
@@ -0,0 +1,31 @@
+{
+ "extends": [
+ "stylelint-config-twbs-bootstrap"
+ ],
+ "rules": {
+ "declaration-property-value-disallowed-list": {
+ "border": "none",
+ "outline": "none"
+ },
+ "function-disallowed-list": [
+ "calc",
+ "lighten",
+ "darken"
+ ],
+ "property-disallowed-list": [
+ "border-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-bottom-right-radius",
+ "border-bottom-left-radius",
+ "transition"
+ ],
+ "scss/dollar-variable-default": [
+ true,
+ {
+ "ignore": "local"
+ }
+ ],
+ "scss/selector-no-union-class-name": true
+ }
+}
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..28fd5e8
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,132 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+ community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of
+ any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address,
+ without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+mdo@getbootstrap.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..dda75ca
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2011-2022 Twitter, Inc.
+Copyright (c) 2011-2022 The Bootstrap Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bd40192
--- /dev/null
+++ b/README.md
@@ -0,0 +1,246 @@
+<p align="center">
+ <a href="https://getbootstrap.com/">
+ <img src="https://getbootstrap.com/docs/5.2/assets/brand/bootstrap-logo-shadow.png" alt="Bootstrap logo" width="200" height="165">
+ </a>
+</p>
+
+<h3 align="center">Bootstrap</h3>
+
+<p align="center">
+ Sleek, intuitive, and powerful front-end framework for faster and easier web development.
+ <br>
+ <a href="https://getbootstrap.com/docs/5.2/"><strong>Explore Bootstrap docs Β»</strong></a>
+ <br>
+ <br>
+ <a href="https://github.com/twbs/bootstrap/issues/new?assignees=-&labels=bug&template=bug_report.yml">Report bug</a>
+ Β·
+ <a href="https://github.com/twbs/bootstrap/issues/new?assignees=&labels=feature&template=feature_request.yml">Request feature</a>
+ Β·
+ <a href="https://themes.getbootstrap.com/">Themes</a>
+ Β·
+ <a href="https://blog.getbootstrap.com/">Blog</a>
+</p>
+
+
+## Bootstrap 5
+
+Our default branch is for development of our Bootstrap 5 release. Head to the [`v4-dev` branch](https://github.com/twbs/bootstrap/tree/v4-dev) to view the readme, documentation, and source code for Bootstrap 4.
+
+
+## Table of contents
+
+- [Quick start](#quick-start)
+- [Status](#status)
+- [What's included](#whats-included)
+- [Bugs and feature requests](#bugs-and-feature-requests)
+- [Documentation](#documentation)
+- [Contributing](#contributing)
+- [Community](#community)
+- [Versioning](#versioning)
+- [Creators](#creators)
+- [Thanks](#thanks)
+- [Copyright and license](#copyright-and-license)
+
+
+## Quick start
+
+Several quick start options are available:
+
+- [Download the latest release](https://github.com/twbs/bootstrap/archive/v5.2.3.zip)
+- Clone the repo: `git clone https://github.com/twbs/bootstrap.git`
+- Install with [npm](https://www.npmjs.com/): `npm install bootstrap@v5.2.3`
+- Install with [yarn](https://yarnpkg.com/): `yarn add bootstrap@v5.2.3`
+- Install with [Composer](https://getcomposer.org/): `composer require twbs/bootstrap:5.2.3`
+- Install with [NuGet](https://www.nuget.org/): CSS: `Install-Package bootstrap` Sass: `Install-Package bootstrap.sass`
+
+Read the [Getting started page](https://getbootstrap.com/docs/5.2/getting-started/introduction/) for information on the framework contents, templates, examples, and more.
+
+
+## Status
+
+[![Build Status](https://img.shields.io/github/workflow/status/twbs/bootstrap/JS%20Tests/main?label=JS%20Tests&logo=github)](https://github.com/twbs/bootstrap/actions?query=workflow%3AJS+Tests+branch%3Amain)
+[![npm version](https://img.shields.io/npm/v/bootstrap)](https://www.npmjs.com/package/bootstrap)
+[![Gem version](https://img.shields.io/gem/v/bootstrap)](https://rubygems.org/gems/bootstrap)
+[![Meteor Atmosphere](https://img.shields.io/badge/meteor-twbs%3Abootstrap-blue)](https://atmospherejs.com/twbs/bootstrap)
+[![Packagist Prerelease](https://img.shields.io/packagist/vpre/twbs/bootstrap)](https://packagist.org/packages/twbs/bootstrap)
+[![NuGet](https://img.shields.io/nuget/vpre/bootstrap)](https://www.nuget.org/packages/bootstrap/absoluteLatest)
+[![Coverage Status](https://img.shields.io/coveralls/github/twbs/bootstrap/main)](https://coveralls.io/github/twbs/bootstrap?branch=main)
+[![CSS gzip size](https://img.badgesize.io/twbs/bootstrap/main/dist/css/bootstrap.min.css?compression=gzip&label=CSS%20gzip%20size)](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css)
+[![CSS Brotli size](https://img.badgesize.io/twbs/bootstrap/main/dist/css/bootstrap.min.css?compression=brotli&label=CSS%20Brotli%20size)](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css)
+[![JS gzip size](https://img.badgesize.io/twbs/bootstrap/main/dist/js/bootstrap.min.js?compression=gzip&label=JS%20gzip%20size)](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js)
+[![JS Brotli size](https://img.badgesize.io/twbs/bootstrap/main/dist/js/bootstrap.min.js?compression=brotli&label=JS%20Brotli%20size)](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js)
+[![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=SkxZcStBeExEdVJqQ2hWYnlWckpkNmNEY213SFp6WHFETWk2bGFuY3pCbz0tLXhqbHJsVlZhQnRBdEpod3NLSDMzaHc9PQ==--3d0b75245708616eb93113221beece33e680b229)](https://www.browserstack.com/automate/public-build/SkxZcStBeExEdVJqQ2hWYnlWckpkNmNEY213SFp6WHFETWk2bGFuY3pCbz0tLXhqbHJsVlZhQnRBdEpod3NLSDMzaHc9PQ==--3d0b75245708616eb93113221beece33e680b229)
+[![Backers on Open Collective](https://img.shields.io/opencollective/backers/bootstrap)](#backers)
+[![Sponsors on Open Collective](https://img.shields.io/opencollective/sponsors/bootstrap)](#sponsors)
+
+
+## What's included
+
+Within the download you'll find the following directories and files, logically grouping common assets and providing both compiled and minified variations.
+
+<details>
+ <summary>Download contents</summary>
+
+ ```text
+ bootstrap/
+ β”œβ”€β”€ css/
+ β”‚ β”œβ”€β”€ bootstrap-grid.css
+ β”‚ β”œβ”€β”€ bootstrap-grid.css.map
+ β”‚ β”œβ”€β”€ bootstrap-grid.min.css
+ β”‚ β”œβ”€β”€ bootstrap-grid.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap-grid.rtl.css
+ β”‚ β”œβ”€β”€ bootstrap-grid.rtl.css.map
+ β”‚ β”œβ”€β”€ bootstrap-grid.rtl.min.css
+ β”‚ β”œβ”€β”€ bootstrap-grid.rtl.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap-reboot.css
+ β”‚ β”œβ”€β”€ bootstrap-reboot.css.map
+ β”‚ β”œβ”€β”€ bootstrap-reboot.min.css
+ β”‚ β”œβ”€β”€ bootstrap-reboot.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap-reboot.rtl.css
+ β”‚ β”œβ”€β”€ bootstrap-reboot.rtl.css.map
+ β”‚ β”œβ”€β”€ bootstrap-reboot.rtl.min.css
+ β”‚ β”œβ”€β”€ bootstrap-reboot.rtl.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap-utilities.css
+ β”‚ β”œβ”€β”€ bootstrap-utilities.css.map
+ β”‚ β”œβ”€β”€ bootstrap-utilities.min.css
+ β”‚ β”œβ”€β”€ bootstrap-utilities.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap-utilities.rtl.css
+ β”‚ β”œβ”€β”€ bootstrap-utilities.rtl.css.map
+ β”‚ β”œβ”€β”€ bootstrap-utilities.rtl.min.css
+ β”‚ β”œβ”€β”€ bootstrap-utilities.rtl.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap.css
+ β”‚ β”œβ”€β”€ bootstrap.css.map
+ β”‚ β”œβ”€β”€ bootstrap.min.css
+ β”‚ β”œβ”€β”€ bootstrap.min.css.map
+ β”‚ β”œβ”€β”€ bootstrap.rtl.css
+ β”‚ β”œβ”€β”€ bootstrap.rtl.css.map
+ β”‚ β”œβ”€β”€ bootstrap.rtl.min.css
+ β”‚ └── bootstrap.rtl.min.css.map
+ └── js/
+ β”œβ”€β”€ bootstrap.bundle.js
+ β”œβ”€β”€ bootstrap.bundle.js.map
+ β”œβ”€β”€ bootstrap.bundle.min.js
+ β”œβ”€β”€ bootstrap.bundle.min.js.map
+ β”œβ”€β”€ bootstrap.esm.js
+ β”œβ”€β”€ bootstrap.esm.js.map
+ β”œβ”€β”€ bootstrap.esm.min.js
+ β”œβ”€β”€ bootstrap.esm.min.js.map
+ β”œβ”€β”€ bootstrap.js
+ β”œβ”€β”€ bootstrap.js.map
+ β”œβ”€β”€ bootstrap.min.js
+ └── bootstrap.min.js.map
+ ```
+</details>
+
+We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified CSS and JS (`bootstrap.min.*`). [Source maps](https://developers.google.com/web/tools/chrome-devtools/javascript/source-maps) (`bootstrap.*.map`) are available for use with certain browsers' developer tools. Bundled JS files (`bootstrap.bundle.js` and minified `bootstrap.bundle.min.js`) include [Popper](https://popper.js.org/).
+
+
+## Bugs and feature requests
+
+Have a bug or a feature request? Please first read the [issue guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md#using-the-issue-tracker) and search for existing and closed issues. If your problem or idea is not addressed yet, [please open a new issue](https://github.com/twbs/bootstrap/issues/new/choose).
+
+
+## Documentation
+
+Bootstrap's documentation, included in this repo in the root directory, is built with [Hugo](https://gohugo.io/) and publicly hosted on GitHub Pages at <https://getbootstrap.com/>. The docs may also be run locally.
+
+Documentation search is powered by [Algolia's DocSearch](https://docsearch.algolia.com/). Working on our search? Be sure to set `debug: true` in `site/assets/js/search.js`.
+
+### Running documentation locally
+
+1. Run `npm install` to install the Node.js dependencies, including Hugo (the site builder).
+2. Run `npm run test` (or a specific npm script) to rebuild distributed CSS and JavaScript files, as well as our docs assets.
+3. From the root `/bootstrap` directory, run `npm run docs-serve` in the command line.
+4. Open `http://localhost:9001/` in your browser, and voilΓ .
+
+Learn more about using Hugo by reading its [documentation](https://gohugo.io/documentation/).
+
+### Documentation for previous releases
+
+You can find all our previous releases docs on <https://getbootstrap.com/docs/versions/>.
+
+[Previous releases](https://github.com/twbs/bootstrap/releases) and their documentation are also available for download.
+
+
+## Contributing
+
+Please read through our [contributing guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md). Included are directions for opening issues, coding standards, and notes on development.
+
+Moreover, if your pull request contains JavaScript patches or features, you must include [relevant unit tests](https://github.com/twbs/bootstrap/tree/main/js/tests). All HTML and CSS should conform to the [Code Guide](https://github.com/mdo/code-guide), maintained by [Mark Otto](https://github.com/mdo).
+
+Editor preferences are available in the [editor config](https://github.com/twbs/bootstrap/blob/main/.editorconfig) for easy use in common text editors. Read more and download plugins at <https://editorconfig.org/>.
+
+
+## Community
+
+Get updates on Bootstrap's development and chat with the project maintainers and community members.
+
+- Follow [@getbootstrap on Twitter](https://twitter.com/getbootstrap).
+- Read and subscribe to [The Official Bootstrap Blog](https://blog.getbootstrap.com/).
+- Ask and explore [our GitHub Discussions](https://github.com/twbs/bootstrap/discussions).
+- Chat with fellow Bootstrappers in IRC. On the `irc.libera.chat` server, in the `#bootstrap` channel.
+- Implementation help may be found at Stack Overflow (tagged [`bootstrap-5`](https://stackoverflow.com/questions/tagged/bootstrap-5)).
+- Developers should use the keyword `bootstrap` on packages which modify or add to the functionality of Bootstrap when distributing through [npm](https://www.npmjs.com/browse/keyword/bootstrap) or similar delivery mechanisms for maximum discoverability.
+
+
+## Versioning
+
+For transparency into our release cycle and in striving to maintain backward compatibility, Bootstrap is maintained under [the Semantic Versioning guidelines](https://semver.org/). Sometimes we screw up, but we adhere to those rules whenever possible.
+
+See [the Releases section of our GitHub project](https://github.com/twbs/bootstrap/releases) for changelogs for each release version of Bootstrap. Release announcement posts on [the official Bootstrap blog](https://blog.getbootstrap.com/) contain summaries of the most noteworthy changes made in each release.
+
+
+## Creators
+
+**Mark Otto**
+
+- <https://twitter.com/mdo>
+- <https://github.com/mdo>
+
+**Jacob Thornton**
+
+- <https://twitter.com/fat>
+- <https://github.com/fat>
+
+
+## Thanks
+
+<a href="https://www.browserstack.com/">
+ <img src="https://live.browserstack.com/images/opensource/browserstack-logo.svg" alt="BrowserStack" width="192" height="42">
+</a>
+
+Thanks to [BrowserStack](https://www.browserstack.com/) for providing the infrastructure that allows us to test in real browsers!
+
+<a href="https://www.netlify.com/">
+ <img src="https://www.netlify.com/v3/img/components/full-logo-light.svg" alt="Netlify" width="147" height="40">
+</a>
+
+Thanks to [Netlify](https://www.netlify.com/) for providing us with Deploy Previews!
+
+
+## Sponsors
+
+Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/bootstrap#sponsor)]
+
+[![OC sponsor 0](https://opencollective.com/bootstrap/sponsor/0/avatar.svg)](https://opencollective.com/bootstrap/sponsor/0/website)
+[![OC sponsor 1](https://opencollective.com/bootstrap/sponsor/1/avatar.svg)](https://opencollective.com/bootstrap/sponsor/1/website)
+[![OC sponsor 2](https://opencollective.com/bootstrap/sponsor/2/avatar.svg)](https://opencollective.com/bootstrap/sponsor/2/website)
+[![OC sponsor 3](https://opencollective.com/bootstrap/sponsor/3/avatar.svg)](https://opencollective.com/bootstrap/sponsor/3/website)
+[![OC sponsor 4](https://opencollective.com/bootstrap/sponsor/4/avatar.svg)](https://opencollective.com/bootstrap/sponsor/4/website)
+[![OC sponsor 5](https://opencollective.com/bootstrap/sponsor/5/avatar.svg)](https://opencollective.com/bootstrap/sponsor/5/website)
+[![OC sponsor 6](https://opencollective.com/bootstrap/sponsor/6/avatar.svg)](https://opencollective.com/bootstrap/sponsor/6/website)
+[![OC sponsor 7](https://opencollective.com/bootstrap/sponsor/7/avatar.svg)](https://opencollective.com/bootstrap/sponsor/7/website)
+[![OC sponsor 8](https://opencollective.com/bootstrap/sponsor/8/avatar.svg)](https://opencollective.com/bootstrap/sponsor/8/website)
+[![OC sponsor 9](https://opencollective.com/bootstrap/sponsor/9/avatar.svg)](https://opencollective.com/bootstrap/sponsor/9/website)
+
+
+## Backers
+
+Thank you to all our backers! πŸ™ [[Become a backer](https://opencollective.com/bootstrap#backer)]
+
+[![Backers](https://opencollective.com/bootstrap/backers.svg?width=890)](https://opencollective.com/bootstrap#backers)
+
+
+## Copyright and license
+
+Code and documentation copyright 2011–2022 the [Bootstrap Authors](https://github.com/twbs/bootstrap/graphs/contributors) and [Twitter, Inc.](https://twitter.com) Code released under the [MIT License](https://github.com/twbs/bootstrap/blob/main/LICENSE). Docs released under [Creative Commons](https://creativecommons.org/licenses/by/3.0/).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..e79dcd8
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,7 @@
+# Reporting Security Issues
+
+The Bootstrap team and community take security issues in Bootstrap seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
+
+To report a security issue, email [security@getbootstrap.com](mailto:security@getbootstrap.com) and include the word "SECURITY" in the subject line.
+
+We'll endeavor to respond quickly, and will keep you updated throughout the process.
diff --git a/build/.eslintrc.json b/build/.eslintrc.json
new file mode 100644
index 0000000..dec6323
--- /dev/null
+++ b/build/.eslintrc.json
@@ -0,0 +1,15 @@
+{
+ "env": {
+ "browser": false,
+ "node": true
+ },
+ "parserOptions": {
+ "sourceType": "script"
+ },
+ "extends": "../.eslintrc.json",
+ "rules": {
+ "no-console": "off",
+ "strict": "error",
+ "unicorn/prefer-top-level-await": "off"
+ }
+}
diff --git a/build/banner.js b/build/banner.js
new file mode 100644
index 0000000..df82ff3
--- /dev/null
+++ b/build/banner.js
@@ -0,0 +1,14 @@
+'use strict'
+
+const pkg = require('../package.json')
+const year = new Date().getFullYear()
+
+function getBanner(pluginFilename) {
+ return `/*!
+ * Bootstrap${pluginFilename ? ` ${pluginFilename}` : ''} v${pkg.version} (${pkg.homepage})
+ * Copyright 2011-${year} ${pkg.author}
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */`
+}
+
+module.exports = getBanner
diff --git a/build/build-plugins.js b/build/build-plugins.js
new file mode 100644
index 0000000..a160209
--- /dev/null
+++ b/build/build-plugins.js
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+
+/*!
+ * Script to build our plugins to use them separately.
+ * Copyright 2020-2022 The Bootstrap Authors
+ * Copyright 2020-2022 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */
+
+'use strict'
+
+const path = require('node:path')
+const rollup = require('rollup')
+const globby = require('globby')
+const { babel } = require('@rollup/plugin-babel')
+const banner = require('./banner.js')
+
+const sourcePath = path.resolve(__dirname, '../js/src/').replace(/\\/g, '/')
+const jsFiles = globby.sync(sourcePath + '/**/*.js')
+
+// Array which holds the resolved plugins
+const resolvedPlugins = []
+
+// Trims the "js" extension and uppercases => first letter, hyphens, backslashes & slashes
+const filenameToEntity = filename => filename.replace('.js', '')
+ .replace(/(?:^|-|\/|\\)[a-z]/g, str => str.slice(-1).toUpperCase())
+
+for (const file of jsFiles) {
+ resolvedPlugins.push({
+ src: file.replace('.js', ''),
+ dist: file.replace('src', 'dist'),
+ fileName: path.basename(file),
+ className: filenameToEntity(path.basename(file))
+ // safeClassName: filenameToEntity(path.relative(sourcePath, file))
+ })
+}
+
+const build = async plugin => {
+ const globals = {}
+
+ const bundle = await rollup.rollup({
+ input: plugin.src,
+ plugins: [
+ babel({
+ // Only transpile our source code
+ exclude: 'node_modules/**',
+ // Include the helpers in each file, at most one copy of each
+ babelHelpers: 'bundled'
+ })
+ ],
+ external(source) {
+ // Pattern to identify local files
+ const pattern = /^(\.{1,2})\//
+
+ // It's not a local file, e.g a Node.js package
+ if (!pattern.test(source)) {
+ globals[source] = source
+ return true
+ }
+
+ const usedPlugin = resolvedPlugins.find(plugin => {
+ return plugin.src.includes(source.replace(pattern, ''))
+ })
+
+ if (!usedPlugin) {
+ throw new Error(`Source ${source} is not mapped!`)
+ }
+
+ // We can change `Index` with `UtilIndex` etc if we use
+ // `safeClassName` instead of `className` everywhere
+ globals[path.normalize(usedPlugin.src)] = usedPlugin.className
+ return true
+ }
+ })
+
+ await bundle.write({
+ banner: banner(plugin.fileName),
+ format: 'umd',
+ name: plugin.className,
+ sourcemap: true,
+ globals,
+ generatedCode: 'es2015',
+ file: plugin.dist
+ })
+
+ console.log(`Built ${plugin.className}`)
+}
+
+(async () => {
+ try {
+ const basename = path.basename(__filename)
+ const timeLabel = `[${basename}] finished`
+
+ console.log('Building individual plugins...')
+ console.time(timeLabel)
+
+ await Promise.all(Object.values(resolvedPlugins).map(plugin => build(plugin)))
+
+ console.timeEnd(timeLabel)
+ } catch (error) {
+ console.error(error)
+ process.exit(1)
+ }
+})()
diff --git a/build/change-version.js b/build/change-version.js
new file mode 100644
index 0000000..57c5fde
--- /dev/null
+++ b/build/change-version.js
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+
+/*!
+ * Script to update version number references in the project.
+ * Copyright 2017-2022 The Bootstrap Authors
+ * Copyright 2017-2022 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */
+
+'use strict'
+
+const fs = require('node:fs').promises
+const path = require('node:path')
+const globby = require('globby')
+
+const VERBOSE = process.argv.includes('--verbose')
+const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run')
+
+// These are the filetypes we only care about replacing the version
+const GLOB = [
+ '**/*.{css,html,js,json,md,scss,txt,yml}'
+]
+const GLOBBY_OPTIONS = {
+ cwd: path.join(__dirname, '..'),
+ gitignore: true
+}
+
+// Blame TC39... https://github.com/benjamingr/RegExp.escape/issues/37
+function regExpQuote(string) {
+ return string.replace(/[$()*+-.?[\\\]^{|}]/g, '\\$&')
+}
+
+function regExpQuoteReplacement(string) {
+ return string.replace(/\$/g, '$$')
+}
+
+async function replaceRecursively(file, oldVersion, newVersion) {
+ const originalString = await fs.readFile(file, 'utf8')
+ const newString = originalString.replace(
+ new RegExp(regExpQuote(oldVersion), 'g'), regExpQuoteReplacement(newVersion)
+ )
+
+ // No need to move any further if the strings are identical
+ if (originalString === newString) {
+ return
+ }
+
+ if (VERBOSE) {
+ console.log(`FILE: ${file}`)
+ }
+
+ if (DRY_RUN) {
+ return
+ }
+
+ await fs.writeFile(file, newString, 'utf8')
+}
+
+async function main(args) {
+ let [oldVersion, newVersion] = args
+
+ if (!oldVersion || !newVersion) {
+ console.error('USAGE: change-version old_version new_version [--verbose] [--dry[-run]]')
+ console.error('Got arguments:', args)
+ process.exit(1)
+ }
+
+ // Strip any leading `v` from arguments because otherwise we will end up with duplicate `v`s
+ [oldVersion, newVersion] = [oldVersion, newVersion].map(arg => arg.startsWith('v') ? arg.slice(1) : arg)
+
+ try {
+ const files = await globby(GLOB, GLOBBY_OPTIONS)
+
+ await Promise.all(files.map(file => replaceRecursively(file, oldVersion, newVersion)))
+ } catch (error) {
+ console.error(error)
+ process.exit(1)
+ }
+}
+
+main(process.argv.slice(2))
diff --git a/build/generate-sri.js b/build/generate-sri.js
new file mode 100644
index 0000000..ef1b39f
--- /dev/null
+++ b/build/generate-sri.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+
+/*!
+ * Script to generate SRI hashes for use in our docs.
+ * Remember to use the same vendor files as the CDN ones,
+ * otherwise the hashes won't match!
+ *
+ * Copyright 2017-2022 The Bootstrap Authors
+ * Copyright 2017-2022 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */
+
+'use strict'
+
+const crypto = require('node:crypto')
+const fs = require('node:fs')
+const path = require('node:path')
+const sh = require('shelljs')
+
+sh.config.fatal = true
+
+const configFile = path.join(__dirname, '../config.yml')
+
+// Array of objects which holds the files to generate SRI hashes for.
+// `file` is the path from the root folder
+// `configPropertyName` is the config.yml variable's name of the file
+const files = [
+ {
+ file: 'dist/css/bootstrap.min.css',
+ configPropertyName: 'css_hash'
+ },
+ {
+ file: 'dist/css/bootstrap.rtl.min.css',
+ configPropertyName: 'css_rtl_hash'
+ },
+ {
+ file: 'dist/js/bootstrap.min.js',
+ configPropertyName: 'js_hash'
+ },
+ {
+ file: 'dist/js/bootstrap.bundle.min.js',
+ configPropertyName: 'js_bundle_hash'
+ },
+ {
+ file: 'node_modules/@popperjs/core/dist/umd/popper.min.js',
+ configPropertyName: 'popper_hash'
+ }
+]
+
+for (const file of files) {
+ fs.readFile(file.file, 'utf8', (error, data) => {
+ if (error) {
+ throw error
+ }
+
+ const algo = 'sha384'
+ const hash = crypto.createHash(algo).update(data, 'utf8').digest('base64')
+ const integrity = `${algo}-${hash}`
+
+ console.log(`${file.configPropertyName}: ${integrity}`)
+
+ sh.sed('-i', new RegExp(`^(\\s+${file.configPropertyName}:\\s+["'])\\S*(["'])`), `$1${integrity}$2`, configFile)
+ })
+}
diff --git a/build/postcss.config.js b/build/postcss.config.js
new file mode 100644
index 0000000..7f8186d
--- /dev/null
+++ b/build/postcss.config.js
@@ -0,0 +1,19 @@
+'use strict'
+
+const mapConfig = {
+ inline: false,
+ annotation: true,
+ sourcesContent: true
+}
+
+module.exports = context => {
+ return {
+ map: context.file.dirname.includes('examples') ? false : mapConfig,
+ plugins: {
+ autoprefixer: {
+ cascade: false
+ },
+ rtlcss: context.env === 'RTL'
+ }
+ }
+}
diff --git a/build/rollup.config.js b/build/rollup.config.js
new file mode 100644
index 0000000..27f12ac
--- /dev/null
+++ b/build/rollup.config.js
@@ -0,0 +1,57 @@
+'use strict'
+
+const path = require('node:path')
+const { babel } = require('@rollup/plugin-babel')
+const { nodeResolve } = require('@rollup/plugin-node-resolve')
+const replace = require('@rollup/plugin-replace')
+const banner = require('./banner.js')
+
+const BUNDLE = process.env.BUNDLE === 'true'
+const ESM = process.env.ESM === 'true'
+
+let fileDestination = `bootstrap${ESM ? '.esm' : ''}`
+const external = ['@popperjs/core']
+const plugins = [
+ babel({
+ // Only transpile our source code
+ exclude: 'node_modules/**',
+ // Include the helpers in the bundle, at most one copy of each
+ babelHelpers: 'bundled'
+ })
+]
+const globals = {
+ '@popperjs/core': 'Popper'
+}
+
+if (BUNDLE) {
+ fileDestination += '.bundle'
+ // Remove last entry in external array to bundle Popper
+ external.pop()
+ delete globals['@popperjs/core']
+ plugins.push(
+ replace({
+ 'process.env.NODE_ENV': '"production"',
+ preventAssignment: true
+ }),
+ nodeResolve()
+ )
+}
+
+const rollupConfig = {
+ input: path.resolve(__dirname, `../js/index.${ESM ? 'esm' : 'umd'}.js`),
+ output: {
+ banner,
+ file: path.resolve(__dirname, `../dist/js/${fileDestination}.js`),
+ format: ESM ? 'esm' : 'umd',
+ globals,
+ generatedCode: 'es2015'
+ },
+ external,
+ plugins
+}
+
+if (!ESM) {
+ rollupConfig.output.name = 'bootstrap'
+}
+
+module.exports = rollupConfig
diff --git a/build/vnu-jar.js b/build/vnu-jar.js
new file mode 100644
index 0000000..f29eeb7
--- /dev/null
+++ b/build/vnu-jar.js
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+
+/*!
+ * Script to run vnu-jar if Java is available.
+ * Copyright 2017-2022 The Bootstrap Authors
+ * Copyright 2017-2022 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */
+
+'use strict'
+
+const { execFile, spawn } = require('node:child_process')
+const vnu = require('vnu-jar')
+
+execFile('java', ['-version'], (error, stdout, stderr) => {
+ if (error) {
+ console.error('Skipping vnu-jar test; Java is missing.')
+ return
+ }
+
+ const is32bitJava = !/64-Bit/.test(stderr)
+
+ // vnu-jar accepts multiple ignores joined with a `|`.
+ // Also note that the ignores are string regular expressions.
+ const ignores = [
+ // "autocomplete" is included in <button> and checkboxes and radio <input>s due to
+ // Firefox's non-standard autocomplete behavior - see https://bugzilla.mozilla.org/show_bug.cgi?id=654072
+ 'Attribute β€œautocomplete” is only allowed when the input type is.*',
+ 'Attribute β€œautocomplete” not allowed on element β€œbutton” at this point.',
+ // Per https://www.w3.org/TR/html-aria/#docconformance having "aria-disabled" on a link is
+ // NOT RECOMMENDED, but it's still valid - we explain in the docs that it's not ideal,
+ // and offer more robust alternatives, but also need to show a less-than-ideal example
+ 'An β€œaria-disabled” attribute whose value is β€œtrue” should not be specified on an β€œa” element that has an β€œhref” attribute.'
+ ].join('|')
+
+ const args = [
+ '-jar',
+ `"${vnu}"`,
+ '--asciiquotes',
+ '--skip-non-html',
+ '--Werror',
+ `--filterpattern "${ignores}"`,
+ '_site/',
+ 'js/tests/'
+ ]
+
+ // For the 32-bit Java we need to pass `-Xss512k`
+ if (is32bitJava) {
+ args.splice(0, 0, '-Xss512k')
+ }
+
+ return spawn('java', args, {
+ shell: true,
+ stdio: 'inherit'
+ })
+ .on('exit', process.exit)
+})
diff --git a/build/zip-examples.js b/build/zip-examples.js
new file mode 100644
index 0000000..077901e
--- /dev/null
+++ b/build/zip-examples.js
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+
+/*!
+ * Script to create the built examples zip archive;
+ * requires the `zip` command to be present!
+ * Copyright 2020-2022 The Bootstrap Authors
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */
+
+'use strict'
+
+const path = require('node:path')
+const sh = require('shelljs')
+
+const pkg = require('../package.json')
+
+const versionShort = pkg.config.version_short
+const distFolder = `bootstrap-${pkg.version}-examples`
+const rootDocsDir = '_site'
+const docsDir = `${rootDocsDir}/docs/${versionShort}/`
+
+// these are the files we need in the examples
+const cssFiles = [
+ 'bootstrap.min.css',
+ 'bootstrap.min.css.map',
+ 'bootstrap.rtl.min.css',
+ 'bootstrap.rtl.min.css.map'
+]
+const jsFiles = [
+ 'bootstrap.bundle.min.js',
+ 'bootstrap.bundle.min.js.map'
+]
+const imgFiles = [
+ 'bootstrap-logo.svg',
+ 'bootstrap-logo-white.svg'
+]
+
+sh.config.fatal = true
+
+if (!sh.test('-d', rootDocsDir)) {
+ throw new Error(`The "${rootDocsDir}" folder does not exist, did you forget building the docs?`)
+}
+
+// switch to the root dir
+sh.cd(path.join(__dirname, '..'))
+
+// remove any previously created folder/zip with the same name
+sh.rm('-rf', [distFolder, `${distFolder}.zip`])
+
+// create any folders so that `cp` works
+sh.mkdir('-p', [
+ distFolder,
+ `${distFolder}/assets/brand/`,
+ `${distFolder}/assets/dist/css/`,
+ `${distFolder}/assets/dist/js/`
+])
+
+sh.cp('-Rf', `${docsDir}/examples/*`, distFolder)
+
+for (const file of cssFiles) {
+ sh.cp('-f', `${docsDir}/dist/css/${file}`, `${distFolder}/assets/dist/css/`)
+}
+
+for (const file of jsFiles) {
+ sh.cp('-f', `${docsDir}/dist/js/${file}`, `${distFolder}/assets/dist/js/`)
+}
+
+for (const file of imgFiles) {
+ sh.cp('-f', `${docsDir}/assets/brand/${file}`, `${distFolder}/assets/brand/`)
+}
+
+sh.rm(`${distFolder}/index.html`)
+
+// get all examples' HTML files
+for (const file of sh.find(`${distFolder}/**/*.html`)) {
+ const fileContents = sh.cat(file)
+ .toString()
+ .replace(new RegExp(`"/docs/${versionShort}/`, 'g'), '"../')
+ .replace(/"..\/dist\//g, '"../assets/dist/')
+ .replace(/(<link href="\.\.\/.*) integrity=".*>/g, '$1>')
+ .replace(/(<script src="\.\.\/.*) integrity=".*>/g, '$1></script>')
+ .replace(/( +)<!-- favicons(.|\n)+<style>/i, ' <style>')
+ new sh.ShellString(fileContents).to(file)
+}
+
+// create the zip file
+sh.exec(`zip -r9 "${distFolder}.zip" "${distFolder}"`)
+
+// remove the folder we created
+sh.rm('-rf', distFolder)
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..0b0629d
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,32 @@
+{
+ "name": "twbs/bootstrap",
+ "description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
+ "keywords": [
+ "css",
+ "js",
+ "sass",
+ "mobile-first",
+ "responsive",
+ "front-end",
+ "framework",
+ "web"
+ ],
+ "homepage": "https://getbootstrap.com/",
+ "authors": [
+ {
+ "name": "Mark Otto",
+ "email": "markdotto@gmail.com"
+ },
+ {
+ "name": "Jacob Thornton",
+ "email": "jacobthornton@gmail.com"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/twbs/bootstrap/issues"
+ },
+ "license": "MIT",
+ "replace": {
+ "twitter/bootstrap": "self.version"
+ }
+}
diff --git a/config.yml b/config.yml
new file mode 100644
index 0000000..682f873
--- /dev/null
+++ b/config.yml
@@ -0,0 +1,88 @@
+languageCode: "en"
+title: "Bootstrap"
+baseURL: "https://getbootstrap.com"
+
+security:
+ enableInlineShortcodes: true
+ funcs:
+ getenv:
+ - ^HUGO_
+ - NETLIFY
+
+markup:
+ goldmark:
+ renderer:
+ unsafe: true
+ highlight:
+ noClasses: false
+ tableOfContents:
+ startLevel: 2
+ endLevel: 6
+
+buildDrafts: true
+buildFuture: true
+
+enableRobotsTXT: true
+metaDataFormat: "yaml"
+disableKinds: ["404", "taxonomy", "term", "RSS"]
+
+publishDir: "_site"
+
+module:
+ mounts:
+ - source: dist
+ target: static/docs/5.2/dist
+ - source: site/assets
+ target: assets
+ - source: site/content
+ target: content
+ - source: site/data
+ target: data
+ - source: site/layouts
+ target: layouts
+ - source: site/static
+ target: static
+ - source: site/static/docs/5.2/assets/img/favicons/apple-touch-icon.png
+ target: static/apple-touch-icon.png
+ - source: site/static/docs/5.2/assets/img/favicons/favicon.ico
+ target: static/favicon.ico
+
+params:
+ subtitle: "The most popular HTML, CSS, and JS library in the world."
+ description: "Powerful, extensible, and feature-packed frontend toolkit. Build and customize with Sass, utilize prebuilt grid system and components, and bring projects to life with powerful JavaScript plugins."
+ authors: "Mark Otto, Jacob Thornton, and Bootstrap contributors"
+
+ current_version: "5.2.3"
+ current_ruby_version: "5.2.3"
+ docs_version: "5.2"
+ rfs_version: "v9.0.6"
+ github_org: "https://github.com/twbs"
+ repo: "https://github.com/twbs/bootstrap"
+ twitter: "getbootstrap"
+ opencollective: "https://opencollective.com/bootstrap"
+ blog: "https://blog.getbootstrap.com/"
+ themes: "https://themes.getbootstrap.com/"
+ icons: "https://icons.getbootstrap.com/"
+ swag: "https://cottonbureau.com/people/bootstrap"
+
+ download:
+ source: "https://github.com/twbs/bootstrap/archive/v5.2.3.zip"
+ dist: "https://github.com/twbs/bootstrap/releases/download/v5.2.3/bootstrap-5.2.3-dist.zip"
+ dist_examples: "https://github.com/twbs/bootstrap/releases/download/v5.2.3/bootstrap-5.2.3-examples.zip"
+
+ cdn:
+ # See https://www.srihash.org for info on how to generate the hashes
+ css: "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
+ css_hash: "sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65"
+ css_rtl: "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.rtl.min.css"
+ css_rtl_hash: "sha384-DOXMLfHhQkvFFp+rWTZwVlPVqdIhpDVYT9csOnHSgWQWPX0v5MCGtjCJbY6ERspU"
+ js: "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js"
+ js_hash: "sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V"
+ js_bundle: "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"
+ js_bundle_hash: "sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4"
+ popper: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"
+ popper_hash: "sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3"
+
+ anchors:
+ min: 2
+ max: 5
diff --git a/js/index.esm.js b/js/index.esm.js
new file mode 100644
index 0000000..b837649
--- /dev/null
+++ b/js/index.esm.js
@@ -0,0 +1,19 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): index.esm.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+export { default as Alert } from './src/alert'
+export { default as Button } from './src/button'
+export { default as Carousel } from './src/carousel'
+export { default as Collapse } from './src/collapse'
+export { default as Dropdown } from './src/dropdown'
+export { default as Modal } from './src/modal'
+export { default as Offcanvas } from './src/offcanvas'
+export { default as Popover } from './src/popover'
+export { default as ScrollSpy } from './src/scrollspy'
+export { default as Tab } from './src/tab'
+export { default as Toast } from './src/toast'
+export { default as Tooltip } from './src/tooltip'
diff --git a/js/index.umd.js b/js/index.umd.js
new file mode 100644
index 0000000..5abe8db
--- /dev/null
+++ b/js/index.umd.js
@@ -0,0 +1,34 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): index.umd.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import Alert from './src/alert'
+import Button from './src/button'
+import Carousel from './src/carousel'
+import Collapse from './src/collapse'
+import Dropdown from './src/dropdown'
+import Modal from './src/modal'
+import Offcanvas from './src/offcanvas'
+import Popover from './src/popover'
+import ScrollSpy from './src/scrollspy'
+import Tab from './src/tab'
+import Toast from './src/toast'
+import Tooltip from './src/tooltip'
+
+export default {
+ Alert,
+ Button,
+ Carousel,
+ Collapse,
+ Dropdown,
+ Modal,
+ Offcanvas,
+ Popover,
+ ScrollSpy,
+ Tab,
+ Toast,
+ Tooltip
+}
diff --git a/js/src/alert.js b/js/src/alert.js
new file mode 100644
index 0000000..59de828
--- /dev/null
+++ b/js/src/alert.js
@@ -0,0 +1,87 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): alert.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin } from './util/index'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'alert'
+const DATA_KEY = 'bs.alert'
+const EVENT_KEY = `.${DATA_KEY}`
+
+const EVENT_CLOSE = `close${EVENT_KEY}`
+const EVENT_CLOSED = `closed${EVENT_KEY}`
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+
+/**
+ * Class definition
+ */
+
+class Alert extends BaseComponent {
+ // Getters
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ close() {
+ const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE)
+
+ if (closeEvent.defaultPrevented) {
+ return
+ }
+
+ this._element.classList.remove(CLASS_NAME_SHOW)
+
+ const isAnimated = this._element.classList.contains(CLASS_NAME_FADE)
+ this._queueCallback(() => this._destroyElement(), this._element, isAnimated)
+ }
+
+ // Private
+ _destroyElement() {
+ this._element.remove()
+ EventHandler.trigger(this._element, EVENT_CLOSED)
+ this.dispose()
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Alert.getOrCreateInstance(this)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](this)
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+enableDismissTrigger(Alert, 'close')
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Alert)
+
+export default Alert
diff --git a/js/src/base-component.js b/js/src/base-component.js
new file mode 100644
index 0000000..0c1a259
--- /dev/null
+++ b/js/src/base-component.js
@@ -0,0 +1,85 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): base-component.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import Data from './dom/data'
+import { executeAfterTransition, getElement } from './util/index'
+import EventHandler from './dom/event-handler'
+import Config from './util/config'
+
+/**
+ * Constants
+ */
+
+const VERSION = '5.2.3'
+
+/**
+ * Class definition
+ */
+
+class BaseComponent extends Config {
+ constructor(element, config) {
+ super()
+
+ element = getElement(element)
+ if (!element) {
+ return
+ }
+
+ this._element = element
+ this._config = this._getConfig(config)
+
+ Data.set(this._element, this.constructor.DATA_KEY, this)
+ }
+
+ // Public
+ dispose() {
+ Data.remove(this._element, this.constructor.DATA_KEY)
+ EventHandler.off(this._element, this.constructor.EVENT_KEY)
+
+ for (const propertyName of Object.getOwnPropertyNames(this)) {
+ this[propertyName] = null
+ }
+ }
+
+ _queueCallback(callback, element, isAnimated = true) {
+ executeAfterTransition(callback, element, isAnimated)
+ }
+
+ _getConfig(config) {
+ config = this._mergeConfigObj(config, this._element)
+ config = this._configAfterMerge(config)
+ this._typeCheckConfig(config)
+ return config
+ }
+
+ // Static
+ static getInstance(element) {
+ return Data.get(getElement(element), this.DATA_KEY)
+ }
+
+ static getOrCreateInstance(element, config = {}) {
+ return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null)
+ }
+
+ static get VERSION() {
+ return VERSION
+ }
+
+ static get DATA_KEY() {
+ return `bs.${this.NAME}`
+ }
+
+ static get EVENT_KEY() {
+ return `.${this.DATA_KEY}`
+ }
+
+ static eventName(name) {
+ return `${name}${this.EVENT_KEY}`
+ }
+}
+
+export default BaseComponent
diff --git a/js/src/button.js b/js/src/button.js
new file mode 100644
index 0000000..03e7604
--- /dev/null
+++ b/js/src/button.js
@@ -0,0 +1,72 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): button.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin } from './util/index'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'button'
+const DATA_KEY = 'bs.button'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const CLASS_NAME_ACTIVE = 'active'
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="button"]'
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+/**
+ * Class definition
+ */
+
+class Button extends BaseComponent {
+ // Getters
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle() {
+ // Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method
+ this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE))
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Button.getOrCreateInstance(this)
+
+ if (config === 'toggle') {
+ data[config]()
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => {
+ event.preventDefault()
+
+ const button = event.target.closest(SELECTOR_DATA_TOGGLE)
+ const data = Button.getOrCreateInstance(button)
+
+ data.toggle()
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Button)
+
+export default Button
diff --git a/js/src/carousel.js b/js/src/carousel.js
new file mode 100644
index 0000000..24bbe39
--- /dev/null
+++ b/js/src/carousel.js
@@ -0,0 +1,475 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): carousel.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import {
+ defineJQueryPlugin,
+ getElementFromSelector,
+ getNextActiveElement,
+ isRTL,
+ isVisible,
+ reflow,
+ triggerTransitionEnd
+} from './util/index'
+import EventHandler from './dom/event-handler'
+import Manipulator from './dom/manipulator'
+import SelectorEngine from './dom/selector-engine'
+import Swipe from './util/swipe'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'carousel'
+const DATA_KEY = 'bs.carousel'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const ARROW_LEFT_KEY = 'ArrowLeft'
+const ARROW_RIGHT_KEY = 'ArrowRight'
+const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch
+
+const ORDER_NEXT = 'next'
+const ORDER_PREV = 'prev'
+const DIRECTION_LEFT = 'left'
+const DIRECTION_RIGHT = 'right'
+
+const EVENT_SLIDE = `slide${EVENT_KEY}`
+const EVENT_SLID = `slid${EVENT_KEY}`
+const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
+const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
+const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
+const EVENT_DRAG_START = `dragstart${EVENT_KEY}`
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_CAROUSEL = 'carousel'
+const CLASS_NAME_ACTIVE = 'active'
+const CLASS_NAME_SLIDE = 'slide'
+const CLASS_NAME_END = 'carousel-item-end'
+const CLASS_NAME_START = 'carousel-item-start'
+const CLASS_NAME_NEXT = 'carousel-item-next'
+const CLASS_NAME_PREV = 'carousel-item-prev'
+
+const SELECTOR_ACTIVE = '.active'
+const SELECTOR_ITEM = '.carousel-item'
+const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM
+const SELECTOR_ITEM_IMG = '.carousel-item img'
+const SELECTOR_INDICATORS = '.carousel-indicators'
+const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'
+const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'
+
+const KEY_TO_DIRECTION = {
+ [ARROW_LEFT_KEY]: DIRECTION_RIGHT,
+ [ARROW_RIGHT_KEY]: DIRECTION_LEFT
+}
+
+const Default = {
+ interval: 5000,
+ keyboard: true,
+ pause: 'hover',
+ ride: false,
+ touch: true,
+ wrap: true
+}
+
+const DefaultType = {
+ interval: '(number|boolean)', // TODO:v6 remove boolean support
+ keyboard: 'boolean',
+ pause: '(string|boolean)',
+ ride: '(boolean|string)',
+ touch: 'boolean',
+ wrap: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Carousel extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._interval = null
+ this._activeElement = null
+ this._isSliding = false
+ this.touchTimeout = null
+ this._swipeHelper = null
+
+ this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
+ this._addEventListeners()
+
+ if (this._config.ride === CLASS_NAME_CAROUSEL) {
+ this.cycle()
+ }
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ next() {
+ this._slide(ORDER_NEXT)
+ }
+
+ nextWhenVisible() {
+ // FIXME TODO use `document.visibilityState`
+ // Don't call next when the page isn't visible
+ // or the carousel or its parent isn't visible
+ if (!document.hidden && isVisible(this._element)) {
+ this.next()
+ }
+ }
+
+ prev() {
+ this._slide(ORDER_PREV)
+ }
+
+ pause() {
+ if (this._isSliding) {
+ triggerTransitionEnd(this._element)
+ }
+
+ this._clearInterval()
+ }
+
+ cycle() {
+ this._clearInterval()
+ this._updateInterval()
+
+ this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval)
+ }
+
+ _maybeEnableCycle() {
+ if (!this._config.ride) {
+ return
+ }
+
+ if (this._isSliding) {
+ EventHandler.one(this._element, EVENT_SLID, () => this.cycle())
+ return
+ }
+
+ this.cycle()
+ }
+
+ to(index) {
+ const items = this._getItems()
+ if (index > items.length - 1 || index < 0) {
+ return
+ }
+
+ if (this._isSliding) {
+ EventHandler.one(this._element, EVENT_SLID, () => this.to(index))
+ return
+ }
+
+ const activeIndex = this._getItemIndex(this._getActive())
+ if (activeIndex === index) {
+ return
+ }
+
+ const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV
+
+ this._slide(order, items[index])
+ }
+
+ dispose() {
+ if (this._swipeHelper) {
+ this._swipeHelper.dispose()
+ }
+
+ super.dispose()
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ config.defaultInterval = config.interval
+ return config
+ }
+
+ _addEventListeners() {
+ if (this._config.keyboard) {
+ EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
+ }
+
+ if (this._config.pause === 'hover') {
+ EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause())
+ EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
+ }
+
+ if (this._config.touch && Swipe.isSupported()) {
+ this._addTouchEventListeners()
+ }
+ }
+
+ _addTouchEventListeners() {
+ for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
+ EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault())
+ }
+
+ const endCallBack = () => {
+ if (this._config.pause !== 'hover') {
+ return
+ }
+
+ // If it's a touch-enabled device, mouseenter/leave are fired as
+ // part of the mouse compatibility events on first tap - the carousel
+ // would stop cycling until user tapped out of it;
+ // here, we listen for touchend, explicitly pause the carousel
+ // (as if it's the second time we tap on it, mouseenter compat event
+ // is NOT fired) and after a timeout (to allow for mouse compatibility
+ // events to fire) we explicitly restart cycling
+
+ this.pause()
+ if (this.touchTimeout) {
+ clearTimeout(this.touchTimeout)
+ }
+
+ this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval)
+ }
+
+ const swipeConfig = {
+ leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),
+ rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),
+ endCallback: endCallBack
+ }
+
+ this._swipeHelper = new Swipe(this._element, swipeConfig)
+ }
+
+ _keydown(event) {
+ if (/input|textarea/i.test(event.target.tagName)) {
+ return
+ }
+
+ const direction = KEY_TO_DIRECTION[event.key]
+ if (direction) {
+ event.preventDefault()
+ this._slide(this._directionToOrder(direction))
+ }
+ }
+
+ _getItemIndex(element) {
+ return this._getItems().indexOf(element)
+ }
+
+ _setActiveIndicatorElement(index) {
+ if (!this._indicatorsElement) {
+ return
+ }
+
+ const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
+
+ activeIndicator.classList.remove(CLASS_NAME_ACTIVE)
+ activeIndicator.removeAttribute('aria-current')
+
+ const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement)
+
+ if (newActiveIndicator) {
+ newActiveIndicator.classList.add(CLASS_NAME_ACTIVE)
+ newActiveIndicator.setAttribute('aria-current', 'true')
+ }
+ }
+
+ _updateInterval() {
+ const element = this._activeElement || this._getActive()
+
+ if (!element) {
+ return
+ }
+
+ const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10)
+
+ this._config.interval = elementInterval || this._config.defaultInterval
+ }
+
+ _slide(order, element = null) {
+ if (this._isSliding) {
+ return
+ }
+
+ const activeElement = this._getActive()
+ const isNext = order === ORDER_NEXT
+ const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap)
+
+ if (nextElement === activeElement) {
+ return
+ }
+
+ const nextElementIndex = this._getItemIndex(nextElement)
+
+ const triggerEvent = eventName => {
+ return EventHandler.trigger(this._element, eventName, {
+ relatedTarget: nextElement,
+ direction: this._orderToDirection(order),
+ from: this._getItemIndex(activeElement),
+ to: nextElementIndex
+ })
+ }
+
+ const slideEvent = triggerEvent(EVENT_SLIDE)
+
+ if (slideEvent.defaultPrevented) {
+ return
+ }
+
+ if (!activeElement || !nextElement) {
+ // Some weirdness is happening, so we bail
+ // todo: change tests that use empty divs to avoid this check
+ return
+ }
+
+ const isCycling = Boolean(this._interval)
+ this.pause()
+
+ this._isSliding = true
+
+ this._setActiveIndicatorElement(nextElementIndex)
+ this._activeElement = nextElement
+
+ const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END
+ const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV
+
+ nextElement.classList.add(orderClassName)
+
+ reflow(nextElement)
+
+ activeElement.classList.add(directionalClassName)
+ nextElement.classList.add(directionalClassName)
+
+ const completeCallBack = () => {
+ nextElement.classList.remove(directionalClassName, orderClassName)
+ nextElement.classList.add(CLASS_NAME_ACTIVE)
+
+ activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)
+
+ this._isSliding = false
+
+ triggerEvent(EVENT_SLID)
+ }
+
+ this._queueCallback(completeCallBack, activeElement, this._isAnimated())
+
+ if (isCycling) {
+ this.cycle()
+ }
+ }
+
+ _isAnimated() {
+ return this._element.classList.contains(CLASS_NAME_SLIDE)
+ }
+
+ _getActive() {
+ return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
+ }
+
+ _getItems() {
+ return SelectorEngine.find(SELECTOR_ITEM, this._element)
+ }
+
+ _clearInterval() {
+ if (this._interval) {
+ clearInterval(this._interval)
+ this._interval = null
+ }
+ }
+
+ _directionToOrder(direction) {
+ if (isRTL()) {
+ return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT
+ }
+
+ return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV
+ }
+
+ _orderToDirection(order) {
+ if (isRTL()) {
+ return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT
+ }
+
+ return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Carousel.getOrCreateInstance(this, config)
+
+ if (typeof config === 'number') {
+ data.to(config)
+ return
+ }
+
+ if (typeof config === 'string') {
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) {
+ const target = getElementFromSelector(this)
+
+ if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
+ return
+ }
+
+ event.preventDefault()
+
+ const carousel = Carousel.getOrCreateInstance(target)
+ const slideIndex = this.getAttribute('data-bs-slide-to')
+
+ if (slideIndex) {
+ carousel.to(slideIndex)
+ carousel._maybeEnableCycle()
+ return
+ }
+
+ if (Manipulator.getDataAttribute(this, 'slide') === 'next') {
+ carousel.next()
+ carousel._maybeEnableCycle()
+ return
+ }
+
+ carousel.prev()
+ carousel._maybeEnableCycle()
+})
+
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE)
+
+ for (const carousel of carousels) {
+ Carousel.getOrCreateInstance(carousel)
+ }
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Carousel)
+
+export default Carousel
diff --git a/js/src/collapse.js b/js/src/collapse.js
new file mode 100644
index 0000000..204d180
--- /dev/null
+++ b/js/src/collapse.js
@@ -0,0 +1,302 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): collapse.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import {
+ defineJQueryPlugin,
+ getElement,
+ getElementFromSelector,
+ getSelectorFromElement,
+ reflow
+} from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'collapse'
+const DATA_KEY = 'bs.collapse'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_COLLAPSE = 'collapse'
+const CLASS_NAME_COLLAPSING = 'collapsing'
+const CLASS_NAME_COLLAPSED = 'collapsed'
+const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`
+const CLASS_NAME_HORIZONTAL = 'collapse-horizontal'
+
+const WIDTH = 'width'
+const HEIGHT = 'height'
+
+const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing'
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"]'
+
+const Default = {
+ parent: null,
+ toggle: true
+}
+
+const DefaultType = {
+ parent: '(null|element)',
+ toggle: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Collapse extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._isTransitioning = false
+ this._triggerArray = []
+
+ const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE)
+
+ for (const elem of toggleList) {
+ const selector = getSelectorFromElement(elem)
+ const filterElement = SelectorEngine.find(selector)
+ .filter(foundElement => foundElement === this._element)
+
+ if (selector !== null && filterElement.length) {
+ this._triggerArray.push(elem)
+ }
+ }
+
+ this._initializeChildren()
+
+ if (!this._config.parent) {
+ this._addAriaAndCollapsedClass(this._triggerArray, this._isShown())
+ }
+
+ if (this._config.toggle) {
+ this.toggle()
+ }
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle() {
+ if (this._isShown()) {
+ this.hide()
+ } else {
+ this.show()
+ }
+ }
+
+ show() {
+ if (this._isTransitioning || this._isShown()) {
+ return
+ }
+
+ let activeChildren = []
+
+ // find active children
+ if (this._config.parent) {
+ activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES)
+ .filter(element => element !== this._element)
+ .map(element => Collapse.getOrCreateInstance(element, { toggle: false }))
+ }
+
+ if (activeChildren.length && activeChildren[0]._isTransitioning) {
+ return
+ }
+
+ const startEvent = EventHandler.trigger(this._element, EVENT_SHOW)
+ if (startEvent.defaultPrevented) {
+ return
+ }
+
+ for (const activeInstance of activeChildren) {
+ activeInstance.hide()
+ }
+
+ const dimension = this._getDimension()
+
+ this._element.classList.remove(CLASS_NAME_COLLAPSE)
+ this._element.classList.add(CLASS_NAME_COLLAPSING)
+
+ this._element.style[dimension] = 0
+
+ this._addAriaAndCollapsedClass(this._triggerArray, true)
+ this._isTransitioning = true
+
+ const complete = () => {
+ this._isTransitioning = false
+
+ this._element.classList.remove(CLASS_NAME_COLLAPSING)
+ this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
+
+ this._element.style[dimension] = ''
+
+ EventHandler.trigger(this._element, EVENT_SHOWN)
+ }
+
+ const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)
+ const scrollSize = `scroll${capitalizedDimension}`
+
+ this._queueCallback(complete, this._element, true)
+ this._element.style[dimension] = `${this._element[scrollSize]}px`
+ }
+
+ hide() {
+ if (this._isTransitioning || !this._isShown()) {
+ return
+ }
+
+ const startEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+ if (startEvent.defaultPrevented) {
+ return
+ }
+
+ const dimension = this._getDimension()
+
+ this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`
+
+ reflow(this._element)
+
+ this._element.classList.add(CLASS_NAME_COLLAPSING)
+ this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
+
+ for (const trigger of this._triggerArray) {
+ const element = getElementFromSelector(trigger)
+
+ if (element && !this._isShown(element)) {
+ this._addAriaAndCollapsedClass([trigger], false)
+ }
+ }
+
+ this._isTransitioning = true
+
+ const complete = () => {
+ this._isTransitioning = false
+ this._element.classList.remove(CLASS_NAME_COLLAPSING)
+ this._element.classList.add(CLASS_NAME_COLLAPSE)
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ }
+
+ this._element.style[dimension] = ''
+
+ this._queueCallback(complete, this._element, true)
+ }
+
+ _isShown(element = this._element) {
+ return element.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ config.toggle = Boolean(config.toggle) // Coerce string values
+ config.parent = getElement(config.parent)
+ return config
+ }
+
+ _getDimension() {
+ return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT
+ }
+
+ _initializeChildren() {
+ if (!this._config.parent) {
+ return
+ }
+
+ const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE)
+
+ for (const element of children) {
+ const selected = getElementFromSelector(element)
+
+ if (selected) {
+ this._addAriaAndCollapsedClass([element], this._isShown(selected))
+ }
+ }
+ }
+
+ _getFirstLevelChildren(selector) {
+ const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent)
+ // remove children if greater depth
+ return SelectorEngine.find(selector, this._config.parent).filter(element => !children.includes(element))
+ }
+
+ _addAriaAndCollapsedClass(triggerArray, isOpen) {
+ if (!triggerArray.length) {
+ return
+ }
+
+ for (const element of triggerArray) {
+ element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen)
+ element.setAttribute('aria-expanded', isOpen)
+ }
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ const _config = {}
+ if (typeof config === 'string' && /show|hide/.test(config)) {
+ _config.toggle = false
+ }
+
+ return this.each(function () {
+ const data = Collapse.getOrCreateInstance(this, _config)
+
+ if (typeof config === 'string') {
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ // preventDefault only for <a> elements (which change the URL) not inside the collapsible element
+ if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) {
+ event.preventDefault()
+ }
+
+ const selector = getSelectorFromElement(this)
+ const selectorElements = SelectorEngine.find(selector)
+
+ for (const element of selectorElements) {
+ Collapse.getOrCreateInstance(element, { toggle: false }).toggle()
+ }
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Collapse)
+
+export default Collapse
diff --git a/js/src/dom/data.js b/js/src/dom/data.js
new file mode 100644
index 0000000..2c6a46e
--- /dev/null
+++ b/js/src/dom/data.js
@@ -0,0 +1,55 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/data.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+/**
+ * Constants
+ */
+
+const elementMap = new Map()
+
+export default {
+ set(element, key, instance) {
+ if (!elementMap.has(element)) {
+ elementMap.set(element, new Map())
+ }
+
+ const instanceMap = elementMap.get(element)
+
+ // make it clear we only want one instance per element
+ // can be removed later when multiple key/instances are fine to be used
+ if (!instanceMap.has(key) && instanceMap.size !== 0) {
+ // eslint-disable-next-line no-console
+ console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`)
+ return
+ }
+
+ instanceMap.set(key, instance)
+ },
+
+ get(element, key) {
+ if (elementMap.has(element)) {
+ return elementMap.get(element).get(key) || null
+ }
+
+ return null
+ },
+
+ remove(element, key) {
+ if (!elementMap.has(element)) {
+ return
+ }
+
+ const instanceMap = elementMap.get(element)
+
+ instanceMap.delete(key)
+
+ // free up element references if there are no instances left for an element
+ if (instanceMap.size === 0) {
+ elementMap.delete(element)
+ }
+ }
+}
diff --git a/js/src/dom/event-handler.js b/js/src/dom/event-handler.js
new file mode 100644
index 0000000..9876d77
--- /dev/null
+++ b/js/src/dom/event-handler.js
@@ -0,0 +1,320 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/event-handler.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { getjQuery } from '../util/index'
+
+/**
+ * Constants
+ */
+
+const namespaceRegex = /[^.]*(?=\..*)\.|.*/
+const stripNameRegex = /\..*/
+const stripUidRegex = /::\d+$/
+const eventRegistry = {} // Events storage
+let uidEvent = 1
+const customEvents = {
+ mouseenter: 'mouseover',
+ mouseleave: 'mouseout'
+}
+
+const nativeEvents = new Set([
+ 'click',
+ 'dblclick',
+ 'mouseup',
+ 'mousedown',
+ 'contextmenu',
+ 'mousewheel',
+ 'DOMMouseScroll',
+ 'mouseover',
+ 'mouseout',
+ 'mousemove',
+ 'selectstart',
+ 'selectend',
+ 'keydown',
+ 'keypress',
+ 'keyup',
+ 'orientationchange',
+ 'touchstart',
+ 'touchmove',
+ 'touchend',
+ 'touchcancel',
+ 'pointerdown',
+ 'pointermove',
+ 'pointerup',
+ 'pointerleave',
+ 'pointercancel',
+ 'gesturestart',
+ 'gesturechange',
+ 'gestureend',
+ 'focus',
+ 'blur',
+ 'change',
+ 'reset',
+ 'select',
+ 'submit',
+ 'focusin',
+ 'focusout',
+ 'load',
+ 'unload',
+ 'beforeunload',
+ 'resize',
+ 'move',
+ 'DOMContentLoaded',
+ 'readystatechange',
+ 'error',
+ 'abort',
+ 'scroll'
+])
+
+/**
+ * Private methods
+ */
+
+function makeEventUid(element, uid) {
+ return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++
+}
+
+function getElementEvents(element) {
+ const uid = makeEventUid(element)
+
+ element.uidEvent = uid
+ eventRegistry[uid] = eventRegistry[uid] || {}
+
+ return eventRegistry[uid]
+}
+
+function bootstrapHandler(element, fn) {
+ return function handler(event) {
+ hydrateObj(event, { delegateTarget: element })
+
+ if (handler.oneOff) {
+ EventHandler.off(element, event.type, fn)
+ }
+
+ return fn.apply(element, [event])
+ }
+}
+
+function bootstrapDelegationHandler(element, selector, fn) {
+ return function handler(event) {
+ const domElements = element.querySelectorAll(selector)
+
+ for (let { target } = event; target && target !== this; target = target.parentNode) {
+ for (const domElement of domElements) {
+ if (domElement !== target) {
+ continue
+ }
+
+ hydrateObj(event, { delegateTarget: target })
+
+ if (handler.oneOff) {
+ EventHandler.off(element, event.type, selector, fn)
+ }
+
+ return fn.apply(target, [event])
+ }
+ }
+ }
+}
+
+function findHandler(events, callable, delegationSelector = null) {
+ return Object.values(events)
+ .find(event => event.callable === callable && event.delegationSelector === delegationSelector)
+}
+
+function normalizeParameters(originalTypeEvent, handler, delegationFunction) {
+ const isDelegated = typeof handler === 'string'
+ // todo: tooltip passes `false` instead of selector, so we need to check
+ const callable = isDelegated ? delegationFunction : (handler || delegationFunction)
+ let typeEvent = getTypeEvent(originalTypeEvent)
+
+ if (!nativeEvents.has(typeEvent)) {
+ typeEvent = originalTypeEvent
+ }
+
+ return [isDelegated, callable, typeEvent]
+}
+
+function addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) {
+ if (typeof originalTypeEvent !== 'string' || !element) {
+ return
+ }
+
+ let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
+
+ // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position
+ // this prevents the handler from being dispatched the same way as mouseover or mouseout does
+ if (originalTypeEvent in customEvents) {
+ const wrapFunction = fn => {
+ return function (event) {
+ if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) {
+ return fn.call(this, event)
+ }
+ }
+ }
+
+ callable = wrapFunction(callable)
+ }
+
+ const events = getElementEvents(element)
+ const handlers = events[typeEvent] || (events[typeEvent] = {})
+ const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null)
+
+ if (previousFunction) {
+ previousFunction.oneOff = previousFunction.oneOff && oneOff
+
+ return
+ }
+
+ const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, ''))
+ const fn = isDelegated ?
+ bootstrapDelegationHandler(element, handler, callable) :
+ bootstrapHandler(element, callable)
+
+ fn.delegationSelector = isDelegated ? handler : null
+ fn.callable = callable
+ fn.oneOff = oneOff
+ fn.uidEvent = uid
+ handlers[uid] = fn
+
+ element.addEventListener(typeEvent, fn, isDelegated)
+}
+
+function removeHandler(element, events, typeEvent, handler, delegationSelector) {
+ const fn = findHandler(events[typeEvent], handler, delegationSelector)
+
+ if (!fn) {
+ return
+ }
+
+ element.removeEventListener(typeEvent, fn, Boolean(delegationSelector))
+ delete events[typeEvent][fn.uidEvent]
+}
+
+function removeNamespacedHandlers(element, events, typeEvent, namespace) {
+ const storeElementEvent = events[typeEvent] || {}
+
+ for (const handlerKey of Object.keys(storeElementEvent)) {
+ if (handlerKey.includes(namespace)) {
+ const event = storeElementEvent[handlerKey]
+ removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)
+ }
+ }
+}
+
+function getTypeEvent(event) {
+ // allow to get the native events from namespaced events ('click.bs.button' --> 'click')
+ event = event.replace(stripNameRegex, '')
+ return customEvents[event] || event
+}
+
+const EventHandler = {
+ on(element, event, handler, delegationFunction) {
+ addHandler(element, event, handler, delegationFunction, false)
+ },
+
+ one(element, event, handler, delegationFunction) {
+ addHandler(element, event, handler, delegationFunction, true)
+ },
+
+ off(element, originalTypeEvent, handler, delegationFunction) {
+ if (typeof originalTypeEvent !== 'string' || !element) {
+ return
+ }
+
+ const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
+ const inNamespace = typeEvent !== originalTypeEvent
+ const events = getElementEvents(element)
+ const storeElementEvent = events[typeEvent] || {}
+ const isNamespace = originalTypeEvent.startsWith('.')
+
+ if (typeof callable !== 'undefined') {
+ // Simplest case: handler is passed, remove that listener ONLY.
+ if (!Object.keys(storeElementEvent).length) {
+ return
+ }
+
+ removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null)
+ return
+ }
+
+ if (isNamespace) {
+ for (const elementEvent of Object.keys(events)) {
+ removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1))
+ }
+ }
+
+ for (const keyHandlers of Object.keys(storeElementEvent)) {
+ const handlerKey = keyHandlers.replace(stripUidRegex, '')
+
+ if (!inNamespace || originalTypeEvent.includes(handlerKey)) {
+ const event = storeElementEvent[keyHandlers]
+ removeHandler(element, events, typeEvent, event.callable, event.delegationSelector)
+ }
+ }
+ },
+
+ trigger(element, event, args) {
+ if (typeof event !== 'string' || !element) {
+ return null
+ }
+
+ const $ = getjQuery()
+ const typeEvent = getTypeEvent(event)
+ const inNamespace = event !== typeEvent
+
+ let jQueryEvent = null
+ let bubbles = true
+ let nativeDispatch = true
+ let defaultPrevented = false
+
+ if (inNamespace && $) {
+ jQueryEvent = $.Event(event, args)
+
+ $(element).trigger(jQueryEvent)
+ bubbles = !jQueryEvent.isPropagationStopped()
+ nativeDispatch = !jQueryEvent.isImmediatePropagationStopped()
+ defaultPrevented = jQueryEvent.isDefaultPrevented()
+ }
+
+ let evt = new Event(event, { bubbles, cancelable: true })
+ evt = hydrateObj(evt, args)
+
+ if (defaultPrevented) {
+ evt.preventDefault()
+ }
+
+ if (nativeDispatch) {
+ element.dispatchEvent(evt)
+ }
+
+ if (evt.defaultPrevented && jQueryEvent) {
+ jQueryEvent.preventDefault()
+ }
+
+ return evt
+ }
+}
+
+function hydrateObj(obj, meta) {
+ for (const [key, value] of Object.entries(meta || {})) {
+ try {
+ obj[key] = value
+ } catch {
+ Object.defineProperty(obj, key, {
+ configurable: true,
+ get() {
+ return value
+ }
+ })
+ }
+ }
+
+ return obj
+}
+
+export default EventHandler
diff --git a/js/src/dom/manipulator.js b/js/src/dom/manipulator.js
new file mode 100644
index 0000000..38ecfe4
--- /dev/null
+++ b/js/src/dom/manipulator.js
@@ -0,0 +1,71 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/manipulator.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+function normalizeData(value) {
+ if (value === 'true') {
+ return true
+ }
+
+ if (value === 'false') {
+ return false
+ }
+
+ if (value === Number(value).toString()) {
+ return Number(value)
+ }
+
+ if (value === '' || value === 'null') {
+ return null
+ }
+
+ if (typeof value !== 'string') {
+ return value
+ }
+
+ try {
+ return JSON.parse(decodeURIComponent(value))
+ } catch {
+ return value
+ }
+}
+
+function normalizeDataKey(key) {
+ return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`)
+}
+
+const Manipulator = {
+ setDataAttribute(element, key, value) {
+ element.setAttribute(`data-bs-${normalizeDataKey(key)}`, value)
+ },
+
+ removeDataAttribute(element, key) {
+ element.removeAttribute(`data-bs-${normalizeDataKey(key)}`)
+ },
+
+ getDataAttributes(element) {
+ if (!element) {
+ return {}
+ }
+
+ const attributes = {}
+ const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'))
+
+ for (const key of bsKeys) {
+ let pureKey = key.replace(/^bs/, '')
+ pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length)
+ attributes[pureKey] = normalizeData(element.dataset[key])
+ }
+
+ return attributes
+ },
+
+ getDataAttribute(element, key) {
+ return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`))
+ }
+}
+
+export default Manipulator
diff --git a/js/src/dom/selector-engine.js b/js/src/dom/selector-engine.js
new file mode 100644
index 0000000..1ba104f
--- /dev/null
+++ b/js/src/dom/selector-engine.js
@@ -0,0 +1,83 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dom/selector-engine.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { isDisabled, isVisible } from '../util/index'
+
+/**
+ * Constants
+ */
+
+const SelectorEngine = {
+ find(selector, element = document.documentElement) {
+ return [].concat(...Element.prototype.querySelectorAll.call(element, selector))
+ },
+
+ findOne(selector, element = document.documentElement) {
+ return Element.prototype.querySelector.call(element, selector)
+ },
+
+ children(element, selector) {
+ return [].concat(...element.children).filter(child => child.matches(selector))
+ },
+
+ parents(element, selector) {
+ const parents = []
+ let ancestor = element.parentNode.closest(selector)
+
+ while (ancestor) {
+ parents.push(ancestor)
+ ancestor = ancestor.parentNode.closest(selector)
+ }
+
+ return parents
+ },
+
+ prev(element, selector) {
+ let previous = element.previousElementSibling
+
+ while (previous) {
+ if (previous.matches(selector)) {
+ return [previous]
+ }
+
+ previous = previous.previousElementSibling
+ }
+
+ return []
+ },
+ // TODO: this is now unused; remove later along with prev()
+ next(element, selector) {
+ let next = element.nextElementSibling
+
+ while (next) {
+ if (next.matches(selector)) {
+ return [next]
+ }
+
+ next = next.nextElementSibling
+ }
+
+ return []
+ },
+
+ focusableChildren(element) {
+ const focusables = [
+ 'a',
+ 'button',
+ 'input',
+ 'textarea',
+ 'select',
+ 'details',
+ '[tabindex]',
+ '[contenteditable="true"]'
+ ].map(selector => `${selector}:not([tabindex^="-"])`).join(',')
+
+ return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el))
+ }
+}
+
+export default SelectorEngine
diff --git a/js/src/dropdown.js b/js/src/dropdown.js
new file mode 100644
index 0000000..9596baa
--- /dev/null
+++ b/js/src/dropdown.js
@@ -0,0 +1,454 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): dropdown.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import * as Popper from '@popperjs/core'
+import {
+ defineJQueryPlugin,
+ getElement,
+ getNextActiveElement,
+ isDisabled,
+ isElement,
+ isRTL,
+ isVisible,
+ noop
+} from './util/index'
+import EventHandler from './dom/event-handler'
+import Manipulator from './dom/manipulator'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'dropdown'
+const DATA_KEY = 'bs.dropdown'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const ESCAPE_KEY = 'Escape'
+const TAB_KEY = 'Tab'
+const ARROW_UP_KEY = 'ArrowUp'
+const ARROW_DOWN_KEY = 'ArrowDown'
+const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button
+
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_DROPUP = 'dropup'
+const CLASS_NAME_DROPEND = 'dropend'
+const CLASS_NAME_DROPSTART = 'dropstart'
+const CLASS_NAME_DROPUP_CENTER = 'dropup-center'
+const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center'
+
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)'
+const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}`
+const SELECTOR_MENU = '.dropdown-menu'
+const SELECTOR_NAVBAR = '.navbar'
+const SELECTOR_NAVBAR_NAV = '.navbar-nav'
+const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'
+
+const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start'
+const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end'
+const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start'
+const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end'
+const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start'
+const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start'
+const PLACEMENT_TOPCENTER = 'top'
+const PLACEMENT_BOTTOMCENTER = 'bottom'
+
+const Default = {
+ autoClose: true,
+ boundary: 'clippingParents',
+ display: 'dynamic',
+ offset: [0, 2],
+ popperConfig: null,
+ reference: 'toggle'
+}
+
+const DefaultType = {
+ autoClose: '(boolean|string)',
+ boundary: '(string|element)',
+ display: 'string',
+ offset: '(array|string|function)',
+ popperConfig: '(null|object|function)',
+ reference: '(string|element|object)'
+}
+
+/**
+ * Class definition
+ */
+
+class Dropdown extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._popper = null
+ this._parent = this._element.parentNode // dropdown wrapper
+ // todo: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.2/forms/input-group/
+ this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||
+ SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||
+ SelectorEngine.findOne(SELECTOR_MENU, this._parent)
+ this._inNavbar = this._detectNavbar()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle() {
+ return this._isShown() ? this.hide() : this.show()
+ }
+
+ show() {
+ if (isDisabled(this._element) || this._isShown()) {
+ return
+ }
+
+ const relatedTarget = {
+ relatedTarget: this._element
+ }
+
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget)
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._createPopper()
+
+ // If this is a touch-enabled device we add extra
+ // empty mouseover listeners to the body's immediate children;
+ // only needed because of broken event delegation on iOS
+ // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
+ if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.on(element, 'mouseover', noop)
+ }
+ }
+
+ this._element.focus()
+ this._element.setAttribute('aria-expanded', true)
+
+ this._menu.classList.add(CLASS_NAME_SHOW)
+ this._element.classList.add(CLASS_NAME_SHOW)
+ EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)
+ }
+
+ hide() {
+ if (isDisabled(this._element) || !this._isShown()) {
+ return
+ }
+
+ const relatedTarget = {
+ relatedTarget: this._element
+ }
+
+ this._completeHide(relatedTarget)
+ }
+
+ dispose() {
+ if (this._popper) {
+ this._popper.destroy()
+ }
+
+ super.dispose()
+ }
+
+ update() {
+ this._inNavbar = this._detectNavbar()
+ if (this._popper) {
+ this._popper.update()
+ }
+ }
+
+ // Private
+ _completeHide(relatedTarget) {
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget)
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ // If this is a touch-enabled device we remove the extra
+ // empty mouseover listeners we added for iOS support
+ if ('ontouchstart' in document.documentElement) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.off(element, 'mouseover', noop)
+ }
+ }
+
+ if (this._popper) {
+ this._popper.destroy()
+ }
+
+ this._menu.classList.remove(CLASS_NAME_SHOW)
+ this._element.classList.remove(CLASS_NAME_SHOW)
+ this._element.setAttribute('aria-expanded', 'false')
+ Manipulator.removeDataAttribute(this._menu, 'popper')
+ EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)
+ }
+
+ _getConfig(config) {
+ config = super._getConfig(config)
+
+ if (typeof config.reference === 'object' && !isElement(config.reference) &&
+ typeof config.reference.getBoundingClientRect !== 'function'
+ ) {
+ // Popper virtual elements require a getBoundingClientRect method
+ throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`)
+ }
+
+ return config
+ }
+
+ _createPopper() {
+ if (typeof Popper === 'undefined') {
+ throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)')
+ }
+
+ let referenceElement = this._element
+
+ if (this._config.reference === 'parent') {
+ referenceElement = this._parent
+ } else if (isElement(this._config.reference)) {
+ referenceElement = getElement(this._config.reference)
+ } else if (typeof this._config.reference === 'object') {
+ referenceElement = this._config.reference
+ }
+
+ const popperConfig = this._getPopperConfig()
+ this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig)
+ }
+
+ _isShown() {
+ return this._menu.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ _getPlacement() {
+ const parentDropdown = this._parent
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) {
+ return PLACEMENT_RIGHT
+ }
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) {
+ return PLACEMENT_LEFT
+ }
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) {
+ return PLACEMENT_TOPCENTER
+ }
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) {
+ return PLACEMENT_BOTTOMCENTER
+ }
+
+ // We need to trim the value because custom properties can also include spaces
+ const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end'
+
+ if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) {
+ return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP
+ }
+
+ return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM
+ }
+
+ _detectNavbar() {
+ return this._element.closest(SELECTOR_NAVBAR) !== null
+ }
+
+ _getOffset() {
+ const { offset } = this._config
+
+ if (typeof offset === 'string') {
+ return offset.split(',').map(value => Number.parseInt(value, 10))
+ }
+
+ if (typeof offset === 'function') {
+ return popperData => offset(popperData, this._element)
+ }
+
+ return offset
+ }
+
+ _getPopperConfig() {
+ const defaultBsPopperConfig = {
+ placement: this._getPlacement(),
+ modifiers: [{
+ name: 'preventOverflow',
+ options: {
+ boundary: this._config.boundary
+ }
+ },
+ {
+ name: 'offset',
+ options: {
+ offset: this._getOffset()
+ }
+ }]
+ }
+
+ // Disable Popper if we have a static display or Dropdown is in Navbar
+ if (this._inNavbar || this._config.display === 'static') {
+ Manipulator.setDataAttribute(this._menu, 'popper', 'static') // todo:v6 remove
+ defaultBsPopperConfig.modifiers = [{
+ name: 'applyStyles',
+ enabled: false
+ }]
+ }
+
+ return {
+ ...defaultBsPopperConfig,
+ ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig)
+ }
+ }
+
+ _selectMenuItem({ key, target }) {
+ const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element))
+
+ if (!items.length) {
+ return
+ }
+
+ // if target isn't included in items (e.g. when expanding the dropdown)
+ // allow cycling to get the last item in case key equals ARROW_UP_KEY
+ getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Dropdown.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+
+ static clearMenus(event) {
+ if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) {
+ return
+ }
+
+ const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN)
+
+ for (const toggle of openToggles) {
+ const context = Dropdown.getInstance(toggle)
+ if (!context || context._config.autoClose === false) {
+ continue
+ }
+
+ const composedPath = event.composedPath()
+ const isMenuTarget = composedPath.includes(context._menu)
+ if (
+ composedPath.includes(context._element) ||
+ (context._config.autoClose === 'inside' && !isMenuTarget) ||
+ (context._config.autoClose === 'outside' && isMenuTarget)
+ ) {
+ continue
+ }
+
+ // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu
+ if (context._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName))) {
+ continue
+ }
+
+ const relatedTarget = { relatedTarget: context._element }
+
+ if (event.type === 'click') {
+ relatedTarget.clickEvent = event
+ }
+
+ context._completeHide(relatedTarget)
+ }
+ }
+
+ static dataApiKeydownHandler(event) {
+ // If not an UP | DOWN | ESCAPE key => not a dropdown command
+ // If input/textarea && if key is other than ESCAPE => not a dropdown command
+
+ const isInput = /input|textarea/i.test(event.target.tagName)
+ const isEscapeEvent = event.key === ESCAPE_KEY
+ const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key)
+
+ if (!isUpOrDownEvent && !isEscapeEvent) {
+ return
+ }
+
+ if (isInput && !isEscapeEvent) {
+ return
+ }
+
+ event.preventDefault()
+
+ // todo: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.2/forms/input-group/
+ const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?
+ this :
+ (SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] ||
+ SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||
+ SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode))
+
+ const instance = Dropdown.getOrCreateInstance(getToggleButton)
+
+ if (isUpOrDownEvent) {
+ event.stopPropagation()
+ instance.show()
+ instance._selectMenuItem(event)
+ return
+ }
+
+ if (instance._isShown()) { // else is escape and we check if it is shown
+ event.stopPropagation()
+ instance.hide()
+ getToggleButton.focus()
+ }
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Dropdown.dataApiKeydownHandler)
+EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler)
+EventHandler.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus)
+EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus)
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ event.preventDefault()
+ Dropdown.getOrCreateInstance(this).toggle()
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Dropdown)
+
+export default Dropdown
diff --git a/js/src/modal.js b/js/src/modal.js
new file mode 100644
index 0000000..26c7e8c
--- /dev/null
+++ b/js/src/modal.js
@@ -0,0 +1,377 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): modal.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, getElementFromSelector, isRTL, isVisible, reflow } from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import ScrollBarHelper from './util/scrollbar'
+import BaseComponent from './base-component'
+import Backdrop from './util/backdrop'
+import FocusTrap from './util/focustrap'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'modal'
+const DATA_KEY = 'bs.modal'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+const ESCAPE_KEY = 'Escape'
+
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_RESIZE = `resize${EVENT_KEY}`
+const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
+const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}`
+const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_OPEN = 'modal-open'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_STATIC = 'modal-static'
+
+const OPEN_SELECTOR = '.modal.show'
+const SELECTOR_DIALOG = '.modal-dialog'
+const SELECTOR_MODAL_BODY = '.modal-body'
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"]'
+
+const Default = {
+ backdrop: true,
+ focus: true,
+ keyboard: true
+}
+
+const DefaultType = {
+ backdrop: '(boolean|string)',
+ focus: 'boolean',
+ keyboard: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Modal extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)
+ this._backdrop = this._initializeBackDrop()
+ this._focustrap = this._initializeFocusTrap()
+ this._isShown = false
+ this._isTransitioning = false
+ this._scrollBar = new ScrollBarHelper()
+
+ this._addEventListeners()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle(relatedTarget) {
+ return this._isShown ? this.hide() : this.show(relatedTarget)
+ }
+
+ show(relatedTarget) {
+ if (this._isShown || this._isTransitioning) {
+ return
+ }
+
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, {
+ relatedTarget
+ })
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._isShown = true
+ this._isTransitioning = true
+
+ this._scrollBar.hide()
+
+ document.body.classList.add(CLASS_NAME_OPEN)
+
+ this._adjustDialog()
+
+ this._backdrop.show(() => this._showElement(relatedTarget))
+ }
+
+ hide() {
+ if (!this._isShown || this._isTransitioning) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ this._isShown = false
+ this._isTransitioning = true
+ this._focustrap.deactivate()
+
+ this._element.classList.remove(CLASS_NAME_SHOW)
+
+ this._queueCallback(() => this._hideModal(), this._element, this._isAnimated())
+ }
+
+ dispose() {
+ for (const htmlElement of [window, this._dialog]) {
+ EventHandler.off(htmlElement, EVENT_KEY)
+ }
+
+ this._backdrop.dispose()
+ this._focustrap.deactivate()
+ super.dispose()
+ }
+
+ handleUpdate() {
+ this._adjustDialog()
+ }
+
+ // Private
+ _initializeBackDrop() {
+ return new Backdrop({
+ isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value,
+ isAnimated: this._isAnimated()
+ })
+ }
+
+ _initializeFocusTrap() {
+ return new FocusTrap({
+ trapElement: this._element
+ })
+ }
+
+ _showElement(relatedTarget) {
+ // try to append dynamic modal
+ if (!document.body.contains(this._element)) {
+ document.body.append(this._element)
+ }
+
+ this._element.style.display = 'block'
+ this._element.removeAttribute('aria-hidden')
+ this._element.setAttribute('aria-modal', true)
+ this._element.setAttribute('role', 'dialog')
+ this._element.scrollTop = 0
+
+ const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog)
+ if (modalBody) {
+ modalBody.scrollTop = 0
+ }
+
+ reflow(this._element)
+
+ this._element.classList.add(CLASS_NAME_SHOW)
+
+ const transitionComplete = () => {
+ if (this._config.focus) {
+ this._focustrap.activate()
+ }
+
+ this._isTransitioning = false
+ EventHandler.trigger(this._element, EVENT_SHOWN, {
+ relatedTarget
+ })
+ }
+
+ this._queueCallback(transitionComplete, this._dialog, this._isAnimated())
+ }
+
+ _addEventListeners() {
+ EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
+ if (event.key !== ESCAPE_KEY) {
+ return
+ }
+
+ if (this._config.keyboard) {
+ event.preventDefault()
+ this.hide()
+ return
+ }
+
+ this._triggerBackdropTransition()
+ })
+
+ EventHandler.on(window, EVENT_RESIZE, () => {
+ if (this._isShown && !this._isTransitioning) {
+ this._adjustDialog()
+ }
+ })
+
+ EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => {
+ // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks
+ EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => {
+ if (this._element !== event.target || this._element !== event2.target) {
+ return
+ }
+
+ if (this._config.backdrop === 'static') {
+ this._triggerBackdropTransition()
+ return
+ }
+
+ if (this._config.backdrop) {
+ this.hide()
+ }
+ })
+ })
+ }
+
+ _hideModal() {
+ this._element.style.display = 'none'
+ this._element.setAttribute('aria-hidden', true)
+ this._element.removeAttribute('aria-modal')
+ this._element.removeAttribute('role')
+ this._isTransitioning = false
+
+ this._backdrop.hide(() => {
+ document.body.classList.remove(CLASS_NAME_OPEN)
+ this._resetAdjustments()
+ this._scrollBar.reset()
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ })
+ }
+
+ _isAnimated() {
+ return this._element.classList.contains(CLASS_NAME_FADE)
+ }
+
+ _triggerBackdropTransition() {
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight
+ const initialOverflowY = this._element.style.overflowY
+ // return if the following background transition hasn't yet completed
+ if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) {
+ return
+ }
+
+ if (!isModalOverflowing) {
+ this._element.style.overflowY = 'hidden'
+ }
+
+ this._element.classList.add(CLASS_NAME_STATIC)
+ this._queueCallback(() => {
+ this._element.classList.remove(CLASS_NAME_STATIC)
+ this._queueCallback(() => {
+ this._element.style.overflowY = initialOverflowY
+ }, this._dialog)
+ }, this._dialog)
+
+ this._element.focus()
+ }
+
+ /**
+ * The following methods are used to handle overflowing modals
+ */
+
+ _adjustDialog() {
+ const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight
+ const scrollbarWidth = this._scrollBar.getWidth()
+ const isBodyOverflowing = scrollbarWidth > 0
+
+ if (isBodyOverflowing && !isModalOverflowing) {
+ const property = isRTL() ? 'paddingLeft' : 'paddingRight'
+ this._element.style[property] = `${scrollbarWidth}px`
+ }
+
+ if (!isBodyOverflowing && isModalOverflowing) {
+ const property = isRTL() ? 'paddingRight' : 'paddingLeft'
+ this._element.style[property] = `${scrollbarWidth}px`
+ }
+ }
+
+ _resetAdjustments() {
+ this._element.style.paddingLeft = ''
+ this._element.style.paddingRight = ''
+ }
+
+ // Static
+ static jQueryInterface(config, relatedTarget) {
+ return this.each(function () {
+ const data = Modal.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](relatedTarget)
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ const target = getElementFromSelector(this)
+
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ EventHandler.one(target, EVENT_SHOW, showEvent => {
+ if (showEvent.defaultPrevented) {
+ // only register focus restorer if modal will actually get shown
+ return
+ }
+
+ EventHandler.one(target, EVENT_HIDDEN, () => {
+ if (isVisible(this)) {
+ this.focus()
+ }
+ })
+ })
+
+ // avoid conflict when clicking modal toggler while another one is open
+ const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)
+ if (alreadyOpen) {
+ Modal.getInstance(alreadyOpen).hide()
+ }
+
+ const data = Modal.getOrCreateInstance(target)
+
+ data.toggle(this)
+})
+
+enableDismissTrigger(Modal)
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Modal)
+
+export default Modal
diff --git a/js/src/offcanvas.js b/js/src/offcanvas.js
new file mode 100644
index 0000000..7dd06fd
--- /dev/null
+++ b/js/src/offcanvas.js
@@ -0,0 +1,283 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): offcanvas.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import {
+ defineJQueryPlugin,
+ getElementFromSelector,
+ isDisabled,
+ isVisible
+} from './util/index'
+import ScrollBarHelper from './util/scrollbar'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+import SelectorEngine from './dom/selector-engine'
+import Backdrop from './util/backdrop'
+import FocusTrap from './util/focustrap'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'offcanvas'
+const DATA_KEY = 'bs.offcanvas'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
+const ESCAPE_KEY = 'Escape'
+
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_SHOWING = 'showing'
+const CLASS_NAME_HIDING = 'hiding'
+const CLASS_NAME_BACKDROP = 'offcanvas-backdrop'
+const OPEN_SELECTOR = '.offcanvas.show'
+
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_RESIZE = `resize${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
+
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]'
+
+const Default = {
+ backdrop: true,
+ keyboard: true,
+ scroll: false
+}
+
+const DefaultType = {
+ backdrop: '(boolean|string)',
+ keyboard: 'boolean',
+ scroll: 'boolean'
+}
+
+/**
+ * Class definition
+ */
+
+class Offcanvas extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._isShown = false
+ this._backdrop = this._initializeBackDrop()
+ this._focustrap = this._initializeFocusTrap()
+ this._addEventListeners()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ toggle(relatedTarget) {
+ return this._isShown ? this.hide() : this.show(relatedTarget)
+ }
+
+ show(relatedTarget) {
+ if (this._isShown) {
+ return
+ }
+
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget })
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._isShown = true
+ this._backdrop.show()
+
+ if (!this._config.scroll) {
+ new ScrollBarHelper().hide()
+ }
+
+ this._element.setAttribute('aria-modal', true)
+ this._element.setAttribute('role', 'dialog')
+ this._element.classList.add(CLASS_NAME_SHOWING)
+
+ const completeCallBack = () => {
+ if (!this._config.scroll || this._config.backdrop) {
+ this._focustrap.activate()
+ }
+
+ this._element.classList.add(CLASS_NAME_SHOW)
+ this._element.classList.remove(CLASS_NAME_SHOWING)
+ EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
+ }
+
+ this._queueCallback(completeCallBack, this._element, true)
+ }
+
+ hide() {
+ if (!this._isShown) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ this._focustrap.deactivate()
+ this._element.blur()
+ this._isShown = false
+ this._element.classList.add(CLASS_NAME_HIDING)
+ this._backdrop.hide()
+
+ const completeCallback = () => {
+ this._element.classList.remove(CLASS_NAME_SHOW, CLASS_NAME_HIDING)
+ this._element.removeAttribute('aria-modal')
+ this._element.removeAttribute('role')
+
+ if (!this._config.scroll) {
+ new ScrollBarHelper().reset()
+ }
+
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ }
+
+ this._queueCallback(completeCallback, this._element, true)
+ }
+
+ dispose() {
+ this._backdrop.dispose()
+ this._focustrap.deactivate()
+ super.dispose()
+ }
+
+ // Private
+ _initializeBackDrop() {
+ const clickCallback = () => {
+ if (this._config.backdrop === 'static') {
+ EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
+ return
+ }
+
+ this.hide()
+ }
+
+ // 'static' option will be translated to true, and booleans will keep their value
+ const isVisible = Boolean(this._config.backdrop)
+
+ return new Backdrop({
+ className: CLASS_NAME_BACKDROP,
+ isVisible,
+ isAnimated: true,
+ rootElement: this._element.parentNode,
+ clickCallback: isVisible ? clickCallback : null
+ })
+ }
+
+ _initializeFocusTrap() {
+ return new FocusTrap({
+ trapElement: this._element
+ })
+ }
+
+ _addEventListeners() {
+ EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => {
+ if (event.key !== ESCAPE_KEY) {
+ return
+ }
+
+ if (!this._config.keyboard) {
+ EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
+ return
+ }
+
+ this.hide()
+ })
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Offcanvas.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](this)
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ const target = getElementFromSelector(this)
+
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ if (isDisabled(this)) {
+ return
+ }
+
+ EventHandler.one(target, EVENT_HIDDEN, () => {
+ // focus on trigger when it is closed
+ if (isVisible(this)) {
+ this.focus()
+ }
+ })
+
+ // avoid conflict when clicking a toggler of an offcanvas, while another is open
+ const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)
+ if (alreadyOpen && alreadyOpen !== target) {
+ Offcanvas.getInstance(alreadyOpen).hide()
+ }
+
+ const data = Offcanvas.getOrCreateInstance(target)
+ data.toggle(this)
+})
+
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ for (const selector of SelectorEngine.find(OPEN_SELECTOR)) {
+ Offcanvas.getOrCreateInstance(selector).show()
+ }
+})
+
+EventHandler.on(window, EVENT_RESIZE, () => {
+ for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) {
+ if (getComputedStyle(element).position !== 'fixed') {
+ Offcanvas.getOrCreateInstance(element).hide()
+ }
+ }
+})
+
+enableDismissTrigger(Offcanvas)
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Offcanvas)
+
+export default Offcanvas
diff --git a/js/src/popover.js b/js/src/popover.js
new file mode 100644
index 0000000..1b09dd4
--- /dev/null
+++ b/js/src/popover.js
@@ -0,0 +1,97 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): popover.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin } from './util/index'
+import Tooltip from './tooltip'
+
+/**
+ * Constants
+ */
+
+const NAME = 'popover'
+
+const SELECTOR_TITLE = '.popover-header'
+const SELECTOR_CONTENT = '.popover-body'
+
+const Default = {
+ ...Tooltip.Default,
+ content: '',
+ offset: [0, 8],
+ placement: 'right',
+ template: '<div class="popover" role="tooltip">' +
+ '<div class="popover-arrow"></div>' +
+ '<h3 class="popover-header"></h3>' +
+ '<div class="popover-body"></div>' +
+ '</div>',
+ trigger: 'click'
+}
+
+const DefaultType = {
+ ...Tooltip.DefaultType,
+ content: '(null|string|element|function)'
+}
+
+/**
+ * Class definition
+ */
+
+class Popover extends Tooltip {
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Overrides
+ _isWithContent() {
+ return this._getTitle() || this._getContent()
+ }
+
+ // Private
+ _getContentForTemplate() {
+ return {
+ [SELECTOR_TITLE]: this._getTitle(),
+ [SELECTOR_CONTENT]: this._getContent()
+ }
+ }
+
+ _getContent() {
+ return this._resolvePossibleFunction(this._config.content)
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Popover.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Popover)
+
+export default Popover
diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js
new file mode 100644
index 0000000..01aba99
--- /dev/null
+++ b/js/src/scrollspy.js
@@ -0,0 +1,294 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): scrollspy.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, getElement, isDisabled, isVisible } from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'scrollspy'
+const DATA_KEY = 'bs.scrollspy'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const EVENT_ACTIVATE = `activate${EVENT_KEY}`
+const EVENT_CLICK = `click${EVENT_KEY}`
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
+const CLASS_NAME_ACTIVE = 'active'
+
+const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'
+const SELECTOR_TARGET_LINKS = '[href]'
+const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
+const SELECTOR_NAV_LINKS = '.nav-link'
+const SELECTOR_NAV_ITEMS = '.nav-item'
+const SELECTOR_LIST_ITEMS = '.list-group-item'
+const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
+const SELECTOR_DROPDOWN = '.dropdown'
+const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
+
+const Default = {
+ offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons
+ rootMargin: '0px 0px -25%',
+ smoothScroll: false,
+ target: null,
+ threshold: [0.1, 0.5, 1]
+}
+
+const DefaultType = {
+ offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons
+ rootMargin: 'string',
+ smoothScroll: 'boolean',
+ target: 'element',
+ threshold: 'array'
+}
+
+/**
+ * Class definition
+ */
+
+class ScrollSpy extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ // this._element is the observablesContainer and config.target the menu links wrapper
+ this._targetLinks = new Map()
+ this._observableSections = new Map()
+ this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
+ this._activeTarget = null
+ this._observer = null
+ this._previousScrollData = {
+ visibleEntryTop: 0,
+ parentScrollTop: 0
+ }
+ this.refresh() // initialize
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ refresh() {
+ this._initializeTargetsAndObservables()
+ this._maybeEnableSmoothScroll()
+
+ if (this._observer) {
+ this._observer.disconnect()
+ } else {
+ this._observer = this._getNewObserver()
+ }
+
+ for (const section of this._observableSections.values()) {
+ this._observer.observe(section)
+ }
+ }
+
+ dispose() {
+ this._observer.disconnect()
+ super.dispose()
+ }
+
+ // Private
+ _configAfterMerge(config) {
+ // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case
+ config.target = getElement(config.target) || document.body
+
+ // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only
+ config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin
+
+ if (typeof config.threshold === 'string') {
+ config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value))
+ }
+
+ return config
+ }
+
+ _maybeEnableSmoothScroll() {
+ if (!this._config.smoothScroll) {
+ return
+ }
+
+ // unregister any previous listeners
+ EventHandler.off(this._config.target, EVENT_CLICK)
+
+ EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
+ const observableSection = this._observableSections.get(event.target.hash)
+ if (observableSection) {
+ event.preventDefault()
+ const root = this._rootElement || window
+ const height = observableSection.offsetTop - this._element.offsetTop
+ if (root.scrollTo) {
+ root.scrollTo({ top: height, behavior: 'smooth' })
+ return
+ }
+
+ // Chrome 60 doesn't support `scrollTo`
+ root.scrollTop = height
+ }
+ })
+ }
+
+ _getNewObserver() {
+ const options = {
+ root: this._rootElement,
+ threshold: this._config.threshold,
+ rootMargin: this._config.rootMargin
+ }
+
+ return new IntersectionObserver(entries => this._observerCallback(entries), options)
+ }
+
+ // The logic of selection
+ _observerCallback(entries) {
+ const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
+ const activate = entry => {
+ this._previousScrollData.visibleEntryTop = entry.target.offsetTop
+ this._process(targetElement(entry))
+ }
+
+ const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
+ const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
+ this._previousScrollData.parentScrollTop = parentScrollTop
+
+ for (const entry of entries) {
+ if (!entry.isIntersecting) {
+ this._activeTarget = null
+ this._clearActiveClass(targetElement(entry))
+
+ continue
+ }
+
+ const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop
+ // if we are scrolling down, pick the bigger offsetTop
+ if (userScrollsDown && entryIsLowerThanPrevious) {
+ activate(entry)
+ // if parent isn't scrolled, let's keep the first visible item, breaking the iteration
+ if (!parentScrollTop) {
+ return
+ }
+
+ continue
+ }
+
+ // if we are scrolling up, pick the smallest offsetTop
+ if (!userScrollsDown && !entryIsLowerThanPrevious) {
+ activate(entry)
+ }
+ }
+ }
+
+ _initializeTargetsAndObservables() {
+ this._targetLinks = new Map()
+ this._observableSections = new Map()
+
+ const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
+
+ for (const anchor of targetLinks) {
+ // ensure that the anchor has an id and is not disabled
+ if (!anchor.hash || isDisabled(anchor)) {
+ continue
+ }
+
+ const observableSection = SelectorEngine.findOne(anchor.hash, this._element)
+
+ // ensure that the observableSection exists & is visible
+ if (isVisible(observableSection)) {
+ this._targetLinks.set(anchor.hash, anchor)
+ this._observableSections.set(anchor.hash, observableSection)
+ }
+ }
+ }
+
+ _process(target) {
+ if (this._activeTarget === target) {
+ return
+ }
+
+ this._clearActiveClass(this._config.target)
+ this._activeTarget = target
+ target.classList.add(CLASS_NAME_ACTIVE)
+ this._activateParents(target)
+
+ EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
+ }
+
+ _activateParents(target) {
+ // Activate dropdown parents
+ if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
+ SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))
+ .classList.add(CLASS_NAME_ACTIVE)
+ return
+ }
+
+ for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
+ // Set triggered links parents as active
+ // With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
+ for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
+ item.classList.add(CLASS_NAME_ACTIVE)
+ }
+ }
+ }
+
+ _clearActiveClass(parent) {
+ parent.classList.remove(CLASS_NAME_ACTIVE)
+
+ const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
+ for (const node of activeNodes) {
+ node.classList.remove(CLASS_NAME_ACTIVE)
+ }
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = ScrollSpy.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
+ ScrollSpy.getOrCreateInstance(spy)
+ }
+})
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(ScrollSpy)
+
+export default ScrollSpy
diff --git a/js/src/tab.js b/js/src/tab.js
new file mode 100644
index 0000000..8dc4644
--- /dev/null
+++ b/js/src/tab.js
@@ -0,0 +1,305 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): tab.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, getElementFromSelector, getNextActiveElement, isDisabled } from './util/index'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+import BaseComponent from './base-component'
+
+/**
+ * Constants
+ */
+
+const NAME = 'tab'
+const DATA_KEY = 'bs.tab'
+const EVENT_KEY = `.${DATA_KEY}`
+
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}`
+const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
+const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
+
+const ARROW_LEFT_KEY = 'ArrowLeft'
+const ARROW_RIGHT_KEY = 'ArrowRight'
+const ARROW_UP_KEY = 'ArrowUp'
+const ARROW_DOWN_KEY = 'ArrowDown'
+
+const CLASS_NAME_ACTIVE = 'active'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+const CLASS_DROPDOWN = 'dropdown'
+
+const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
+const SELECTOR_DROPDOWN_MENU = '.dropdown-menu'
+const NOT_SELECTOR_DROPDOWN_TOGGLE = ':not(.dropdown-toggle)'
+
+const SELECTOR_TAB_PANEL = '.list-group, .nav, [role="tablist"]'
+const SELECTOR_OUTER = '.nav-item, .list-group-item'
+const SELECTOR_INNER = `.nav-link${NOT_SELECTOR_DROPDOWN_TOGGLE}, .list-group-item${NOT_SELECTOR_DROPDOWN_TOGGLE}, [role="tab"]${NOT_SELECTOR_DROPDOWN_TOGGLE}`
+const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]' // todo:v6: could be only `tab`
+const SELECTOR_INNER_ELEM = `${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`
+
+const SELECTOR_DATA_TOGGLE_ACTIVE = `.${CLASS_NAME_ACTIVE}[data-bs-toggle="tab"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="pill"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="list"]`
+
+/**
+ * Class definition
+ */
+
+class Tab extends BaseComponent {
+ constructor(element) {
+ super(element)
+ this._parent = this._element.closest(SELECTOR_TAB_PANEL)
+
+ if (!this._parent) {
+ return
+ // todo: should Throw exception on v6
+ // throw new TypeError(`${element.outerHTML} has not a valid parent ${SELECTOR_INNER_ELEM}`)
+ }
+
+ // Set up initial aria attributes
+ this._setInitialAttributes(this._parent, this._getChildren())
+
+ EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
+ }
+
+ // Getters
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ show() { // Shows this elem and deactivate the active sibling if exists
+ const innerElem = this._element
+ if (this._elemIsActive(innerElem)) {
+ return
+ }
+
+ // Search for active tab on same parent to deactivate it
+ const active = this._getActiveElem()
+
+ const hideEvent = active ?
+ EventHandler.trigger(active, EVENT_HIDE, { relatedTarget: innerElem }) :
+ null
+
+ const showEvent = EventHandler.trigger(innerElem, EVENT_SHOW, { relatedTarget: active })
+
+ if (showEvent.defaultPrevented || (hideEvent && hideEvent.defaultPrevented)) {
+ return
+ }
+
+ this._deactivate(active, innerElem)
+ this._activate(innerElem, active)
+ }
+
+ // Private
+ _activate(element, relatedElem) {
+ if (!element) {
+ return
+ }
+
+ element.classList.add(CLASS_NAME_ACTIVE)
+
+ this._activate(getElementFromSelector(element)) // Search and activate/show the proper section
+
+ const complete = () => {
+ if (element.getAttribute('role') !== 'tab') {
+ element.classList.add(CLASS_NAME_SHOW)
+ return
+ }
+
+ element.removeAttribute('tabindex')
+ element.setAttribute('aria-selected', true)
+ this._toggleDropDown(element, true)
+ EventHandler.trigger(element, EVENT_SHOWN, {
+ relatedTarget: relatedElem
+ })
+ }
+
+ this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
+ }
+
+ _deactivate(element, relatedElem) {
+ if (!element) {
+ return
+ }
+
+ element.classList.remove(CLASS_NAME_ACTIVE)
+ element.blur()
+
+ this._deactivate(getElementFromSelector(element)) // Search and deactivate the shown section too
+
+ const complete = () => {
+ if (element.getAttribute('role') !== 'tab') {
+ element.classList.remove(CLASS_NAME_SHOW)
+ return
+ }
+
+ element.setAttribute('aria-selected', false)
+ element.setAttribute('tabindex', '-1')
+ this._toggleDropDown(element, false)
+ EventHandler.trigger(element, EVENT_HIDDEN, { relatedTarget: relatedElem })
+ }
+
+ this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
+ }
+
+ _keydown(event) {
+ if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key))) {
+ return
+ }
+
+ event.stopPropagation()// stopPropagation/preventDefault both added to support up/down keys without scrolling the page
+ event.preventDefault()
+ const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
+ const nextActiveElement = getNextActiveElement(this._getChildren().filter(element => !isDisabled(element)), event.target, isNext, true)
+
+ if (nextActiveElement) {
+ nextActiveElement.focus({ preventScroll: true })
+ Tab.getOrCreateInstance(nextActiveElement).show()
+ }
+ }
+
+ _getChildren() { // collection of inner elements
+ return SelectorEngine.find(SELECTOR_INNER_ELEM, this._parent)
+ }
+
+ _getActiveElem() {
+ return this._getChildren().find(child => this._elemIsActive(child)) || null
+ }
+
+ _setInitialAttributes(parent, children) {
+ this._setAttributeIfNotExists(parent, 'role', 'tablist')
+
+ for (const child of children) {
+ this._setInitialAttributesOnChild(child)
+ }
+ }
+
+ _setInitialAttributesOnChild(child) {
+ child = this._getInnerElement(child)
+ const isActive = this._elemIsActive(child)
+ const outerElem = this._getOuterElement(child)
+ child.setAttribute('aria-selected', isActive)
+
+ if (outerElem !== child) {
+ this._setAttributeIfNotExists(outerElem, 'role', 'presentation')
+ }
+
+ if (!isActive) {
+ child.setAttribute('tabindex', '-1')
+ }
+
+ this._setAttributeIfNotExists(child, 'role', 'tab')
+
+ // set attributes to the related panel too
+ this._setInitialAttributesOnTargetPanel(child)
+ }
+
+ _setInitialAttributesOnTargetPanel(child) {
+ const target = getElementFromSelector(child)
+
+ if (!target) {
+ return
+ }
+
+ this._setAttributeIfNotExists(target, 'role', 'tabpanel')
+
+ if (child.id) {
+ this._setAttributeIfNotExists(target, 'aria-labelledby', `#${child.id}`)
+ }
+ }
+
+ _toggleDropDown(element, open) {
+ const outerElem = this._getOuterElement(element)
+ if (!outerElem.classList.contains(CLASS_DROPDOWN)) {
+ return
+ }
+
+ const toggle = (selector, className) => {
+ const element = SelectorEngine.findOne(selector, outerElem)
+ if (element) {
+ element.classList.toggle(className, open)
+ }
+ }
+
+ toggle(SELECTOR_DROPDOWN_TOGGLE, CLASS_NAME_ACTIVE)
+ toggle(SELECTOR_DROPDOWN_MENU, CLASS_NAME_SHOW)
+ outerElem.setAttribute('aria-expanded', open)
+ }
+
+ _setAttributeIfNotExists(element, attribute, value) {
+ if (!element.hasAttribute(attribute)) {
+ element.setAttribute(attribute, value)
+ }
+ }
+
+ _elemIsActive(elem) {
+ return elem.classList.contains(CLASS_NAME_ACTIVE)
+ }
+
+ // Try to get the inner element (usually the .nav-link)
+ _getInnerElement(elem) {
+ return elem.matches(SELECTOR_INNER_ELEM) ? elem : SelectorEngine.findOne(SELECTOR_INNER_ELEM, elem)
+ }
+
+ // Try to get the outer element (usually the .nav-item)
+ _getOuterElement(elem) {
+ return elem.closest(SELECTOR_OUTER) || elem
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Tab.getOrCreateInstance(this)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ if (isDisabled(this)) {
+ return
+ }
+
+ Tab.getOrCreateInstance(this).show()
+})
+
+/**
+ * Initialize on focus
+ */
+EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
+ for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE)) {
+ Tab.getOrCreateInstance(element)
+ }
+})
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Tab)
+
+export default Tab
diff --git a/js/src/toast.js b/js/src/toast.js
new file mode 100644
index 0000000..a7fe775
--- /dev/null
+++ b/js/src/toast.js
@@ -0,0 +1,225 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): toast.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { defineJQueryPlugin, reflow } from './util/index'
+import EventHandler from './dom/event-handler'
+import BaseComponent from './base-component'
+import { enableDismissTrigger } from './util/component-functions'
+
+/**
+ * Constants
+ */
+
+const NAME = 'toast'
+const DATA_KEY = 'bs.toast'
+const EVENT_KEY = `.${DATA_KEY}`
+
+const EVENT_MOUSEOVER = `mouseover${EVENT_KEY}`
+const EVENT_MOUSEOUT = `mouseout${EVENT_KEY}`
+const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
+const EVENT_FOCUSOUT = `focusout${EVENT_KEY}`
+const EVENT_HIDE = `hide${EVENT_KEY}`
+const EVENT_HIDDEN = `hidden${EVENT_KEY}`
+const EVENT_SHOW = `show${EVENT_KEY}`
+const EVENT_SHOWN = `shown${EVENT_KEY}`
+
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_HIDE = 'hide' // @deprecated - kept here only for backwards compatibility
+const CLASS_NAME_SHOW = 'show'
+const CLASS_NAME_SHOWING = 'showing'
+
+const DefaultType = {
+ animation: 'boolean',
+ autohide: 'boolean',
+ delay: 'number'
+}
+
+const Default = {
+ animation: true,
+ autohide: true,
+ delay: 5000
+}
+
+/**
+ * Class definition
+ */
+
+class Toast extends BaseComponent {
+ constructor(element, config) {
+ super(element, config)
+
+ this._timeout = null
+ this._hasMouseInteraction = false
+ this._hasKeyboardInteraction = false
+ this._setListeners()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ show() {
+ const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
+
+ if (showEvent.defaultPrevented) {
+ return
+ }
+
+ this._clearTimeout()
+
+ if (this._config.animation) {
+ this._element.classList.add(CLASS_NAME_FADE)
+ }
+
+ const complete = () => {
+ this._element.classList.remove(CLASS_NAME_SHOWING)
+ EventHandler.trigger(this._element, EVENT_SHOWN)
+
+ this._maybeScheduleHide()
+ }
+
+ this._element.classList.remove(CLASS_NAME_HIDE) // @deprecated
+ reflow(this._element)
+ this._element.classList.add(CLASS_NAME_SHOW, CLASS_NAME_SHOWING)
+
+ this._queueCallback(complete, this._element, this._config.animation)
+ }
+
+ hide() {
+ if (!this.isShown()) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
+
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ const complete = () => {
+ this._element.classList.add(CLASS_NAME_HIDE) // @deprecated
+ this._element.classList.remove(CLASS_NAME_SHOWING, CLASS_NAME_SHOW)
+ EventHandler.trigger(this._element, EVENT_HIDDEN)
+ }
+
+ this._element.classList.add(CLASS_NAME_SHOWING)
+ this._queueCallback(complete, this._element, this._config.animation)
+ }
+
+ dispose() {
+ this._clearTimeout()
+
+ if (this.isShown()) {
+ this._element.classList.remove(CLASS_NAME_SHOW)
+ }
+
+ super.dispose()
+ }
+
+ isShown() {
+ return this._element.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ // Private
+
+ _maybeScheduleHide() {
+ if (!this._config.autohide) {
+ return
+ }
+
+ if (this._hasMouseInteraction || this._hasKeyboardInteraction) {
+ return
+ }
+
+ this._timeout = setTimeout(() => {
+ this.hide()
+ }, this._config.delay)
+ }
+
+ _onInteraction(event, isInteracting) {
+ switch (event.type) {
+ case 'mouseover':
+ case 'mouseout': {
+ this._hasMouseInteraction = isInteracting
+ break
+ }
+
+ case 'focusin':
+ case 'focusout': {
+ this._hasKeyboardInteraction = isInteracting
+ break
+ }
+
+ default: {
+ break
+ }
+ }
+
+ if (isInteracting) {
+ this._clearTimeout()
+ return
+ }
+
+ const nextElement = event.relatedTarget
+ if (this._element === nextElement || this._element.contains(nextElement)) {
+ return
+ }
+
+ this._maybeScheduleHide()
+ }
+
+ _setListeners() {
+ EventHandler.on(this._element, EVENT_MOUSEOVER, event => this._onInteraction(event, true))
+ EventHandler.on(this._element, EVENT_MOUSEOUT, event => this._onInteraction(event, false))
+ EventHandler.on(this._element, EVENT_FOCUSIN, event => this._onInteraction(event, true))
+ EventHandler.on(this._element, EVENT_FOCUSOUT, event => this._onInteraction(event, false))
+ }
+
+ _clearTimeout() {
+ clearTimeout(this._timeout)
+ this._timeout = null
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Toast.getOrCreateInstance(this, config)
+
+ if (typeof config === 'string') {
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config](this)
+ }
+ })
+ }
+}
+
+/**
+ * Data API implementation
+ */
+
+enableDismissTrigger(Toast)
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Toast)
+
+export default Toast
diff --git a/js/src/tooltip.js b/js/src/tooltip.js
new file mode 100644
index 0000000..748a0e1
--- /dev/null
+++ b/js/src/tooltip.js
@@ -0,0 +1,633 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): tooltip.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import * as Popper from '@popperjs/core'
+import { defineJQueryPlugin, findShadowRoot, getElement, getUID, isRTL, noop } from './util/index'
+import { DefaultAllowlist } from './util/sanitizer'
+import EventHandler from './dom/event-handler'
+import Manipulator from './dom/manipulator'
+import BaseComponent from './base-component'
+import TemplateFactory from './util/template-factory'
+
+/**
+ * Constants
+ */
+
+const NAME = 'tooltip'
+const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
+
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_MODAL = 'modal'
+const CLASS_NAME_SHOW = 'show'
+
+const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
+const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
+
+const EVENT_MODAL_HIDE = 'hide.bs.modal'
+
+const TRIGGER_HOVER = 'hover'
+const TRIGGER_FOCUS = 'focus'
+const TRIGGER_CLICK = 'click'
+const TRIGGER_MANUAL = 'manual'
+
+const EVENT_HIDE = 'hide'
+const EVENT_HIDDEN = 'hidden'
+const EVENT_SHOW = 'show'
+const EVENT_SHOWN = 'shown'
+const EVENT_INSERTED = 'inserted'
+const EVENT_CLICK = 'click'
+const EVENT_FOCUSIN = 'focusin'
+const EVENT_FOCUSOUT = 'focusout'
+const EVENT_MOUSEENTER = 'mouseenter'
+const EVENT_MOUSELEAVE = 'mouseleave'
+
+const AttachmentMap = {
+ AUTO: 'auto',
+ TOP: 'top',
+ RIGHT: isRTL() ? 'left' : 'right',
+ BOTTOM: 'bottom',
+ LEFT: isRTL() ? 'right' : 'left'
+}
+
+const Default = {
+ allowList: DefaultAllowlist,
+ animation: true,
+ boundary: 'clippingParents',
+ container: false,
+ customClass: '',
+ delay: 0,
+ fallbackPlacements: ['top', 'right', 'bottom', 'left'],
+ html: false,
+ offset: [0, 0],
+ placement: 'top',
+ popperConfig: null,
+ sanitize: true,
+ sanitizeFn: null,
+ selector: false,
+ template: '<div class="tooltip" role="tooltip">' +
+ '<div class="tooltip-arrow"></div>' +
+ '<div class="tooltip-inner"></div>' +
+ '</div>',
+ title: '',
+ trigger: 'hover focus'
+}
+
+const DefaultType = {
+ allowList: 'object',
+ animation: 'boolean',
+ boundary: '(string|element)',
+ container: '(string|element|boolean)',
+ customClass: '(string|function)',
+ delay: '(number|object)',
+ fallbackPlacements: 'array',
+ html: 'boolean',
+ offset: '(array|string|function)',
+ placement: '(string|function)',
+ popperConfig: '(null|object|function)',
+ sanitize: 'boolean',
+ sanitizeFn: '(null|function)',
+ selector: '(string|boolean)',
+ template: 'string',
+ title: '(string|element|function)',
+ trigger: 'string'
+}
+
+/**
+ * Class definition
+ */
+
+class Tooltip extends BaseComponent {
+ constructor(element, config) {
+ if (typeof Popper === 'undefined') {
+ throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)')
+ }
+
+ super(element, config)
+
+ // Private
+ this._isEnabled = true
+ this._timeout = 0
+ this._isHovered = null
+ this._activeTrigger = {}
+ this._popper = null
+ this._templateFactory = null
+ this._newContent = null
+
+ // Protected
+ this.tip = null
+
+ this._setListeners()
+
+ if (!this._config.selector) {
+ this._fixTitle()
+ }
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ enable() {
+ this._isEnabled = true
+ }
+
+ disable() {
+ this._isEnabled = false
+ }
+
+ toggleEnabled() {
+ this._isEnabled = !this._isEnabled
+ }
+
+ toggle() {
+ if (!this._isEnabled) {
+ return
+ }
+
+ this._activeTrigger.click = !this._activeTrigger.click
+ if (this._isShown()) {
+ this._leave()
+ return
+ }
+
+ this._enter()
+ }
+
+ dispose() {
+ clearTimeout(this._timeout)
+
+ EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
+
+ if (this._element.getAttribute('data-bs-original-title')) {
+ this._element.setAttribute('title', this._element.getAttribute('data-bs-original-title'))
+ }
+
+ this._disposePopper()
+ super.dispose()
+ }
+
+ show() {
+ if (this._element.style.display === 'none') {
+ throw new Error('Please use show on visible elements')
+ }
+
+ if (!(this._isWithContent() && this._isEnabled)) {
+ return
+ }
+
+ const showEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOW))
+ const shadowRoot = findShadowRoot(this._element)
+ const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
+
+ if (showEvent.defaultPrevented || !isInTheDom) {
+ return
+ }
+
+ // todo v6 remove this OR make it optional
+ this._disposePopper()
+
+ const tip = this._getTipElement()
+
+ this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
+
+ const { container } = this._config
+
+ if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
+ container.append(tip)
+ EventHandler.trigger(this._element, this.constructor.eventName(EVENT_INSERTED))
+ }
+
+ this._popper = this._createPopper(tip)
+
+ tip.classList.add(CLASS_NAME_SHOW)
+
+ // If this is a touch-enabled device we add extra
+ // empty mouseover listeners to the body's immediate children;
+ // only needed because of broken event delegation on iOS
+ // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
+ if ('ontouchstart' in document.documentElement) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.on(element, 'mouseover', noop)
+ }
+ }
+
+ const complete = () => {
+ EventHandler.trigger(this._element, this.constructor.eventName(EVENT_SHOWN))
+
+ if (this._isHovered === false) {
+ this._leave()
+ }
+
+ this._isHovered = false
+ }
+
+ this._queueCallback(complete, this.tip, this._isAnimated())
+ }
+
+ hide() {
+ if (!this._isShown()) {
+ return
+ }
+
+ const hideEvent = EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDE))
+ if (hideEvent.defaultPrevented) {
+ return
+ }
+
+ const tip = this._getTipElement()
+ tip.classList.remove(CLASS_NAME_SHOW)
+
+ // If this is a touch-enabled device we remove the extra
+ // empty mouseover listeners we added for iOS support
+ if ('ontouchstart' in document.documentElement) {
+ for (const element of [].concat(...document.body.children)) {
+ EventHandler.off(element, 'mouseover', noop)
+ }
+ }
+
+ this._activeTrigger[TRIGGER_CLICK] = false
+ this._activeTrigger[TRIGGER_FOCUS] = false
+ this._activeTrigger[TRIGGER_HOVER] = false
+ this._isHovered = null // it is a trick to support manual triggering
+
+ const complete = () => {
+ if (this._isWithActiveTrigger()) {
+ return
+ }
+
+ if (!this._isHovered) {
+ this._disposePopper()
+ }
+
+ this._element.removeAttribute('aria-describedby')
+ EventHandler.trigger(this._element, this.constructor.eventName(EVENT_HIDDEN))
+ }
+
+ this._queueCallback(complete, this.tip, this._isAnimated())
+ }
+
+ update() {
+ if (this._popper) {
+ this._popper.update()
+ }
+ }
+
+ // Protected
+ _isWithContent() {
+ return Boolean(this._getTitle())
+ }
+
+ _getTipElement() {
+ if (!this.tip) {
+ this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
+ }
+
+ return this.tip
+ }
+
+ _createTipElement(content) {
+ const tip = this._getTemplateFactory(content).toHtml()
+
+ // todo: remove this check on v6
+ if (!tip) {
+ return null
+ }
+
+ tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
+ // todo: on v6 the following can be achieved with CSS only
+ tip.classList.add(`bs-${this.constructor.NAME}-auto`)
+
+ const tipId = getUID(this.constructor.NAME).toString()
+
+ tip.setAttribute('id', tipId)
+
+ if (this._isAnimated()) {
+ tip.classList.add(CLASS_NAME_FADE)
+ }
+
+ return tip
+ }
+
+ setContent(content) {
+ this._newContent = content
+ if (this._isShown()) {
+ this._disposePopper()
+ this.show()
+ }
+ }
+
+ _getTemplateFactory(content) {
+ if (this._templateFactory) {
+ this._templateFactory.changeContent(content)
+ } else {
+ this._templateFactory = new TemplateFactory({
+ ...this._config,
+ // the `content` var has to be after `this._config`
+ // to override config.content in case of popover
+ content,
+ extraClass: this._resolvePossibleFunction(this._config.customClass)
+ })
+ }
+
+ return this._templateFactory
+ }
+
+ _getContentForTemplate() {
+ return {
+ [SELECTOR_TOOLTIP_INNER]: this._getTitle()
+ }
+ }
+
+ _getTitle() {
+ return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title')
+ }
+
+ // Private
+ _initializeOnDelegatedTarget(event) {
+ return this.constructor.getOrCreateInstance(event.delegateTarget, this._getDelegateConfig())
+ }
+
+ _isAnimated() {
+ return this._config.animation || (this.tip && this.tip.classList.contains(CLASS_NAME_FADE))
+ }
+
+ _isShown() {
+ return this.tip && this.tip.classList.contains(CLASS_NAME_SHOW)
+ }
+
+ _createPopper(tip) {
+ const placement = typeof this._config.placement === 'function' ?
+ this._config.placement.call(this, tip, this._element) :
+ this._config.placement
+ const attachment = AttachmentMap[placement.toUpperCase()]
+ return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
+ }
+
+ _getOffset() {
+ const { offset } = this._config
+
+ if (typeof offset === 'string') {
+ return offset.split(',').map(value => Number.parseInt(value, 10))
+ }
+
+ if (typeof offset === 'function') {
+ return popperData => offset(popperData, this._element)
+ }
+
+ return offset
+ }
+
+ _resolvePossibleFunction(arg) {
+ return typeof arg === 'function' ? arg.call(this._element) : arg
+ }
+
+ _getPopperConfig(attachment) {
+ const defaultBsPopperConfig = {
+ placement: attachment,
+ modifiers: [
+ {
+ name: 'flip',
+ options: {
+ fallbackPlacements: this._config.fallbackPlacements
+ }
+ },
+ {
+ name: 'offset',
+ options: {
+ offset: this._getOffset()
+ }
+ },
+ {
+ name: 'preventOverflow',
+ options: {
+ boundary: this._config.boundary
+ }
+ },
+ {
+ name: 'arrow',
+ options: {
+ element: `.${this.constructor.NAME}-arrow`
+ }
+ },
+ {
+ name: 'preSetPlacement',
+ enabled: true,
+ phase: 'beforeMain',
+ fn: data => {
+ // Pre-set Popper's placement attribute in order to read the arrow sizes properly.
+ // Otherwise, Popper mixes up the width and height dimensions since the initial arrow style is for top placement
+ this._getTipElement().setAttribute('data-popper-placement', data.state.placement)
+ }
+ }
+ ]
+ }
+
+ return {
+ ...defaultBsPopperConfig,
+ ...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig)
+ }
+ }
+
+ _setListeners() {
+ const triggers = this._config.trigger.split(' ')
+
+ for (const trigger of triggers) {
+ if (trigger === 'click') {
+ EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK), this._config.selector, event => {
+ const context = this._initializeOnDelegatedTarget(event)
+ context.toggle()
+ })
+ } else if (trigger !== TRIGGER_MANUAL) {
+ const eventIn = trigger === TRIGGER_HOVER ?
+ this.constructor.eventName(EVENT_MOUSEENTER) :
+ this.constructor.eventName(EVENT_FOCUSIN)
+ const eventOut = trigger === TRIGGER_HOVER ?
+ this.constructor.eventName(EVENT_MOUSELEAVE) :
+ this.constructor.eventName(EVENT_FOCUSOUT)
+
+ EventHandler.on(this._element, eventIn, this._config.selector, event => {
+ const context = this._initializeOnDelegatedTarget(event)
+ context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
+ context._enter()
+ })
+ EventHandler.on(this._element, eventOut, this._config.selector, event => {
+ const context = this._initializeOnDelegatedTarget(event)
+ context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
+ context._element.contains(event.relatedTarget)
+
+ context._leave()
+ })
+ }
+ }
+
+ this._hideModalHandler = () => {
+ if (this._element) {
+ this.hide()
+ }
+ }
+
+ EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
+ }
+
+ _fixTitle() {
+ const title = this._element.getAttribute('title')
+
+ if (!title) {
+ return
+ }
+
+ if (!this._element.getAttribute('aria-label') && !this._element.textContent.trim()) {
+ this._element.setAttribute('aria-label', title)
+ }
+
+ this._element.setAttribute('data-bs-original-title', title) // DO NOT USE IT. Is only for backwards compatibility
+ this._element.removeAttribute('title')
+ }
+
+ _enter() {
+ if (this._isShown() || this._isHovered) {
+ this._isHovered = true
+ return
+ }
+
+ this._isHovered = true
+
+ this._setTimeout(() => {
+ if (this._isHovered) {
+ this.show()
+ }
+ }, this._config.delay.show)
+ }
+
+ _leave() {
+ if (this._isWithActiveTrigger()) {
+ return
+ }
+
+ this._isHovered = false
+
+ this._setTimeout(() => {
+ if (!this._isHovered) {
+ this.hide()
+ }
+ }, this._config.delay.hide)
+ }
+
+ _setTimeout(handler, timeout) {
+ clearTimeout(this._timeout)
+ this._timeout = setTimeout(handler, timeout)
+ }
+
+ _isWithActiveTrigger() {
+ return Object.values(this._activeTrigger).includes(true)
+ }
+
+ _getConfig(config) {
+ const dataAttributes = Manipulator.getDataAttributes(this._element)
+
+ for (const dataAttribute of Object.keys(dataAttributes)) {
+ if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
+ delete dataAttributes[dataAttribute]
+ }
+ }
+
+ config = {
+ ...dataAttributes,
+ ...(typeof config === 'object' && config ? config : {})
+ }
+ config = this._mergeConfigObj(config)
+ config = this._configAfterMerge(config)
+ this._typeCheckConfig(config)
+ return config
+ }
+
+ _configAfterMerge(config) {
+ config.container = config.container === false ? document.body : getElement(config.container)
+
+ if (typeof config.delay === 'number') {
+ config.delay = {
+ show: config.delay,
+ hide: config.delay
+ }
+ }
+
+ if (typeof config.title === 'number') {
+ config.title = config.title.toString()
+ }
+
+ if (typeof config.content === 'number') {
+ config.content = config.content.toString()
+ }
+
+ return config
+ }
+
+ _getDelegateConfig() {
+ const config = {}
+
+ for (const key in this._config) {
+ if (this.constructor.Default[key] !== this._config[key]) {
+ config[key] = this._config[key]
+ }
+ }
+
+ config.selector = false
+ config.trigger = 'manual'
+
+ // In the future can be replaced with:
+ // const keysWithDifferentValues = Object.entries(this._config).filter(entry => this.constructor.Default[entry[0]] !== this._config[entry[0]])
+ // `Object.fromEntries(keysWithDifferentValues)`
+ return config
+ }
+
+ _disposePopper() {
+ if (this._popper) {
+ this._popper.destroy()
+ this._popper = null
+ }
+
+ if (this.tip) {
+ this.tip.remove()
+ this.tip = null
+ }
+ }
+
+ // Static
+ static jQueryInterface(config) {
+ return this.each(function () {
+ const data = Tooltip.getOrCreateInstance(this, config)
+
+ if (typeof config !== 'string') {
+ return
+ }
+
+ if (typeof data[config] === 'undefined') {
+ throw new TypeError(`No method named "${config}"`)
+ }
+
+ data[config]()
+ })
+ }
+}
+
+/**
+ * jQuery
+ */
+
+defineJQueryPlugin(Tooltip)
+
+export default Tooltip
diff --git a/js/src/util/backdrop.js b/js/src/util/backdrop.js
new file mode 100644
index 0000000..78279e0
--- /dev/null
+++ b/js/src/util/backdrop.js
@@ -0,0 +1,149 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/backdrop.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import EventHandler from '../dom/event-handler'
+import { execute, executeAfterTransition, getElement, reflow } from './index'
+import Config from './config'
+
+/**
+ * Constants
+ */
+
+const NAME = 'backdrop'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}`
+
+const Default = {
+ className: 'modal-backdrop',
+ clickCallback: null,
+ isAnimated: false,
+ isVisible: true, // if false, we use the backdrop helper without adding any element to the dom
+ rootElement: 'body' // give the choice to place backdrop under different elements
+}
+
+const DefaultType = {
+ className: 'string',
+ clickCallback: '(function|null)',
+ isAnimated: 'boolean',
+ isVisible: 'boolean',
+ rootElement: '(element|string)'
+}
+
+/**
+ * Class definition
+ */
+
+class Backdrop extends Config {
+ constructor(config) {
+ super()
+ this._config = this._getConfig(config)
+ this._isAppended = false
+ this._element = null
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ show(callback) {
+ if (!this._config.isVisible) {
+ execute(callback)
+ return
+ }
+
+ this._append()
+
+ const element = this._getElement()
+ if (this._config.isAnimated) {
+ reflow(element)
+ }
+
+ element.classList.add(CLASS_NAME_SHOW)
+
+ this._emulateAnimation(() => {
+ execute(callback)
+ })
+ }
+
+ hide(callback) {
+ if (!this._config.isVisible) {
+ execute(callback)
+ return
+ }
+
+ this._getElement().classList.remove(CLASS_NAME_SHOW)
+
+ this._emulateAnimation(() => {
+ this.dispose()
+ execute(callback)
+ })
+ }
+
+ dispose() {
+ if (!this._isAppended) {
+ return
+ }
+
+ EventHandler.off(this._element, EVENT_MOUSEDOWN)
+
+ this._element.remove()
+ this._isAppended = false
+ }
+
+ // Private
+ _getElement() {
+ if (!this._element) {
+ const backdrop = document.createElement('div')
+ backdrop.className = this._config.className
+ if (this._config.isAnimated) {
+ backdrop.classList.add(CLASS_NAME_FADE)
+ }
+
+ this._element = backdrop
+ }
+
+ return this._element
+ }
+
+ _configAfterMerge(config) {
+ // use getElement() with the default "body" to get a fresh Element on each instantiation
+ config.rootElement = getElement(config.rootElement)
+ return config
+ }
+
+ _append() {
+ if (this._isAppended) {
+ return
+ }
+
+ const element = this._getElement()
+ this._config.rootElement.append(element)
+
+ EventHandler.on(element, EVENT_MOUSEDOWN, () => {
+ execute(this._config.clickCallback)
+ })
+
+ this._isAppended = true
+ }
+
+ _emulateAnimation(callback) {
+ executeAfterTransition(callback, this._getElement(), this._config.isAnimated)
+ }
+}
+
+export default Backdrop
diff --git a/js/src/util/component-functions.js b/js/src/util/component-functions.js
new file mode 100644
index 0000000..c2f99cc
--- /dev/null
+++ b/js/src/util/component-functions.js
@@ -0,0 +1,34 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/component-functions.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import EventHandler from '../dom/event-handler'
+import { getElementFromSelector, isDisabled } from './index'
+
+const enableDismissTrigger = (component, method = 'hide') => {
+ const clickEvent = `click.dismiss${component.EVENT_KEY}`
+ const name = component.NAME
+
+ EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"]`, function (event) {
+ if (['A', 'AREA'].includes(this.tagName)) {
+ event.preventDefault()
+ }
+
+ if (isDisabled(this)) {
+ return
+ }
+
+ const target = getElementFromSelector(this) || this.closest(`.${name}`)
+ const instance = component.getOrCreateInstance(target)
+
+ // Method argument is left, for Alert and only, as it doesn't implement the 'hide' method
+ instance[method]()
+ })
+}
+
+export {
+ enableDismissTrigger
+}
diff --git a/js/src/util/config.js b/js/src/util/config.js
new file mode 100644
index 0000000..1205905
--- /dev/null
+++ b/js/src/util/config.js
@@ -0,0 +1,66 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/config.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { isElement, toType } from './index'
+import Manipulator from '../dom/manipulator'
+
+/**
+ * Class definition
+ */
+
+class Config {
+ // Getters
+ static get Default() {
+ return {}
+ }
+
+ static get DefaultType() {
+ return {}
+ }
+
+ static get NAME() {
+ throw new Error('You have to implement the static method "NAME", for each component!')
+ }
+
+ _getConfig(config) {
+ config = this._mergeConfigObj(config)
+ config = this._configAfterMerge(config)
+ this._typeCheckConfig(config)
+ return config
+ }
+
+ _configAfterMerge(config) {
+ return config
+ }
+
+ _mergeConfigObj(config, element) {
+ const jsonConfig = isElement(element) ? Manipulator.getDataAttribute(element, 'config') : {} // try to parse
+
+ return {
+ ...this.constructor.Default,
+ ...(typeof jsonConfig === 'object' ? jsonConfig : {}),
+ ...(isElement(element) ? Manipulator.getDataAttributes(element) : {}),
+ ...(typeof config === 'object' ? config : {})
+ }
+ }
+
+ _typeCheckConfig(config, configTypes = this.constructor.DefaultType) {
+ for (const property of Object.keys(configTypes)) {
+ const expectedTypes = configTypes[property]
+ const value = config[property]
+ const valueType = isElement(value) ? 'element' : toType(value)
+
+ if (!new RegExp(expectedTypes).test(valueType)) {
+ throw new TypeError(
+ `${this.constructor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`
+ )
+ }
+ }
+ }
+}
+
+export default Config
diff --git a/js/src/util/focustrap.js b/js/src/util/focustrap.js
new file mode 100644
index 0000000..ef69166
--- /dev/null
+++ b/js/src/util/focustrap.js
@@ -0,0 +1,115 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/focustrap.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import EventHandler from '../dom/event-handler'
+import SelectorEngine from '../dom/selector-engine'
+import Config from './config'
+
+/**
+ * Constants
+ */
+
+const NAME = 'focustrap'
+const DATA_KEY = 'bs.focustrap'
+const EVENT_KEY = `.${DATA_KEY}`
+const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
+const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY}`
+
+const TAB_KEY = 'Tab'
+const TAB_NAV_FORWARD = 'forward'
+const TAB_NAV_BACKWARD = 'backward'
+
+const Default = {
+ autofocus: true,
+ trapElement: null // The element to trap focus inside of
+}
+
+const DefaultType = {
+ autofocus: 'boolean',
+ trapElement: 'element'
+}
+
+/**
+ * Class definition
+ */
+
+class FocusTrap extends Config {
+ constructor(config) {
+ super()
+ this._config = this._getConfig(config)
+ this._isActive = false
+ this._lastTabNavDirection = null
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ activate() {
+ if (this._isActive) {
+ return
+ }
+
+ if (this._config.autofocus) {
+ this._config.trapElement.focus()
+ }
+
+ EventHandler.off(document, EVENT_KEY) // guard against infinite focus loop
+ EventHandler.on(document, EVENT_FOCUSIN, event => this._handleFocusin(event))
+ EventHandler.on(document, EVENT_KEYDOWN_TAB, event => this._handleKeydown(event))
+
+ this._isActive = true
+ }
+
+ deactivate() {
+ if (!this._isActive) {
+ return
+ }
+
+ this._isActive = false
+ EventHandler.off(document, EVENT_KEY)
+ }
+
+ // Private
+ _handleFocusin(event) {
+ const { trapElement } = this._config
+
+ if (event.target === document || event.target === trapElement || trapElement.contains(event.target)) {
+ return
+ }
+
+ const elements = SelectorEngine.focusableChildren(trapElement)
+
+ if (elements.length === 0) {
+ trapElement.focus()
+ } else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) {
+ elements[elements.length - 1].focus()
+ } else {
+ elements[0].focus()
+ }
+ }
+
+ _handleKeydown(event) {
+ if (event.key !== TAB_KEY) {
+ return
+ }
+
+ this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD
+ }
+}
+
+export default FocusTrap
diff --git a/js/src/util/index.js b/js/src/util/index.js
new file mode 100644
index 0000000..297e571
--- /dev/null
+++ b/js/src/util/index.js
@@ -0,0 +1,336 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/index.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+const MAX_UID = 1_000_000
+const MILLISECONDS_MULTIPLIER = 1000
+const TRANSITION_END = 'transitionend'
+
+// Shout-out Angus Croll (https://goo.gl/pxwQGp)
+const toType = object => {
+ if (object === null || object === undefined) {
+ return `${object}`
+ }
+
+ return Object.prototype.toString.call(object).match(/\s([a-z]+)/i)[1].toLowerCase()
+}
+
+/**
+ * Public Util API
+ */
+
+const getUID = prefix => {
+ do {
+ prefix += Math.floor(Math.random() * MAX_UID)
+ } while (document.getElementById(prefix))
+
+ return prefix
+}
+
+const getSelector = element => {
+ let selector = element.getAttribute('data-bs-target')
+
+ if (!selector || selector === '#') {
+ let hrefAttribute = element.getAttribute('href')
+
+ // The only valid content that could double as a selector are IDs or classes,
+ // so everything starting with `#` or `.`. If a "real" URL is used as the selector,
+ // `document.querySelector` will rightfully complain it is invalid.
+ // See https://github.com/twbs/bootstrap/issues/32273
+ if (!hrefAttribute || (!hrefAttribute.includes('#') && !hrefAttribute.startsWith('.'))) {
+ return null
+ }
+
+ // Just in case some CMS puts out a full URL with the anchor appended
+ if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) {
+ hrefAttribute = `#${hrefAttribute.split('#')[1]}`
+ }
+
+ selector = hrefAttribute && hrefAttribute !== '#' ? hrefAttribute.trim() : null
+ }
+
+ return selector
+}
+
+const getSelectorFromElement = element => {
+ const selector = getSelector(element)
+
+ if (selector) {
+ return document.querySelector(selector) ? selector : null
+ }
+
+ return null
+}
+
+const getElementFromSelector = element => {
+ const selector = getSelector(element)
+
+ return selector ? document.querySelector(selector) : null
+}
+
+const getTransitionDurationFromElement = element => {
+ if (!element) {
+ return 0
+ }
+
+ // Get transition-duration of the element
+ let { transitionDuration, transitionDelay } = window.getComputedStyle(element)
+
+ const floatTransitionDuration = Number.parseFloat(transitionDuration)
+ const floatTransitionDelay = Number.parseFloat(transitionDelay)
+
+ // Return 0 if element or transition duration is not found
+ if (!floatTransitionDuration && !floatTransitionDelay) {
+ return 0
+ }
+
+ // If multiple durations are defined, take the first
+ transitionDuration = transitionDuration.split(',')[0]
+ transitionDelay = transitionDelay.split(',')[0]
+
+ return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER
+}
+
+const triggerTransitionEnd = element => {
+ element.dispatchEvent(new Event(TRANSITION_END))
+}
+
+const isElement = object => {
+ if (!object || typeof object !== 'object') {
+ return false
+ }
+
+ if (typeof object.jquery !== 'undefined') {
+ object = object[0]
+ }
+
+ return typeof object.nodeType !== 'undefined'
+}
+
+const getElement = object => {
+ // it's a jQuery object or a node element
+ if (isElement(object)) {
+ return object.jquery ? object[0] : object
+ }
+
+ if (typeof object === 'string' && object.length > 0) {
+ return document.querySelector(object)
+ }
+
+ return null
+}
+
+const isVisible = element => {
+ if (!isElement(element) || element.getClientRects().length === 0) {
+ return false
+ }
+
+ const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'
+ // Handle `details` element as its content may falsie appear visible when it is closed
+ const closedDetails = element.closest('details:not([open])')
+
+ if (!closedDetails) {
+ return elementIsVisible
+ }
+
+ if (closedDetails !== element) {
+ const summary = element.closest('summary')
+ if (summary && summary.parentNode !== closedDetails) {
+ return false
+ }
+
+ if (summary === null) {
+ return false
+ }
+ }
+
+ return elementIsVisible
+}
+
+const isDisabled = element => {
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
+ return true
+ }
+
+ if (element.classList.contains('disabled')) {
+ return true
+ }
+
+ if (typeof element.disabled !== 'undefined') {
+ return element.disabled
+ }
+
+ return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'
+}
+
+const findShadowRoot = element => {
+ if (!document.documentElement.attachShadow) {
+ return null
+ }
+
+ // Can find the shadow root otherwise it'll return the document
+ if (typeof element.getRootNode === 'function') {
+ const root = element.getRootNode()
+ return root instanceof ShadowRoot ? root : null
+ }
+
+ if (element instanceof ShadowRoot) {
+ return element
+ }
+
+ // when we don't find a shadow root
+ if (!element.parentNode) {
+ return null
+ }
+
+ return findShadowRoot(element.parentNode)
+}
+
+const noop = () => {}
+
+/**
+ * Trick to restart an element's animation
+ *
+ * @param {HTMLElement} element
+ * @return void
+ *
+ * @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
+ */
+const reflow = element => {
+ element.offsetHeight // eslint-disable-line no-unused-expressions
+}
+
+const getjQuery = () => {
+ if (window.jQuery && !document.body.hasAttribute('data-bs-no-jquery')) {
+ return window.jQuery
+ }
+
+ return null
+}
+
+const DOMContentLoadedCallbacks = []
+
+const onDOMContentLoaded = callback => {
+ if (document.readyState === 'loading') {
+ // add listener on the first call when the document is in loading state
+ if (!DOMContentLoadedCallbacks.length) {
+ document.addEventListener('DOMContentLoaded', () => {
+ for (const callback of DOMContentLoadedCallbacks) {
+ callback()
+ }
+ })
+ }
+
+ DOMContentLoadedCallbacks.push(callback)
+ } else {
+ callback()
+ }
+}
+
+const isRTL = () => document.documentElement.dir === 'rtl'
+
+const defineJQueryPlugin = plugin => {
+ onDOMContentLoaded(() => {
+ const $ = getjQuery()
+ /* istanbul ignore if */
+ if ($) {
+ const name = plugin.NAME
+ const JQUERY_NO_CONFLICT = $.fn[name]
+ $.fn[name] = plugin.jQueryInterface
+ $.fn[name].Constructor = plugin
+ $.fn[name].noConflict = () => {
+ $.fn[name] = JQUERY_NO_CONFLICT
+ return plugin.jQueryInterface
+ }
+ }
+ })
+}
+
+const execute = callback => {
+ if (typeof callback === 'function') {
+ callback()
+ }
+}
+
+const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
+ if (!waitForTransition) {
+ execute(callback)
+ return
+ }
+
+ const durationPadding = 5
+ const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding
+
+ let called = false
+
+ const handler = ({ target }) => {
+ if (target !== transitionElement) {
+ return
+ }
+
+ called = true
+ transitionElement.removeEventListener(TRANSITION_END, handler)
+ execute(callback)
+ }
+
+ transitionElement.addEventListener(TRANSITION_END, handler)
+ setTimeout(() => {
+ if (!called) {
+ triggerTransitionEnd(transitionElement)
+ }
+ }, emulatedDuration)
+}
+
+/**
+ * Return the previous/next element of a list.
+ *
+ * @param {array} list The list of elements
+ * @param activeElement The active element
+ * @param shouldGetNext Choose to get next or previous element
+ * @param isCycleAllowed
+ * @return {Element|elem} The proper element
+ */
+const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => {
+ const listLength = list.length
+ let index = list.indexOf(activeElement)
+
+ // if the element does not exist in the list return an element
+ // depending on the direction and if cycle is allowed
+ if (index === -1) {
+ return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0]
+ }
+
+ index += shouldGetNext ? 1 : -1
+
+ if (isCycleAllowed) {
+ index = (index + listLength) % listLength
+ }
+
+ return list[Math.max(0, Math.min(index, listLength - 1))]
+}
+
+export {
+ defineJQueryPlugin,
+ execute,
+ executeAfterTransition,
+ findShadowRoot,
+ getElement,
+ getElementFromSelector,
+ getjQuery,
+ getNextActiveElement,
+ getSelectorFromElement,
+ getTransitionDurationFromElement,
+ getUID,
+ isDisabled,
+ isElement,
+ isRTL,
+ isVisible,
+ noop,
+ onDOMContentLoaded,
+ reflow,
+ triggerTransitionEnd,
+ toType
+}
diff --git a/js/src/util/sanitizer.js b/js/src/util/sanitizer.js
new file mode 100644
index 0000000..5328691
--- /dev/null
+++ b/js/src/util/sanitizer.js
@@ -0,0 +1,118 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/sanitizer.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+const uriAttributes = new Set([
+ 'background',
+ 'cite',
+ 'href',
+ 'itemtype',
+ 'longdesc',
+ 'poster',
+ 'src',
+ 'xlink:href'
+])
+
+const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
+
+/**
+ * A pattern that recognizes a commonly useful subset of URLs that are safe.
+ *
+ * Shout-out to Angular https://github.com/angular/angular/blob/12.2.x/packages/core/src/sanitization/url_sanitizer.ts
+ */
+const SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i
+
+/**
+ * A pattern that matches safe data URLs. Only matches image, video and audio types.
+ *
+ * Shout-out to Angular https://github.com/angular/angular/blob/12.2.x/packages/core/src/sanitization/url_sanitizer.ts
+ */
+const DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i
+
+const allowedAttribute = (attribute, allowedAttributeList) => {
+ const attributeName = attribute.nodeName.toLowerCase()
+
+ if (allowedAttributeList.includes(attributeName)) {
+ if (uriAttributes.has(attributeName)) {
+ return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue) || DATA_URL_PATTERN.test(attribute.nodeValue))
+ }
+
+ return true
+ }
+
+ // Check if a regular expression validates the attribute.
+ return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp)
+ .some(regex => regex.test(attributeName))
+}
+
+export const DefaultAllowlist = {
+ // Global attributes allowed on any supplied element below.
+ '*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],
+ a: ['target', 'href', 'title', 'rel'],
+ area: [],
+ b: [],
+ br: [],
+ col: [],
+ code: [],
+ div: [],
+ em: [],
+ hr: [],
+ h1: [],
+ h2: [],
+ h3: [],
+ h4: [],
+ h5: [],
+ h6: [],
+ i: [],
+ img: ['src', 'srcset', 'alt', 'title', 'width', 'height'],
+ li: [],
+ ol: [],
+ p: [],
+ pre: [],
+ s: [],
+ small: [],
+ span: [],
+ sub: [],
+ sup: [],
+ strong: [],
+ u: [],
+ ul: []
+}
+
+export function sanitizeHtml(unsafeHtml, allowList, sanitizeFunction) {
+ if (!unsafeHtml.length) {
+ return unsafeHtml
+ }
+
+ if (sanitizeFunction && typeof sanitizeFunction === 'function') {
+ return sanitizeFunction(unsafeHtml)
+ }
+
+ const domParser = new window.DOMParser()
+ const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html')
+ const elements = [].concat(...createdDocument.body.querySelectorAll('*'))
+
+ for (const element of elements) {
+ const elementName = element.nodeName.toLowerCase()
+
+ if (!Object.keys(allowList).includes(elementName)) {
+ element.remove()
+
+ continue
+ }
+
+ const attributeList = [].concat(...element.attributes)
+ const allowedAttributes = [].concat(allowList['*'] || [], allowList[elementName] || [])
+
+ for (const attribute of attributeList) {
+ if (!allowedAttribute(attribute, allowedAttributes)) {
+ element.removeAttribute(attribute.nodeName)
+ }
+ }
+ }
+
+ return createdDocument.body.innerHTML
+}
diff --git a/js/src/util/scrollbar.js b/js/src/util/scrollbar.js
new file mode 100644
index 0000000..5cac7b6
--- /dev/null
+++ b/js/src/util/scrollbar.js
@@ -0,0 +1,114 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/scrollBar.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import SelectorEngine from '../dom/selector-engine'
+import Manipulator from '../dom/manipulator'
+import { isElement } from './index'
+
+/**
+ * Constants
+ */
+
+const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top'
+const SELECTOR_STICKY_CONTENT = '.sticky-top'
+const PROPERTY_PADDING = 'padding-right'
+const PROPERTY_MARGIN = 'margin-right'
+
+/**
+ * Class definition
+ */
+
+class ScrollBarHelper {
+ constructor() {
+ this._element = document.body
+ }
+
+ // Public
+ getWidth() {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes
+ const documentWidth = document.documentElement.clientWidth
+ return Math.abs(window.innerWidth - documentWidth)
+ }
+
+ hide() {
+ const width = this.getWidth()
+ this._disableOverFlow()
+ // give padding to element to balance the hidden scrollbar width
+ this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width)
+ // trick: We adjust positive paddingRight and negative marginRight to sticky-top elements to keep showing fullwidth
+ this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width)
+ this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width)
+ }
+
+ reset() {
+ this._resetElementAttributes(this._element, 'overflow')
+ this._resetElementAttributes(this._element, PROPERTY_PADDING)
+ this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING)
+ this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN)
+ }
+
+ isOverflowing() {
+ return this.getWidth() > 0
+ }
+
+ // Private
+ _disableOverFlow() {
+ this._saveInitialAttribute(this._element, 'overflow')
+ this._element.style.overflow = 'hidden'
+ }
+
+ _setElementAttributes(selector, styleProperty, callback) {
+ const scrollbarWidth = this.getWidth()
+ const manipulationCallBack = element => {
+ if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) {
+ return
+ }
+
+ this._saveInitialAttribute(element, styleProperty)
+ const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty)
+ element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`)
+ }
+
+ this._applyManipulationCallback(selector, manipulationCallBack)
+ }
+
+ _saveInitialAttribute(element, styleProperty) {
+ const actualValue = element.style.getPropertyValue(styleProperty)
+ if (actualValue) {
+ Manipulator.setDataAttribute(element, styleProperty, actualValue)
+ }
+ }
+
+ _resetElementAttributes(selector, styleProperty) {
+ const manipulationCallBack = element => {
+ const value = Manipulator.getDataAttribute(element, styleProperty)
+ // We only want to remove the property if the value is `null`; the value can also be zero
+ if (value === null) {
+ element.style.removeProperty(styleProperty)
+ return
+ }
+
+ Manipulator.removeDataAttribute(element, styleProperty)
+ element.style.setProperty(styleProperty, value)
+ }
+
+ this._applyManipulationCallback(selector, manipulationCallBack)
+ }
+
+ _applyManipulationCallback(selector, callBack) {
+ if (isElement(selector)) {
+ callBack(selector)
+ return
+ }
+
+ for (const sel of SelectorEngine.find(selector, this._element)) {
+ callBack(sel)
+ }
+ }
+}
+
+export default ScrollBarHelper
diff --git a/js/src/util/swipe.js b/js/src/util/swipe.js
new file mode 100644
index 0000000..7126360
--- /dev/null
+++ b/js/src/util/swipe.js
@@ -0,0 +1,146 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/swipe.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import Config from './config'
+import EventHandler from '../dom/event-handler'
+import { execute } from './index'
+
+/**
+ * Constants
+ */
+
+const NAME = 'swipe'
+const EVENT_KEY = '.bs.swipe'
+const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`
+const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`
+const EVENT_TOUCHEND = `touchend${EVENT_KEY}`
+const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
+const EVENT_POINTERUP = `pointerup${EVENT_KEY}`
+const POINTER_TYPE_TOUCH = 'touch'
+const POINTER_TYPE_PEN = 'pen'
+const CLASS_NAME_POINTER_EVENT = 'pointer-event'
+const SWIPE_THRESHOLD = 40
+
+const Default = {
+ endCallback: null,
+ leftCallback: null,
+ rightCallback: null
+}
+
+const DefaultType = {
+ endCallback: '(function|null)',
+ leftCallback: '(function|null)',
+ rightCallback: '(function|null)'
+}
+
+/**
+ * Class definition
+ */
+
+class Swipe extends Config {
+ constructor(element, config) {
+ super()
+ this._element = element
+
+ if (!element || !Swipe.isSupported()) {
+ return
+ }
+
+ this._config = this._getConfig(config)
+ this._deltaX = 0
+ this._supportPointerEvents = Boolean(window.PointerEvent)
+ this._initEvents()
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ dispose() {
+ EventHandler.off(this._element, EVENT_KEY)
+ }
+
+ // Private
+ _start(event) {
+ if (!this._supportPointerEvents) {
+ this._deltaX = event.touches[0].clientX
+
+ return
+ }
+
+ if (this._eventIsPointerPenTouch(event)) {
+ this._deltaX = event.clientX
+ }
+ }
+
+ _end(event) {
+ if (this._eventIsPointerPenTouch(event)) {
+ this._deltaX = event.clientX - this._deltaX
+ }
+
+ this._handleSwipe()
+ execute(this._config.endCallback)
+ }
+
+ _move(event) {
+ this._deltaX = event.touches && event.touches.length > 1 ?
+ 0 :
+ event.touches[0].clientX - this._deltaX
+ }
+
+ _handleSwipe() {
+ const absDeltaX = Math.abs(this._deltaX)
+
+ if (absDeltaX <= SWIPE_THRESHOLD) {
+ return
+ }
+
+ const direction = absDeltaX / this._deltaX
+
+ this._deltaX = 0
+
+ if (!direction) {
+ return
+ }
+
+ execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback)
+ }
+
+ _initEvents() {
+ if (this._supportPointerEvents) {
+ EventHandler.on(this._element, EVENT_POINTERDOWN, event => this._start(event))
+ EventHandler.on(this._element, EVENT_POINTERUP, event => this._end(event))
+
+ this._element.classList.add(CLASS_NAME_POINTER_EVENT)
+ } else {
+ EventHandler.on(this._element, EVENT_TOUCHSTART, event => this._start(event))
+ EventHandler.on(this._element, EVENT_TOUCHMOVE, event => this._move(event))
+ EventHandler.on(this._element, EVENT_TOUCHEND, event => this._end(event))
+ }
+ }
+
+ _eventIsPointerPenTouch(event) {
+ return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)
+ }
+
+ // Static
+ static isSupported() {
+ return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+ }
+}
+
+export default Swipe
diff --git a/js/src/util/template-factory.js b/js/src/util/template-factory.js
new file mode 100644
index 0000000..cf402fa
--- /dev/null
+++ b/js/src/util/template-factory.js
@@ -0,0 +1,160 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.2.3): util/template-factory.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { DefaultAllowlist, sanitizeHtml } from './sanitizer'
+import { getElement, isElement } from '../util/index'
+import SelectorEngine from '../dom/selector-engine'
+import Config from './config'
+
+/**
+ * Constants
+ */
+
+const NAME = 'TemplateFactory'
+
+const Default = {
+ allowList: DefaultAllowlist,
+ content: {}, // { selector : text , selector2 : text2 , }
+ extraClass: '',
+ html: false,
+ sanitize: true,
+ sanitizeFn: null,
+ template: '<div></div>'
+}
+
+const DefaultType = {
+ allowList: 'object',
+ content: 'object',
+ extraClass: '(string|function)',
+ html: 'boolean',
+ sanitize: 'boolean',
+ sanitizeFn: '(null|function)',
+ template: 'string'
+}
+
+const DefaultContentType = {
+ entry: '(string|element|function|null)',
+ selector: '(string|element)'
+}
+
+/**
+ * Class definition
+ */
+
+class TemplateFactory extends Config {
+ constructor(config) {
+ super()
+ this._config = this._getConfig(config)
+ }
+
+ // Getters
+ static get Default() {
+ return Default
+ }
+
+ static get DefaultType() {
+ return DefaultType
+ }
+
+ static get NAME() {
+ return NAME
+ }
+
+ // Public
+ getContent() {
+ return Object.values(this._config.content)
+ .map(config => this._resolvePossibleFunction(config))
+ .filter(Boolean)
+ }
+
+ hasContent() {
+ return this.getContent().length > 0
+ }
+
+ changeContent(content) {
+ this._checkContent(content)
+ this._config.content = { ...this._config.content, ...content }
+ return this
+ }
+
+ toHtml() {
+ const templateWrapper = document.createElement('div')
+ templateWrapper.innerHTML = this._maybeSanitize(this._config.template)
+
+ for (const [selector, text] of Object.entries(this._config.content)) {
+ this._setContent(templateWrapper, text, selector)
+ }
+
+ const template = templateWrapper.children[0]
+ const extraClass = this._resolvePossibleFunction(this._config.extraClass)
+
+ if (extraClass) {
+ template.classList.add(...extraClass.split(' '))
+ }
+
+ return template
+ }
+
+ // Private
+ _typeCheckConfig(config) {
+ super._typeCheckConfig(config)
+ this._checkContent(config.content)
+ }
+
+ _checkContent(arg) {
+ for (const [selector, content] of Object.entries(arg)) {
+ super._typeCheckConfig({ selector, entry: content }, DefaultContentType)
+ }
+ }
+
+ _setContent(template, content, selector) {
+ const templateElement = SelectorEngine.findOne(selector, template)
+
+ if (!templateElement) {
+ return
+ }
+
+ content = this._resolvePossibleFunction(content)
+
+ if (!content) {
+ templateElement.remove()
+ return
+ }
+
+ if (isElement(content)) {
+ this._putElementInTemplate(getElement(content), templateElement)
+ return
+ }
+
+ if (this._config.html) {
+ templateElement.innerHTML = this._maybeSanitize(content)
+ return
+ }
+
+ templateElement.textContent = content
+ }
+
+ _maybeSanitize(arg) {
+ return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg
+ }
+
+ _resolvePossibleFunction(arg) {
+ return typeof arg === 'function' ? arg(this) : arg
+ }
+
+ _putElementInTemplate(element, templateElement) {
+ if (this._config.html) {
+ templateElement.innerHTML = ''
+ templateElement.append(element)
+ return
+ }
+
+ templateElement.textContent = element.textContent
+ }
+}
+
+export default TemplateFactory
diff --git a/js/tests/README.md b/js/tests/README.md
new file mode 100644
index 0000000..79d05d4
--- /dev/null
+++ b/js/tests/README.md
@@ -0,0 +1,73 @@
+## How does Bootstrap's test suite work?
+
+Bootstrap uses [Jasmine](https://jasmine.github.io/). Each plugin has a file dedicated to its tests in `tests/unit/<plugin-name>.spec.js`.
+
+- `visual/` contains "visual" tests which are run interactively in real browsers and require manual verification by humans.
+
+To run the unit test suite via [Karma](https://karma-runner.github.io/), run `npm run js-test`.
+To run the unit test suite via [Karma](https://karma-runner.github.io/) and debug, run `npm run js-debug`.
+
+## How do I add a new unit test?
+
+1. Locate and open the file dedicated to the plugin which you need to add tests to (`tests/unit/<plugin-name>.spec.js`).
+2. Review the [Jasmine API Documentation](https://jasmine.github.io/pages/docs_home.html) and use the existing tests as references for how to structure your new tests.
+3. Write the necessary unit test(s) for the new or revised functionality.
+4. Run `npm run js-test` to see the results of your newly-added test(s).
+
+**Note:** Your new unit tests should fail before your changes are applied to the plugin, and should pass after your changes are applied to the plugin.
+
+## What should a unit test look like?
+
+- Each test should have a unique name clearly stating what unit is being tested.
+- Each test should be in the corresponding `describe`.
+- Each test should test only one unit per test, although one test can include several assertions. Create multiple tests for multiple units of functionality.
+- Each test should use [`expect`](https://jasmine.github.io/api/edge/matchers.html) to ensure something is expected.
+- Each test should follow the project's [JavaScript Code Guidelines](https://github.com/twbs/bootstrap/blob/main/.github/CONTRIBUTING.md#js)
+
+## Code coverage
+
+Currently we're aiming for at least 90% test coverage for our code. To ensure your changes meet or exceed this limit, run `npm run js-test-karma` and open the file in `js/coverage/lcov-report/index.html` to see the code coverage for each plugin. See more details when you select a plugin and ensure your change is fully covered by unit tests.
+
+### Example tests
+
+```js
+// Synchronous test
+describe('getInstance', () => {
+ it('should return null if there is no instance', () => {
+ // Make assertion
+ expect(Tab.getInstance(fixtureEl)).toBeNull()
+ })
+
+ it('should return this instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const divEl = fixtureEl.querySelector('div')
+ const tab = new Tab(divEl)
+
+ // Make assertion
+ expect(Tab.getInstance(divEl)).toEqual(tab)
+ })
+})
+
+// Asynchronous test
+it('should show a tooltip without the animation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+
+ expect(tip).not.toBeNull()
+ expect(tip.classList.contains('fade')).toEqual(false)
+ resolve()
+ })
+
+ tooltip.show()
+ })
+})
+```
diff --git a/js/tests/browsers.js b/js/tests/browsers.js
new file mode 100644
index 0000000..8adedc6
--- /dev/null
+++ b/js/tests/browsers.js
@@ -0,0 +1,79 @@
+/* eslint-env node */
+/* eslint-disable camelcase */
+
+const browsers = {
+ safariMac: {
+ base: 'BrowserStack',
+ os: 'OS X',
+ os_version: 'Catalina',
+ browser: 'Safari',
+ browser_version: 'latest'
+ },
+ chromeMac: {
+ base: 'BrowserStack',
+ os: 'OS X',
+ os_version: 'Catalina',
+ browser: 'Chrome',
+ browser_version: 'latest'
+ },
+ firefoxMac: {
+ base: 'BrowserStack',
+ os: 'OS X',
+ os_version: 'Catalina',
+ browser: 'Firefox',
+ browser_version: 'latest'
+ },
+ chromeWin10: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Chrome',
+ browser_version: '60'
+ },
+ firefoxWin10: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Firefox',
+ browser_version: '60'
+ },
+ chromeWin10Latest: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Chrome',
+ browser_version: 'latest'
+ },
+ firefoxWin10Latest: {
+ base: 'BrowserStack',
+ os: 'Windows',
+ os_version: '10',
+ browser: 'Firefox',
+ browser_version: 'latest'
+ },
+ iphone7: {
+ base: 'BrowserStack',
+ os: 'ios',
+ os_version: '12.0',
+ device: 'iPhone 7',
+ real_mobile: true
+ },
+ iphone12: {
+ base: 'BrowserStack',
+ os: 'ios',
+ os_version: '14.0',
+ device: 'iPhone 12',
+ real_mobile: true
+ },
+ pixel2: {
+ base: 'BrowserStack',
+ os: 'android',
+ os_version: '8.0',
+ device: 'Google Pixel 2',
+ real_mobile: true
+ }
+}
+
+module.exports = {
+ browsers
+}
diff --git a/js/tests/helpers/fixture.js b/js/tests/helpers/fixture.js
new file mode 100644
index 0000000..5ad14e1
--- /dev/null
+++ b/js/tests/helpers/fixture.js
@@ -0,0 +1,47 @@
+const fixtureId = 'fixture'
+
+export const getFixture = () => {
+ let fixtureElement = document.getElementById(fixtureId)
+
+ if (!fixtureElement) {
+ fixtureElement = document.createElement('div')
+ fixtureElement.setAttribute('id', fixtureId)
+ fixtureElement.style.position = 'absolute'
+ fixtureElement.style.top = '-10000px'
+ fixtureElement.style.left = '-10000px'
+ fixtureElement.style.width = '10000px'
+ fixtureElement.style.height = '10000px'
+ document.body.append(fixtureElement)
+ }
+
+ return fixtureElement
+}
+
+export const clearFixture = () => {
+ const fixtureElement = getFixture()
+
+ fixtureElement.innerHTML = ''
+}
+
+export const createEvent = (eventName, parameters = {}) => {
+ return new Event(eventName, parameters)
+}
+
+export const jQueryMock = {
+ elements: undefined,
+ fn: {},
+ each(fn) {
+ for (const element of this.elements) {
+ fn.call(element)
+ }
+ }
+}
+
+export const clearBodyAndDocument = () => {
+ const attributes = ['data-bs-padding-right', 'style']
+
+ for (const attribute of attributes) {
+ document.documentElement.removeAttribute(attribute)
+ document.body.removeAttribute(attribute)
+ }
+}
diff --git a/js/tests/integration/bundle-modularity.js b/js/tests/integration/bundle-modularity.js
new file mode 100644
index 0000000..8546141
--- /dev/null
+++ b/js/tests/integration/bundle-modularity.js
@@ -0,0 +1,7 @@
+import Tooltip from '../../dist/tooltip'
+import '../../dist/carousel'
+
+window.addEventListener('load', () => {
+ [].concat(...document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+ .map(tooltipNode => new Tooltip(tooltipNode))
+})
diff --git a/js/tests/integration/bundle.js b/js/tests/integration/bundle.js
new file mode 100644
index 0000000..452088a
--- /dev/null
+++ b/js/tests/integration/bundle.js
@@ -0,0 +1,6 @@
+import { Tooltip } from '../../../dist/js/bootstrap.esm.js'
+
+window.addEventListener('load', () => {
+ [].concat(...document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+ .map(tooltipNode => new Tooltip(tooltipNode))
+})
diff --git a/js/tests/integration/index.html b/js/tests/integration/index.html
new file mode 100644
index 0000000..4c71bad
--- /dev/null
+++ b/js/tests/integration/index.html
@@ -0,0 +1,67 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <!-- Required meta tags -->
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <!-- Bootstrap CSS -->
+ <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <div class="container py-4">
+ <h1>Hello, world!</h1>
+
+ <div class="mt-5">
+ <button type="button" class="btn btn-secondary mb-3" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">
+ Tooltip on top
+ </button>
+
+ <div id="carouselExampleIndicators" class="carousel slide mt-2" data-bs-ride="carousel">
+ <div class="carousel-indicators">
+ <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0" aria-label="Slide 1"></button>
+ <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1" class="active" aria-current="true" aria-label="Slide 2"></button>
+ <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
+ </div>
+
+ <div class="carousel-inner">
+ <div class="carousel-item">
+ <img class="d-block w-100" alt="First slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3EFirst%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
+ <div class="carousel-caption d-none d-md-block">
+ <h5>First slide label</h5>
+ <p>Nulla vitae elit libero, a pharetra augue mollis interdum.</p>
+ </div>
+ </div>
+ <div class="carousel-item active">
+ <img class="d-block w-100" alt="Second slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3ESecond%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
+ <div class="carousel-caption d-none d-md-block">
+ <h5>Second slide label</h5>
+ <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
+ </div>
+ </div>
+ <div class="carousel-item">
+ <img class="d-block w-100" alt="Third slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3EThird%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
+ <div class="carousel-caption d-none d-md-block">
+ <h5>Third slide label</h5>
+ <p>Praesent commodo cursus magna, vel scelerisque nisl consectetur.</p>
+ </div>
+ </div>
+ </div>
+
+ <a class="carousel-control-prev" href="#carouselExampleIndicators" role="button" data-bs-slide="prev">
+ <span class="carousel-control-prev-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Previous</span>
+ </a>
+ <a class="carousel-control-next" href="#carouselExampleIndicators" role="button" data-bs-slide="next">
+ <span class="carousel-control-next-icon" aria-hidden="true"></span>
+ <span class="visually-hidden">Next</span>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <script src="../../coverage/bundle.js"></script>
+ </body>
+</html>
diff --git a/js/tests/integration/rollup.bundle-modularity.js b/js/tests/integration/rollup.bundle-modularity.js
new file mode 100644
index 0000000..a8670ca
--- /dev/null
+++ b/js/tests/integration/rollup.bundle-modularity.js
@@ -0,0 +1,17 @@
+/* eslint-env node */
+
+const commonjs = require('@rollup/plugin-commonjs')
+const configRollup = require('./rollup.bundle')
+
+const config = {
+ ...configRollup,
+ input: 'js/tests/integration/bundle-modularity.js',
+ output: {
+ file: 'js/coverage/bundle-modularity.js',
+ format: 'iife'
+ }
+}
+
+config.plugins.unshift(commonjs())
+
+module.exports = config
diff --git a/js/tests/integration/rollup.bundle.js b/js/tests/integration/rollup.bundle.js
new file mode 100644
index 0000000..caddcab
--- /dev/null
+++ b/js/tests/integration/rollup.bundle.js
@@ -0,0 +1,24 @@
+/* eslint-env node */
+
+const { babel } = require('@rollup/plugin-babel')
+const { nodeResolve } = require('@rollup/plugin-node-resolve')
+const replace = require('@rollup/plugin-replace')
+
+module.exports = {
+ input: 'js/tests/integration/bundle.js',
+ output: {
+ file: 'js/coverage/bundle.js',
+ format: 'iife'
+ },
+ plugins: [
+ replace({
+ 'process.env.NODE_ENV': '"production"',
+ preventAssignment: true
+ }),
+ nodeResolve(),
+ babel({
+ exclude: 'node_modules/**',
+ babelHelpers: 'bundled'
+ })
+ ]
+}
diff --git a/js/tests/karma.conf.js b/js/tests/karma.conf.js
new file mode 100644
index 0000000..6636ff1
--- /dev/null
+++ b/js/tests/karma.conf.js
@@ -0,0 +1,171 @@
+/* eslint-env node */
+
+'use strict'
+
+const path = require('node:path')
+const ip = require('ip')
+const { babel } = require('@rollup/plugin-babel')
+const istanbul = require('rollup-plugin-istanbul')
+const { nodeResolve } = require('@rollup/plugin-node-resolve')
+const replace = require('@rollup/plugin-replace')
+const { browsers } = require('./browsers')
+
+const ENV = process.env
+const BROWSERSTACK = Boolean(ENV.BROWSERSTACK)
+const DEBUG = Boolean(ENV.DEBUG)
+const JQUERY_TEST = Boolean(ENV.JQUERY)
+
+const frameworks = [
+ 'jasmine'
+]
+
+const plugins = [
+ 'karma-jasmine',
+ 'karma-rollup-preprocessor'
+]
+
+const reporters = ['dots']
+
+const detectBrowsers = {
+ usePhantomJS: false,
+ postDetection(availableBrowser) {
+ // On CI just use Chrome
+ if (ENV.CI === true) {
+ return ['ChromeHeadless']
+ }
+
+ if (availableBrowser.includes('Chrome')) {
+ return DEBUG ? ['Chrome'] : ['ChromeHeadless']
+ }
+
+ if (availableBrowser.includes('Chromium')) {
+ return DEBUG ? ['Chromium'] : ['ChromiumHeadless']
+ }
+
+ if (availableBrowser.includes('Firefox')) {
+ return DEBUG ? ['Firefox'] : ['FirefoxHeadless']
+ }
+
+ throw new Error('Please install Chrome, Chromium or Firefox')
+ }
+}
+
+const config = {
+ basePath: '../..',
+ port: 9876,
+ colors: true,
+ autoWatch: false,
+ singleRun: true,
+ concurrency: Number.POSITIVE_INFINITY,
+ client: {
+ clearContext: false
+ },
+ files: [
+ 'node_modules/hammer-simulator/index.js',
+ {
+ pattern: 'js/tests/unit/**/!(jquery).spec.js',
+ watched: !BROWSERSTACK
+ }
+ ],
+ preprocessors: {
+ 'js/tests/unit/**/*.spec.js': ['rollup']
+ },
+ rollupPreprocessor: {
+ plugins: [
+ replace({
+ 'process.env.NODE_ENV': '"dev"',
+ preventAssignment: true
+ }),
+ istanbul({
+ exclude: [
+ 'node_modules/**',
+ 'js/tests/unit/**/*.spec.js',
+ 'js/tests/helpers/**/*.js'
+ ]
+ }),
+ babel({
+ // Only transpile our source code
+ exclude: 'node_modules/**',
+ // Inline the required helpers in each file
+ babelHelpers: 'inline'
+ }),
+ nodeResolve()
+ ],
+ output: {
+ format: 'iife',
+ name: 'bootstrapTest',
+ sourcemap: 'inline',
+ generatedCode: 'es2015'
+ }
+ }
+}
+
+if (BROWSERSTACK) {
+ config.hostname = ip.address()
+ config.browserStack = {
+ username: ENV.BROWSER_STACK_USERNAME,
+ accessKey: ENV.BROWSER_STACK_ACCESS_KEY,
+ build: `bootstrap-${ENV.GITHUB_SHA ? ENV.GITHUB_SHA.slice(0, 7) + '-' : ''}${new Date().toISOString()}`,
+ project: 'Bootstrap',
+ retryLimit: 2
+ }
+ plugins.push('karma-browserstack-launcher', 'karma-jasmine-html-reporter')
+ config.customLaunchers = browsers
+ config.browsers = Object.keys(browsers)
+ reporters.push('BrowserStack', 'kjhtml')
+} else if (JQUERY_TEST) {
+ frameworks.push('detectBrowsers')
+ plugins.push(
+ 'karma-chrome-launcher',
+ 'karma-firefox-launcher',
+ 'karma-detect-browsers'
+ )
+ config.detectBrowsers = detectBrowsers
+ config.files = [
+ 'node_modules/jquery/dist/jquery.slim.min.js',
+ {
+ pattern: 'js/tests/unit/jquery.spec.js',
+ watched: false
+ }
+ ]
+} else {
+ frameworks.push('detectBrowsers')
+ plugins.push(
+ 'karma-chrome-launcher',
+ 'karma-firefox-launcher',
+ 'karma-detect-browsers',
+ 'karma-coverage-istanbul-reporter'
+ )
+ reporters.push('coverage-istanbul')
+ config.detectBrowsers = detectBrowsers
+ config.coverageIstanbulReporter = {
+ dir: path.resolve(__dirname, '../coverage/'),
+ reports: ['lcov', 'text-summary'],
+ thresholds: {
+ emitWarning: false,
+ global: {
+ statements: 90,
+ branches: 89,
+ functions: 90,
+ lines: 90
+ }
+ }
+ }
+
+ if (DEBUG) {
+ config.hostname = ip.address()
+ plugins.push('karma-jasmine-html-reporter')
+ reporters.push('kjhtml')
+ config.singleRun = false
+ config.autoWatch = true
+ }
+}
+
+config.frameworks = frameworks
+config.plugins = plugins
+config.reporters = reporters
+
+module.exports = karmaConfig => {
+ config.logLevel = karmaConfig.LOG_ERROR
+ karmaConfig.set(config)
+}
diff --git a/js/tests/unit/.eslintrc.json b/js/tests/unit/.eslintrc.json
new file mode 100644
index 0000000..6362a1a
--- /dev/null
+++ b/js/tests/unit/.eslintrc.json
@@ -0,0 +1,13 @@
+{
+ "extends": [
+ "../../../.eslintrc.json"
+ ],
+ "env": {
+ "jasmine": true
+ },
+ "rules": {
+ "unicorn/consistent-function-scoping": "off",
+ "unicorn/no-useless-undefined": "off",
+ "unicorn/prefer-add-event-listener": "off"
+ }
+}
diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js
new file mode 100644
index 0000000..d3740c9
--- /dev/null
+++ b/js/tests/unit/alert.spec.js
@@ -0,0 +1,259 @@
+import Alert from '../../src/alert'
+import { getTransitionDurationFromElement } from '../../src/util/index'
+import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Alert', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+ const alertBySelector = new Alert('.alert')
+ const alertByElement = new Alert(alertEl)
+
+ expect(alertBySelector._element).toEqual(alertEl)
+ expect(alertByElement._element).toEqual(alertEl)
+ })
+
+ it('should return version', () => {
+ expect(Alert.VERSION).toEqual(jasmine.any(String))
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Alert.DATA_KEY).toEqual('bs.alert')
+ })
+ })
+
+ describe('data-api', () => {
+ it('should close an alert without instantiating it manually', () => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-bs-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ const button = document.querySelector('button')
+
+ button.click()
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ })
+
+ it('should close an alert without instantiating it manually with the parent selector', () => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-bs-target=".alert" data-bs-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ const button = document.querySelector('button')
+
+ button.click()
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ })
+ })
+
+ describe('close', () => {
+ it('should close an alert', () => {
+ return new Promise(resolve => {
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ alert.close()
+ })
+ })
+
+ it('should close alert with fade class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="alert fade"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('transitionend', () => {
+ expect().nothing()
+ })
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ expect(document.querySelectorAll('.alert')).toHaveSize(0)
+ resolve()
+ })
+
+ alert.close()
+ })
+ })
+
+ it('should not remove alert if close event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const getAlert = () => document.querySelector('.alert')
+ const alertEl = getAlert()
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('close.bs.alert', event => {
+ event.preventDefault()
+ setTimeout(() => {
+ expect(getAlert()).not.toBeNull()
+ resolve()
+ }, 10)
+ })
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ reject(new Error('should not fire closed event'))
+ })
+
+ alert.close()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an alert', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ expect(Alert.getInstance(alertEl)).not.toBeNull()
+
+ alert.dispose()
+
+ expect(Alert.getInstance(alertEl)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should handle config passed and toggle existing alert', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ const spy = spyOn(alert, 'close')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock, 'close')
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should create new alert instance and call close', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ expect(Alert.getInstance(alertEl)).toBeNull()
+ jQueryMock.fn.alert.call(jQueryMock, 'close')
+
+ expect(fixtureEl.querySelector('.alert')).toBeNull()
+ })
+
+ it('should just create an alert instance without calling close', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock)
+
+ expect(Alert.getInstance(alertEl)).not.toBeNull()
+ expect(fixtureEl.querySelector('.alert')).not.toBeNull()
+ })
+
+ it('should throw an error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.alert.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+
+ it('should throw an error on protected method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = '_getConfig'
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.alert.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const alert = new Alert(div)
+
+ expect(Alert.getInstance(div)).toEqual(alert)
+ expect(Alert.getInstance(div)).toBeInstanceOf(Alert)
+ })
+
+ it('should return null when there is no alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Alert.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const alert = new Alert(div)
+
+ expect(Alert.getOrCreateInstance(div)).toEqual(alert)
+ expect(Alert.getInstance(div)).toEqual(Alert.getOrCreateInstance(div, {}))
+ expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
+ })
+
+ it('should return new instance when there is no alert instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Alert.getInstance(div)).toBeNull()
+ expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
+ })
+ })
+})
diff --git a/js/tests/unit/base-component.spec.js b/js/tests/unit/base-component.spec.js
new file mode 100644
index 0000000..b2352d6
--- /dev/null
+++ b/js/tests/unit/base-component.spec.js
@@ -0,0 +1,168 @@
+import BaseComponent from '../../src/base-component'
+import { clearFixture, getFixture } from '../helpers/fixture'
+import EventHandler from '../../src/dom/event-handler'
+import { noop } from '../../src/util'
+
+class DummyClass extends BaseComponent {
+ constructor(element) {
+ super(element)
+
+ EventHandler.on(this._element, `click${DummyClass.EVENT_KEY}`, noop)
+ }
+
+ static get NAME() {
+ return 'dummy'
+ }
+}
+
+describe('Base Component', () => {
+ let fixtureEl
+ const name = 'dummy'
+ let element
+ let instance
+ const createInstance = () => {
+ fixtureEl.innerHTML = '<div id="foo"></div>'
+ element = fixtureEl.querySelector('#foo')
+ instance = new DummyClass(element)
+ }
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('Static Methods', () => {
+ describe('VERSION', () => {
+ it('should return version', () => {
+ expect(DummyClass.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(DummyClass.DATA_KEY).toEqual(`bs.${name}`)
+ })
+ })
+
+ describe('NAME', () => {
+ it('should throw an Error if it is not initialized', () => {
+ expect(() => {
+ // eslint-disable-next-line no-unused-expressions
+ BaseComponent.NAME
+ }).toThrowError(Error)
+ })
+
+ it('should return plugin NAME', () => {
+ expect(DummyClass.NAME).toEqual(name)
+ })
+ })
+
+ describe('EVENT_KEY', () => {
+ it('should return plugin event key', () => {
+ expect(DummyClass.EVENT_KEY).toEqual(`.bs.${name}`)
+ })
+ })
+ })
+
+ describe('Public Methods', () => {
+ describe('constructor', () => {
+ it('should accept element, either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo"></div>',
+ '<div id="bar"></div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('#foo')
+ const elInstance = new DummyClass(el)
+ const selectorInstance = new DummyClass('#bar')
+
+ expect(elInstance._element).toEqual(el)
+ expect(selectorInstance._element).toEqual(fixtureEl.querySelector('#bar'))
+ })
+
+ it('should not initialize and add element record to Data (caching), if argument `element` is not an HTML element', () => {
+ fixtureEl.innerHTML = ''
+
+ const el = fixtureEl.querySelector('#foo')
+ const elInstance = new DummyClass(el)
+ const selectorInstance = new DummyClass('#bar')
+
+ expect(elInstance._element).not.toBeDefined()
+ expect(selectorInstance._element).not.toBeDefined()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an component', () => {
+ createInstance()
+ expect(DummyClass.getInstance(element)).not.toBeNull()
+
+ instance.dispose()
+
+ expect(DummyClass.getInstance(element)).toBeNull()
+ expect(instance._element).toBeNull()
+ })
+
+ it('should de-register element event listeners', () => {
+ createInstance()
+ const spy = spyOn(EventHandler, 'off')
+
+ instance.dispose()
+
+ expect(spy).toHaveBeenCalledWith(element, DummyClass.EVENT_KEY)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return an instance', () => {
+ createInstance()
+
+ expect(DummyClass.getInstance(element)).toEqual(instance)
+ expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass)
+ })
+
+ it('should accept element, either passed as a CSS selector, jQuery element, or DOM element', () => {
+ createInstance()
+
+ expect(DummyClass.getInstance('#foo')).toEqual(instance)
+ expect(DummyClass.getInstance(element)).toEqual(instance)
+
+ const fakejQueryObject = {
+ 0: element,
+ jquery: 'foo'
+ }
+
+ expect(DummyClass.getInstance(fakejQueryObject)).toEqual(instance)
+ })
+
+ it('should return null when there is no instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(DummyClass.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return an instance', () => {
+ createInstance()
+
+ expect(DummyClass.getOrCreateInstance(element)).toEqual(instance)
+ expect(DummyClass.getInstance(element)).toEqual(DummyClass.getOrCreateInstance(element, {}))
+ expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
+ })
+
+ it('should return new instance when there is no alert instance', () => {
+ fixtureEl.innerHTML = '<div id="foo"></div>'
+ element = fixtureEl.querySelector('#foo')
+
+ expect(DummyClass.getInstance(element)).toBeNull()
+ expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/button.spec.js b/js/tests/unit/button.spec.js
new file mode 100644
index 0000000..09ed17e
--- /dev/null
+++ b/js/tests/unit/button.spec.js
@@ -0,0 +1,183 @@
+import Button from '../../src/button'
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Button', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<button data-bs-toggle="button">Placeholder</button>'
+ const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]')
+ const buttonBySelector = new Button('[data-bs-toggle="button"]')
+ const buttonByElement = new Button(buttonEl)
+
+ expect(buttonBySelector._element).toEqual(buttonEl)
+ expect(buttonByElement._element).toEqual(buttonEl)
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Button.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Button.DATA_KEY).toEqual('bs.button')
+ })
+ })
+
+ describe('data-api', () => {
+ it('should toggle active class on click', () => {
+ fixtureEl.innerHTML = [
+ '<button class="btn" data-bs-toggle="button">btn</button>',
+ '<button class="btn testParent" data-bs-toggle="button"><div class="test"></div></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+ const btnTestParent = fixtureEl.querySelector('.testParent')
+
+ expect(btn).not.toHaveClass('active')
+
+ btn.click()
+
+ expect(btn).toHaveClass('active')
+
+ btn.click()
+
+ expect(btn).not.toHaveClass('active')
+
+ divTest.click()
+
+ expect(btnTestParent).toHaveClass('active')
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle aria-pressed', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button" aria-pressed="false"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ expect(btnEl.getAttribute('aria-pressed')).toEqual('false')
+ expect(btnEl).not.toHaveClass('active')
+
+ button.toggle()
+
+ expect(btnEl.getAttribute('aria-pressed')).toEqual('true')
+ expect(btnEl).toHaveClass('active')
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a button', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ expect(Button.getInstance(btnEl)).not.toBeNull()
+
+ button.dispose()
+
+ expect(Button.getInstance(btnEl)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should handle config passed and toggle existing button', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ const spy = spyOn(button, 'toggle')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock, 'toggle')
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should create new button instance and call toggle', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock, 'toggle')
+
+ expect(Button.getInstance(btnEl)).not.toBeNull()
+ expect(btnEl).toHaveClass('active')
+ })
+
+ it('should just create a button instance without calling toggle', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock)
+
+ expect(Button.getInstance(btnEl)).not.toBeNull()
+ expect(btnEl).not.toHaveClass('active')
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const button = new Button(div)
+
+ expect(Button.getInstance(div)).toEqual(button)
+ expect(Button.getInstance(div)).toBeInstanceOf(Button)
+ })
+
+ it('should return null when there is no button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Button.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const button = new Button(div)
+
+ expect(Button.getOrCreateInstance(div)).toEqual(button)
+ expect(Button.getInstance(div)).toEqual(Button.getOrCreateInstance(div, {}))
+ expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
+ })
+
+ it('should return new instance when there is no button instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Button.getInstance(div)).toBeNull()
+ expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
+ })
+ })
+})
diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js
new file mode 100644
index 0000000..d951bd5
--- /dev/null
+++ b/js/tests/unit/carousel.spec.js
@@ -0,0 +1,1570 @@
+import Carousel from '../../src/carousel'
+import EventHandler from '../../src/dom/event-handler'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+import { isRTL, noop } from '../../src/util/index'
+import Swipe from '../../src/util/swipe'
+
+describe('Carousel', () => {
+ const { Simulator, PointerEvent } = window
+ const originWinPointerEvent = PointerEvent
+ const supportPointerEvent = Boolean(PointerEvent)
+
+ const cssStyleCarousel = '.carousel.pointer-event { touch-action: none; }'
+
+ const stylesCarousel = document.createElement('style')
+ stylesCarousel.type = 'text/css'
+ stylesCarousel.append(document.createTextNode(cssStyleCarousel))
+
+ const clearPointerEvents = () => {
+ window.PointerEvent = null
+ }
+
+ const restorePointerEvents = () => {
+ window.PointerEvent = originWinPointerEvent
+ }
+
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Carousel.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Carousel.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Carousel.DATA_KEY).toEqual('bs.carousel')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carouselBySelector = new Carousel('#myCarousel')
+ const carouselByElement = new Carousel(carouselEl)
+
+ expect(carouselBySelector._element).toEqual(carouselEl)
+ expect(carouselByElement._element).toEqual(carouselEl)
+ })
+
+ it('should start cycling if `ride`===`carousel`', () => {
+ fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
+
+ const carousel = new Carousel('#myCarousel')
+ expect(carousel._interval).not.toBeNull()
+ })
+
+ it('should not start cycling if `ride`!==`carousel`', () => {
+ fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="true"></div>'
+
+ const carousel = new Carousel('#myCarousel')
+ expect(carousel._interval).toBeNull()
+ })
+
+ it('should go to next item if right arrow key is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spy = spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowRight'
+
+ carouselEl.dispatchEvent(keydown)
+ })
+ })
+
+ it('should ignore keyboard events if data-bs-keyboard=false', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide" data-bs-keyboard="false">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const spy = spyOn(EventHandler, 'trigger').and.callThrough()
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ // eslint-disable-next-line no-new
+ new Carousel('#myCarousel')
+ expect(spy).not.toHaveBeenCalledWith(carouselEl, 'keydown.bs.carousel', jasmine.any(Function))
+ })
+
+ it('should ignore mouse events if data-bs-pause=false', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide" data-bs-pause="false">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const spy = spyOn(EventHandler, 'trigger').and.callThrough()
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ // eslint-disable-next-line no-new
+ new Carousel('#myCarousel')
+ expect(spy).not.toHaveBeenCalledWith(carouselEl, 'hover.bs.carousel', jasmine.any(Function))
+ })
+
+ it('should go to previous item if left arrow key is pressed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="item1" class="carousel-item">item 1</div>',
+ ' <div class="carousel-item active">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spy = spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowLeft'
+
+ carouselEl.dispatchEvent(keydown)
+ })
+ })
+
+ it('should not prevent keydown if key is not ARROW_LEFT or ARROW_RIGHT', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spy = spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('keydown', event => {
+ expect(spy).toHaveBeenCalled()
+ expect(event.defaultPrevented).toBeFalse()
+ resolve()
+ })
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ carouselEl.dispatchEvent(keydown)
+ })
+ })
+
+ it('should ignore keyboard events within <input>s and <textarea>s', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">',
+ ' <input type="text">',
+ ' <textarea></textarea>',
+ ' </div>',
+ ' <div class="carousel-item"></div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spyKeydown = spyOn(carousel, '_keydown').and.callThrough()
+ const spySlide = spyOn(carousel, '_slide')
+
+ const keydown = createEvent('keydown', { bubbles: true, cancelable: true })
+ keydown.key = 'ArrowRight'
+ Object.defineProperty(keydown, 'target', {
+ value: input,
+ writable: true,
+ configurable: true
+ })
+
+ input.dispatchEvent(keydown)
+
+ expect(spyKeydown).toHaveBeenCalled()
+ expect(spySlide).not.toHaveBeenCalled()
+
+ spyKeydown.calls.reset()
+ spySlide.calls.reset()
+
+ Object.defineProperty(keydown, 'target', {
+ value: textarea
+ })
+ textarea.dispatchEvent(keydown)
+
+ expect(spyKeydown).toHaveBeenCalled()
+ expect(spySlide).not.toHaveBeenCalled()
+ })
+
+ it('should not slide if arrow key is pressed and carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ carousel._isSliding = true
+
+ for (const key of ['ArrowLeft', 'ArrowRight']) {
+ const keydown = createEvent('keydown')
+ keydown.key = key
+
+ carouselEl.dispatchEvent(keydown)
+ }
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should wrap around from end to start when wrap option is true', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="one" class="carousel-item active"></div>',
+ ' <div id="two" class="carousel-item"></div>',
+ ' <div id="three" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, { wrap: true })
+ const getActiveId = () => carouselEl.querySelector('.carousel-item.active').getAttribute('id')
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ const activeId = getActiveId()
+
+ if (activeId === 'two') {
+ carousel.next()
+ return
+ }
+
+ if (activeId === 'three') {
+ carousel.next()
+ return
+ }
+
+ if (activeId === 'one') {
+ // carousel wrapped around and slid from 3rd to 1st slide
+ expect(activeId).toEqual('one')
+ expect(event.from + 1).toEqual(3)
+ resolve()
+ }
+ })
+
+ carousel.next()
+ })
+ })
+
+ it('should stay at the start when the prev method is called and wrap is false', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="one" class="carousel-item active"></div>',
+ ' <div id="two" class="carousel-item"></div>',
+ ' <div id="three" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const firstElement = fixtureEl.querySelector('#one')
+ const carousel = new Carousel(carouselEl, { wrap: false })
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ reject(new Error('carousel slid when it should not have slid'))
+ })
+
+ carousel.prev()
+
+ setTimeout(() => {
+ expect(firstElement).toHaveClass('active')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not add touch event listeners if touch = false', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ const spy = spyOn(Carousel.prototype, '_addTouchEventListeners')
+
+ const carousel = new Carousel(carouselEl, {
+ touch: false
+ })
+
+ expect(spy).not.toHaveBeenCalled()
+ expect(carousel._swipeHelper).toBeNull()
+ })
+
+ it('should not add touch event listeners if touch supported = false', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ spyOn(Swipe, 'isSupported').and.returnValue(false)
+
+ const carousel = new Carousel(carouselEl)
+ EventHandler.off(carouselEl, Carousel.EVENT_KEY)
+
+ const spy = spyOn(carousel, '_addTouchEventListeners')
+
+ carousel._addEventListeners()
+
+ expect(spy).not.toHaveBeenCalled()
+ expect(carousel._swipeHelper).toBeNull()
+ })
+
+ it('should add touch event listeners by default', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ spyOn(Carousel.prototype, '_addTouchEventListeners')
+
+ // Headless browser does not support touch events, so need to fake it
+ // to test that touch events are add properly.
+ document.documentElement.ontouchstart = noop
+ const carousel = new Carousel(carouselEl)
+
+ expect(carousel._addTouchEventListeners).toHaveBeenCalled()
+ })
+
+ it('should allow swiperight and call _slide (prev) with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ document.documentElement.ontouchstart = noop
+ document.head.append(stylesCarousel)
+ Simulator.setType('pointer')
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('prev')
+ expect(event.direction).toEqual('right')
+ stylesCarousel.remove()
+ delete document.documentElement.ontouchstart
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should allow swipeleft and call next with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ document.documentElement.ontouchstart = noop
+ document.head.append(stylesCarousel)
+ Simulator.setType('pointer')
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).not.toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('next')
+ expect(event.direction).toEqual('left')
+ stylesCarousel.remove()
+ delete document.documentElement.ontouchstart
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should allow swiperight and call _slide (prev) with touch events', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('prev')
+ expect(event.direction).toEqual('right')
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should allow swipeleft and call _slide (next) with touch events', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, '_slide').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', event => {
+ expect(item).not.toHaveClass('active')
+ expect(spy).toHaveBeenCalledWith('next')
+ expect(event.direction).toEqual('left')
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ resolve()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+ })
+ })
+
+ it('should not slide when swiping and carousel is sliding', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+ carousel._isSliding = true
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ resolve()
+ }, 300)
+ })
+ })
+
+ it('should not allow pinch with touch events', () => {
+ return new Promise(resolve => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = noop
+
+ fixtureEl.innerHTML = '<div class="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0,
+ touches: 2
+ }, () => {
+ restorePointerEvents()
+ delete document.documentElement.ontouchstart
+ expect(carousel._swipeHelper._deltaX).toEqual(0)
+ resolve()
+ })
+ })
+ })
+
+ it('should call pause method on mouse over with pause equal to hover', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, 'pause')
+
+ const mouseOverEvent = createEvent('mouseover')
+ carouselEl.dispatchEvent(mouseOverEvent)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should call `maybeEnableCycle` on mouse out with pause equal to hover', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="carousel" data-bs-ride="true"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spyEnable = spyOn(carousel, '_maybeEnableCycle').and.callThrough()
+ const spyCycle = spyOn(carousel, 'cycle')
+
+ const mouseOutEvent = createEvent('mouseout')
+ carouselEl.dispatchEvent(mouseOutEvent)
+
+ setTimeout(() => {
+ expect(spyEnable).toHaveBeenCalled()
+ expect(spyCycle).toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+ })
+ })
+
+ describe('next', () => {
+ it('should not slide if the carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ carousel._isSliding = true
+ carousel.next()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not fire slid when slide is prevented', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ let slidEvent = false
+
+ const doneTest = () => {
+ setTimeout(() => {
+ expect(slidEvent).toBeFalse()
+ resolve()
+ }, 20)
+ }
+
+ carouselEl.addEventListener('slide.bs.carousel', event => {
+ event.preventDefault()
+ doneTest()
+ })
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ slidEvent = true
+ })
+
+ carousel.next()
+ })
+ })
+
+ it('should fire slide event with: direction, relatedTarget, from and to', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const onSlide = event => {
+ expect(event.direction).toEqual('left')
+ expect(event.relatedTarget).toHaveClass('carousel-item')
+ expect(event.from).toEqual(0)
+ expect(event.to).toEqual(1)
+
+ carouselEl.removeEventListener('slide.bs.carousel', onSlide)
+ carouselEl.addEventListener('slide.bs.carousel', onSlide2)
+
+ carousel.prev()
+ }
+
+ const onSlide2 = event => {
+ expect(event.direction).toEqual('right')
+ resolve()
+ }
+
+ carouselEl.addEventListener('slide.bs.carousel', onSlide)
+ carousel.next()
+ })
+ })
+
+ it('should fire slid event with: direction, relatedTarget, from and to', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const onSlid = event => {
+ expect(event.direction).toEqual('left')
+ expect(event.relatedTarget).toHaveClass('carousel-item')
+ expect(event.from).toEqual(0)
+ expect(event.to).toEqual(1)
+
+ carouselEl.removeEventListener('slid.bs.carousel', onSlid)
+ carouselEl.addEventListener('slid.bs.carousel', onSlid2)
+
+ carousel.prev()
+ }
+
+ const onSlid2 = event => {
+ expect(event.direction).toEqual('right')
+ resolve()
+ }
+
+ carouselEl.addEventListener('slid.bs.carousel', onSlid)
+ carousel.next()
+ })
+ })
+
+ it('should update the active element to the next item before sliding', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="secondItem" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const secondItemEl = fixtureEl.querySelector('#secondItem')
+ const carousel = new Carousel(carouselEl)
+
+ carousel.next()
+
+ expect(carousel._activeElement).toEqual(secondItemEl)
+ })
+
+ it('should continue cycling if it was already', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+ const spy = spyOn(carousel, 'cycle')
+
+ carousel.next()
+ expect(spy).not.toHaveBeenCalled()
+
+ carousel.cycle()
+ carousel.next()
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it('should update indicators if present', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-indicators">',
+ ' <button type="button" id="firstIndicator" data-bs-target="myCarousel" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>',
+ ' <button type="button" id="secondIndicator" data-bs-target="myCarousel" data-bs-slide-to="1" aria-label="Slide 2"></button>',
+ ' <button type="button" data-bs-target="myCarousel" data-bs-slide-to="2" aria-label="Slide 3"></button>',
+ ' </div>',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const firstIndicator = fixtureEl.querySelector('#firstIndicator')
+ const secondIndicator = fixtureEl.querySelector('#secondIndicator')
+ const carousel = new Carousel(carouselEl)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(firstIndicator).not.toHaveClass('active')
+ expect(firstIndicator.hasAttribute('aria-current')).toBeFalse()
+ expect(secondIndicator).toHaveClass('active')
+ expect(secondIndicator.getAttribute('aria-current')).toEqual('true')
+ resolve()
+ })
+
+ carousel.next()
+ })
+ })
+
+ it('should call next()/prev() instance methods when clicking the respective direction buttons', () => {
+ fixtureEl.innerHTML = [
+ '<div id="carousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" type="button" data-bs-target="#carousel" data-bs-slide="prev"></button>',
+ ' <button class="carousel-control-next" type="button" data-bs-target="#carousel" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#carousel')
+ const prevBtnEl = fixtureEl.querySelector('.carousel-control-prev')
+ const nextBtnEl = fixtureEl.querySelector('.carousel-control-next')
+
+ const carousel = new Carousel(carouselEl)
+ const nextSpy = spyOn(carousel, 'next')
+ const prevSpy = spyOn(carousel, 'prev')
+ const spyEnable = spyOn(carousel, '_maybeEnableCycle')
+
+ nextBtnEl.click()
+ prevBtnEl.click()
+
+ expect(nextSpy).toHaveBeenCalled()
+ expect(prevSpy).toHaveBeenCalled()
+ expect(spyEnable).toHaveBeenCalled()
+ })
+ })
+
+ describe('nextWhenVisible', () => {
+ it('should not call next when the page is not visible', () => {
+ fixtureEl.innerHTML = [
+ '<div style="display: none;">',
+ ' <div class="carousel"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(carousel, 'next')
+
+ carousel.nextWhenVisible()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('prev', () => {
+ it('should not slide if the carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ carousel._isSliding = true
+ carousel.prev()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('pause', () => {
+ it('should trigger transitionend if the carousel have carousel-item-next or carousel-item-prev class, cause is sliding', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item carousel-item-next">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+ const spy = spyOn(carousel, '_clearInterval')
+
+ carouselEl.addEventListener('transitionend', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ carousel._slide('next')
+ carousel.pause()
+ })
+ })
+ })
+
+ describe('cycle', () => {
+ it('should set an interval', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ const spy = spyOn(window, 'setInterval').and.callThrough()
+
+ carousel.cycle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should clear interval if there is one', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ carousel._interval = setInterval(noop, 10)
+
+ const spySet = spyOn(window, 'setInterval').and.callThrough()
+ const spyClear = spyOn(window, 'clearInterval').and.callThrough()
+
+ carousel.cycle()
+
+ expect(spySet).toHaveBeenCalled()
+ expect(spyClear).toHaveBeenCalled()
+ })
+
+ it('should get interval from data attribute on the active item element', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active" data-bs-interval="7">item 1</div>',
+ ' <div id="secondItem" class="carousel-item" data-bs-interval="9385">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const secondItemEl = fixtureEl.querySelector('#secondItem')
+ const carousel = new Carousel(carouselEl, {
+ interval: 1814
+ })
+
+ expect(carousel._config.interval).toEqual(1814)
+
+ carousel.cycle()
+
+ expect(carousel._config.interval).toEqual(7)
+
+ carousel._activeElement = secondItemEl
+ carousel.cycle()
+
+ expect(carousel._config.interval).toEqual(9385)
+ })
+ })
+
+ describe('to', () => {
+ it('should go directly to the provided index', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="item1" class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div id="item3" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+
+ carousel.to(2)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+ resolve()
+ })
+ })
+ })
+
+ it('should return to a previous slide if the provided index is lower than the current', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div id="item3" class="carousel-item active">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+
+ carousel.to(1)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
+ resolve()
+ })
+ })
+ })
+
+ it('should do nothing if a wrong index is provided', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_slide')
+
+ carousel.to(25)
+
+ expect(spy).not.toHaveBeenCalled()
+
+ spy.calls.reset()
+
+ carousel.to(-5)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should not continue if the provided is the same compare to the current one', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_slide')
+
+ carousel.to(0)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should wait before performing to if a slide is sliding', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spyOne = spyOn(EventHandler, 'one').and.callThrough()
+ const spySlide = spyOn(carousel, '_slide')
+
+ carousel._isSliding = true
+ carousel.to(1)
+
+ expect(spySlide).not.toHaveBeenCalled()
+ expect(spyOne).toHaveBeenCalled()
+
+ const spyTo = spyOn(carousel, 'to')
+
+ EventHandler.trigger(carouselEl, 'slid.bs.carousel')
+
+ setTimeout(() => {
+ expect(spyTo).toHaveBeenCalledWith(1)
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('rtl function', () => {
+ it('"_directionToOrder" and "_orderToDirection" must return the right results', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(carousel._directionToOrder('left')).toEqual('next')
+ expect(carousel._directionToOrder('right')).toEqual('prev')
+
+ expect(carousel._orderToDirection('next')).toEqual('left')
+ expect(carousel._orderToDirection('prev')).toEqual('right')
+ })
+
+ it('"_directionToOrder" and "_orderToDirection" must return the right results when rtl=true', () => {
+ document.documentElement.dir = 'rtl'
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ expect(isRTL()).toBeTrue()
+
+ expect(carousel._directionToOrder('left')).toEqual('prev')
+ expect(carousel._directionToOrder('right')).toEqual('next')
+
+ expect(carousel._orderToDirection('next')).toEqual('right')
+ expect(carousel._orderToDirection('prev')).toEqual('left')
+ document.documentElement.dir = 'ltl'
+ })
+
+ it('"_slide" has to call _directionToOrder and "_orderToDirection"', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_orderToDirection').and.callThrough()
+
+ carousel._slide(carousel._directionToOrder('left'))
+ expect(spy).toHaveBeenCalledWith('next')
+
+ carousel._slide(carousel._directionToOrder('right'))
+ expect(spy).toHaveBeenCalledWith('prev')
+ })
+
+ it('"_slide" has to call "_directionToOrder" and "_orderToDirection" when rtl=true', () => {
+ document.documentElement.dir = 'rtl'
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ const spy = spyOn(carousel, '_orderToDirection').and.callThrough()
+
+ carousel._slide(carousel._directionToOrder('left'))
+ expect(spy).toHaveBeenCalledWith('prev')
+
+ carousel._slide(carousel._directionToOrder('right'))
+ expect(spy).toHaveBeenCalledWith('next')
+
+ document.documentElement.dir = 'ltl'
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a carousel', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-bs-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const addEventSpy = spyOn(carouselEl, 'addEventListener').and.callThrough()
+ const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough()
+
+ // Headless browser does not support touch events, so need to fake it
+ // to test that touch events are add/removed properly.
+ document.documentElement.ontouchstart = noop
+
+ const carousel = new Carousel(carouselEl)
+ const swipeHelperSpy = spyOn(carousel._swipeHelper, 'dispose').and.callThrough()
+
+ const expectedArgs = [
+ ['keydown', jasmine.any(Function), jasmine.any(Boolean)],
+ ['mouseover', jasmine.any(Function), jasmine.any(Boolean)],
+ ['mouseout', jasmine.any(Function), jasmine.any(Boolean)],
+ ...(carousel._swipeHelper._supportPointerEvents ?
+ [
+ ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
+ ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
+ ] :
+ [
+ ['touchstart', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchmove', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchend', jasmine.any(Function), jasmine.any(Boolean)]
+ ])
+ ]
+
+ expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+ carousel.dispose()
+
+ expect(carousel._swipeHelper).toBeNull()
+ expect(removeEventSpy).toHaveBeenCalledWith(carouselEl, Carousel.EVENT_KEY)
+ expect(swipeHelperSpy).toHaveBeenCalled()
+
+ delete document.documentElement.ontouchstart
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+ expect(Carousel.getInstance(div)).toBeInstanceOf(Carousel)
+ })
+
+ it('should return null when there is no carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Carousel.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ expect(Carousel.getOrCreateInstance(div)).toEqual(carousel)
+ expect(Carousel.getInstance(div)).toEqual(Carousel.getOrCreateInstance(div, {}))
+ expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel)
+ })
+
+ it('should return new instance when there is no carousel instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Carousel.getInstance(div)).toBeNull()
+ expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel)
+ })
+
+ it('should return new instance when there is no carousel instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Carousel.getInstance(div)).toBeNull()
+ const carousel = Carousel.getOrCreateInstance(div, {
+ interval: 1
+ })
+ expect(carousel).toBeInstanceOf(Carousel)
+
+ expect(carousel._config.interval).toEqual(1)
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div, {
+ interval: 1
+ })
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+
+ const carousel2 = Carousel.getOrCreateInstance(div, {
+ interval: 2
+ })
+ expect(carousel).toBeInstanceOf(Carousel)
+ expect(carousel2).toEqual(carousel)
+
+ expect(carousel2._config.interval).toEqual(1)
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a carousel', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock)
+
+ expect(Carousel.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a carousel', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock)
+
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+ })
+
+ it('should call to if the config is a number', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+ const slideTo = 2
+
+ const spy = spyOn(carousel, 'to')
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock, slideTo)
+
+ expect(spy).toHaveBeenCalledWith(slideTo)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.carousel.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('data-api', () => {
+ it('should init carousels with data-bs-ride="carousel" on load', () => {
+ fixtureEl.innerHTML = '<div data-bs-ride="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const loadEvent = createEvent('load')
+
+ window.dispatchEvent(loadEvent)
+ const carousel = Carousel.getInstance(carouselEl)
+ expect(carousel._interval).not.toBeNull()
+ })
+
+ it('should create carousel and go to the next slide on click (with real button controls)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
+ ' <button id="next" class="carousel-control-next" data-bs-target="#myCarousel" type="button" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2).toHaveClass('active')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should create carousel and go to the next slide on click (using links as controls)', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <a class="carousel-control-prev" href="#myCarousel" role="button" data-bs-slide="prev"></a>',
+ ' <a id="next" class="carousel-control-next" href="#myCarousel" role="button" data-bs-slide="next"></a>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2).toHaveClass('active')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should create carousel and go to the next slide on click with data-bs-slide-to', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide" data-bs-ride="true">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div id="next" data-bs-target="#myCarousel" data-bs-slide-to="1"></div>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2).toHaveClass('active')
+ expect(Carousel.getInstance('#myCarousel')._interval).not.toBeNull()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should do nothing if no selector on click on arrows', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
+ ' <button id="next" class="carousel-control-next" type="button" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+
+ next.click()
+
+ expect().nothing()
+ })
+
+ it('should do nothing if no carousel class on click on arrows', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
+ ' <button id="next" class="carousel-control-next" data-bs-target="#myCarousel" type="button" data-bs-slide="next"></button>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+
+ next.click()
+
+ expect().nothing()
+ })
+ })
+})
diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js
new file mode 100644
index 0000000..9c86719
--- /dev/null
+++ b/js/tests/unit/collapse.spec.js
@@ -0,0 +1,1062 @@
+import Collapse from '../../src/collapse'
+import EventHandler from '../../src/dom/event-handler'
+import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Collapse', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Collapse.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Collapse.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Collapse.DATA_KEY).toEqual('bs.collapse')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = '<div class="my-collapse"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div.my-collapse')
+ const collapseBySelector = new Collapse('div.my-collapse')
+ const collapseByElement = new Collapse(collapseEl)
+
+ expect(collapseBySelector._element).toEqual(collapseEl)
+ expect(collapseByElement._element).toEqual(collapseEl)
+ })
+
+ it('should allow jquery object in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const fakejQueryObject = {
+ 0: myCollapseEl,
+ jquery: 'foo'
+ }
+ const collapse = new Collapse(collapseEl, {
+ parent: fakejQueryObject
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+
+ it('should allow non jquery object in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const collapse = new Collapse(collapseEl, {
+ parent: myCollapseEl
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+
+ it('should allow string selector in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const collapse = new Collapse(collapseEl, {
+ parent: 'div.my-collapse'
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should call show method if show class is not present', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl)
+
+ const spy = spyOn(collapse, 'show')
+
+ collapse.toggle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should call hide method if show class is present', () => {
+ fixtureEl.innerHTML = '<div class="show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('.show')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const spy = spyOn(collapse, 'hide')
+
+ collapse.toggle()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should find collapse children if they have collapse class too not only data-bs-parent', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-bs-toggle="collapse" href="#">Toggle item 1</a>',
+ ' <div id="collapse1" class="collapse show">Lorem ipsum 1</div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="triggerCollapse2" data-bs-toggle="collapse" href="#">Toggle item 2</a>',
+ ' <div id="collapse2" class="collapse">Lorem ipsum 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const parent = fixtureEl.querySelector('.my-collapse')
+ const collapseEl1 = fixtureEl.querySelector('#collapse1')
+ const collapseEl2 = fixtureEl.querySelector('#collapse2')
+
+ const collapseList = [].concat(...fixtureEl.querySelectorAll('.collapse'))
+ .map(el => new Collapse(el, {
+ parent,
+ toggle: false
+ }))
+
+ collapseEl2.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl2).toHaveClass('show')
+ expect(collapseEl1).not.toHaveClass('show')
+ resolve()
+ })
+
+ collapseList[1].toggle()
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should do nothing if is transitioning', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse._isTransitioning = true
+ collapse.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div class="show"></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse.show()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should show a collapsed element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="collapse" style="height: 0px;"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('show.bs.collapse', () => {
+ expect(collapseEl.style.height).toEqual('0px')
+ })
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl).toHaveClass('show')
+ expect(collapseEl.style.height).toEqual('')
+ resolve()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should show a collapsed element on width', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="collapse collapse-horizontal" style="width: 0px;"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('show.bs.collapse', () => {
+ expect(collapseEl.style.width).toEqual('0px')
+ })
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl).toHaveClass('show')
+ expect(collapseEl.style.width).toEqual('')
+ resolve()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should collapse only the first collapse', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="card" id="accordion1">',
+ ' <div id="collapse1" class="collapse"></div>',
+ '</div>',
+ '<div class="card" id="accordion2">',
+ ' <div id="collapse2" class="collapse show"></div>',
+ '</div>'
+ ].join('')
+
+ const el1 = fixtureEl.querySelector('#collapse1')
+ const el2 = fixtureEl.querySelector('#collapse2')
+ const collapse = new Collapse(el1, {
+ toggle: false
+ })
+
+ el1.addEventListener('shown.bs.collapse', () => {
+ expect(el1).toHaveClass('show')
+ expect(el2).toHaveClass('show')
+ resolve()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should be able to handle toggling of other children siblings', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="parentGroup" class="accordion">',
+ ' <div id="parentHeader" class="accordion-header">',
+ ' <button data-bs-target="#parentContent" data-bs-toggle="collapse" role="button" class="accordion-toggle">Parent</button>',
+ ' </div>',
+ ' <div id="parentContent" class="accordion-collapse collapse" aria-labelledby="parentHeader" data-bs-parent="#parentGroup">',
+ ' <div class="accordion-body">',
+ ' <div id="childGroup" class="accordion">',
+ ' <div class="accordion-item">',
+ ' <div id="childHeader1" class="accordion-header">',
+ ' <button data-bs-target="#childContent1" data-bs-toggle="collapse" role="button" class="accordion-toggle">Child 1</button>',
+ ' </div>',
+ ' <div id="childContent1" class="accordion-collapse collapse" aria-labelledby="childHeader1" data-bs-parent="#childGroup">',
+ ' <div>content</div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="accordion-item">',
+ ' <div id="childHeader2" class="accordion-header">',
+ ' <button data-bs-target="#childContent2" data-bs-toggle="collapse" role="button" class="accordion-toggle">Child 2</button>',
+ ' </div>',
+ ' <div id="childContent2" class="accordion-collapse collapse" aria-labelledby="childHeader2" data-bs-parent="#childGroup">',
+ ' <div>content</div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const el = selector => fixtureEl.querySelector(selector)
+
+ const parentBtn = el('[data-bs-target="#parentContent"]')
+ const childBtn1 = el('[data-bs-target="#childContent1"]')
+ const childBtn2 = el('[data-bs-target="#childContent2"]')
+
+ const parentCollapseEl = el('#parentContent')
+ const childCollapseEl1 = el('#childContent1')
+ const childCollapseEl2 = el('#childContent2')
+
+ parentCollapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(parentCollapseEl).toHaveClass('show')
+ childBtn1.click()
+ })
+ childCollapseEl1.addEventListener('shown.bs.collapse', () => {
+ expect(childCollapseEl1).toHaveClass('show')
+ childBtn2.click()
+ })
+ childCollapseEl2.addEventListener('shown.bs.collapse', () => {
+ expect(childCollapseEl2).toHaveClass('show')
+ expect(childCollapseEl1).not.toHaveClass('show')
+ resolve()
+ })
+
+ parentBtn.click()
+ })
+ })
+
+ it('should not change tab tabpanels descendants on accordion', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="accordion" id="accordionExample">',
+ ' <div class="accordion-item">',
+ ' <h2 class="accordion-header" id="headingOne">',
+ ' <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">',
+ ' Accordion Item #1',
+ ' </button>',
+ ' </h2>',
+ ' <div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne" data-bs-parent="#accordionExample">',
+ ' <div class="accordion-body">',
+ ' <nav>',
+ ' <div class="nav nav-tabs" id="nav-tab" role="tablist">',
+ ' <button class="nav-link active" id="nav-home-tab" data-bs-toggle="tab" data-bs-target="#nav-home" type="button" role="tab" aria-controls="nav-home" aria-selected="true">Home</button>',
+ ' <button class="nav-link" id="nav-profile-tab" data-bs-toggle="tab" data-bs-target="#nav-profile" type="button" role="tab" aria-controls="nav-profile" aria-selected="false">Profile</button>',
+ ' </div>',
+ ' </nav>',
+ ' <div class="tab-content" id="nav-tabContent">',
+ ' <div class="tab-pane fade show active" id="nav-home" role="tabpanel" aria-labelledby="nav-home-tab">Home</div>',
+ ' <div class="tab-pane fade" id="nav-profile" role="tabpanel" aria-labelledby="nav-profile-tab">Profile</div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('#collapseOne')
+ const activeTabPane = fixtureEl.querySelector('#nav-home')
+ const collapse = new Collapse(el)
+ let times = 1
+
+ el.addEventListener('hidden.bs.collapse', () => {
+ collapse.show()
+ })
+
+ el.addEventListener('shown.bs.collapse', () => {
+ expect(activeTabPane).toHaveClass('show')
+ times++
+ if (times === 2) {
+ resolve()
+ }
+
+ collapse.hide()
+ })
+
+ collapse.show()
+ })
+ })
+
+ it('should not fire shown when show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="collapse"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ }
+
+ collapseEl.addEventListener('show.bs.collapse', event => {
+ event.preventDefault()
+ expectEnd()
+ })
+
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ reject(new Error('should not fire shown event'))
+ })
+
+ collapse.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should do nothing if is transitioning', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse._isTransitioning = true
+ collapse.hide()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const spy = spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse.hide()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should hide a collapsed element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('hidden.bs.collapse', () => {
+ expect(collapseEl).not.toHaveClass('show')
+ expect(collapseEl.style.height).toEqual('')
+ resolve()
+ })
+
+ collapse.hide()
+ })
+ })
+
+ it('should not fire hidden when hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ }
+
+ collapseEl.addEventListener('hide.bs.collapse', event => {
+ event.preventDefault()
+ expectEnd()
+ })
+
+ collapseEl.addEventListener('hidden.bs.collapse', () => {
+ reject(new Error('should not fire hidden event'))
+ })
+
+ collapse.hide()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a collapse', () => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ expect(Collapse.getInstance(collapseEl)).toEqual(collapse)
+
+ collapse.dispose()
+
+ expect(Collapse.getInstance(collapseEl)).toBeNull()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should prevent url change if click on nested elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-bs-toggle="collapse" class="collapsed" href="#collapse">',
+ ' <span id="nested"></span>',
+ '</a>',
+ '<div id="collapse" class="collapse"></div>'
+ ].join('')
+
+ const triggerEl = fixtureEl.querySelector('a')
+ const nestedTriggerEl = fixtureEl.querySelector('#nested')
+
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ triggerEl.addEventListener('click', event => {
+ expect(event.target.isEqualNode(nestedTriggerEl)).toBeTrue()
+ expect(event.delegateTarget.isEqualNode(triggerEl)).toBeTrue()
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ nestedTriggerEl.click()
+ })
+ })
+
+ it('should show multiple collapsed elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-bs-toggle="collapse" class="collapsed" href=".multi"></a>',
+ '<div id="collapse1" class="collapse multi"></div>',
+ '<div id="collapse2" class="collapse multi"></div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('a')
+ const collapse1 = fixtureEl.querySelector('#collapse1')
+ const collapse2 = fixtureEl.querySelector('#collapse2')
+
+ collapse2.addEventListener('shown.bs.collapse', () => {
+ expect(trigger.getAttribute('aria-expanded')).toEqual('true')
+ expect(trigger).not.toHaveClass('collapsed')
+ expect(collapse1).toHaveClass('show')
+ expect(collapse1).toHaveClass('show')
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should hide multiple collapsed elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-bs-toggle="collapse" href=".multi"></a>',
+ '<div id="collapse1" class="collapse multi show"></div>',
+ '<div id="collapse2" class="collapse multi show"></div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('a')
+ const collapse1 = fixtureEl.querySelector('#collapse1')
+ const collapse2 = fixtureEl.querySelector('#collapse2')
+
+ collapse2.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger.getAttribute('aria-expanded')).toEqual('false')
+ expect(trigger).toHaveClass('collapsed')
+ expect(collapse1).not.toHaveClass('show')
+ expect(collapse1).not.toHaveClass('show')
+ resolve()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should remove "collapsed" class from target when collapse is shown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a id="link1" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
+ '<a id="link2" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
+ '<div id="test1"></div>'
+ ].join('')
+
+ const link1 = fixtureEl.querySelector('#link1')
+ const link2 = fixtureEl.querySelector('#link2')
+ const collapseTest1 = fixtureEl.querySelector('#test1')
+
+ collapseTest1.addEventListener('shown.bs.collapse', () => {
+ expect(link1.getAttribute('aria-expanded')).toEqual('true')
+ expect(link2.getAttribute('aria-expanded')).toEqual('true')
+ expect(link1).not.toHaveClass('collapsed')
+ expect(link2).not.toHaveClass('collapsed')
+ resolve()
+ })
+
+ link1.click()
+ })
+ })
+
+ it('should add "collapsed" class to target when collapse is hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a id="link1" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
+ '<a id="link2" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
+ '<div id="test1" class="show"></div>'
+ ].join('')
+
+ const link1 = fixtureEl.querySelector('#link1')
+ const link2 = fixtureEl.querySelector('#link2')
+ const collapseTest1 = fixtureEl.querySelector('#test1')
+
+ collapseTest1.addEventListener('hidden.bs.collapse', () => {
+ expect(link1.getAttribute('aria-expanded')).toEqual('false')
+ expect(link2.getAttribute('aria-expanded')).toEqual('false')
+ expect(link1).toHaveClass('collapsed')
+ expect(link2).toHaveClass('collapsed')
+ resolve()
+ })
+
+ link1.click()
+ })
+ })
+
+ it('should allow accordion to use children other than card', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" class="collapse" role="tabpanel" aria-labelledby="headingThree" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" class="collapse show" role="tabpanel" aria-labelledby="headingTwo" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOne = fixtureEl.querySelector('#collapseOne')
+ const collapseTwo = fixtureEl.querySelector('#collapseTwo')
+
+ collapseOne.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne).toHaveClass('show')
+ expect(collapseTwo).not.toHaveClass('show')
+
+ collapseTwo.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne).not.toHaveClass('show')
+ expect(collapseTwo).toHaveClass('show')
+ resolve()
+ })
+
+ triggerTwo.click()
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should not prevent event for input', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<input type="checkbox" data-bs-toggle="collapse" data-bs-target="#collapsediv1">',
+ '<div id="collapsediv1"></div>'
+ ].join('')
+
+ const target = fixtureEl.querySelector('input')
+ const collapseEl = fixtureEl.querySelector('#collapsediv1')
+
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl).toHaveClass('show')
+ expect(target.checked).toBeTrue()
+ resolve()
+ })
+
+ target.click()
+ })
+ })
+
+ it('should allow accordion to contain nested elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="row">',
+ ' <div class="col-lg-6">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" class="collapse" role="tabpanel" aria-labelledby="headingThree" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="col-lg-6">',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" class="collapse show" role="tabpanel" aria-labelledby="headingTwo" data-bs-parent="#accordion"></div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerEl = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwoEl = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOneEl = fixtureEl.querySelector('#collapseOne')
+ const collapseTwoEl = fixtureEl.querySelector('#collapseTwo')
+
+ collapseOneEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOneEl).toHaveClass('show')
+ expect(triggerEl).not.toHaveClass('collapsed')
+ expect(triggerEl.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(collapseTwoEl).not.toHaveClass('show')
+ expect(triggerTwoEl).toHaveClass('collapsed')
+ expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('false')
+
+ collapseTwoEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOneEl).not.toHaveClass('show')
+ expect(triggerEl).toHaveClass('collapsed')
+ expect(triggerEl.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(collapseTwoEl).toHaveClass('show')
+ expect(triggerTwoEl).not.toHaveClass('collapsed')
+ expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ triggerTwoEl.click()
+ })
+
+ triggerEl.click()
+ })
+ })
+
+ it('should allow accordion to target multiple elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <a id="linkTriggerOne" data-bs-toggle="collapse" data-bs-target=".collapseOne" href="#" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" data-bs-target=".collapseTwo" href="#" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseOneOne" class="collapse collapseOne" role="tabpanel" data-bs-parent="#accordion"></div>',
+ ' <div id="collapseOneTwo" class="collapse collapseOne" role="tabpanel" data-bs-parent="#accordion"></div>',
+ ' <div id="collapseTwoOne" class="collapse collapseTwo" role="tabpanel" data-bs-parent="#accordion"></div>',
+ ' <div id="collapseTwoTwo" class="collapse collapseTwo" role="tabpanel" data-bs-parent="#accordion"></div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTriggerOne')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOneOne = fixtureEl.querySelector('#collapseOneOne')
+ const collapseOneTwo = fixtureEl.querySelector('#collapseOneTwo')
+ const collapseTwoOne = fixtureEl.querySelector('#collapseTwoOne')
+ const collapseTwoTwo = fixtureEl.querySelector('#collapseTwoTwo')
+ const collapsedElements = {
+ one: false,
+ two: false
+ }
+
+ function firstTest() {
+ expect(collapseOneOne).toHaveClass('show')
+ expect(collapseOneTwo).toHaveClass('show')
+
+ expect(collapseTwoOne).not.toHaveClass('show')
+ expect(collapseTwoTwo).not.toHaveClass('show')
+
+ triggerTwo.click()
+ }
+
+ function secondTest() {
+ expect(collapseOneOne).not.toHaveClass('show')
+ expect(collapseOneTwo).not.toHaveClass('show')
+
+ expect(collapseTwoOne).toHaveClass('show')
+ expect(collapseTwoTwo).toHaveClass('show')
+ resolve()
+ }
+
+ collapseOneOne.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.one) {
+ firstTest()
+ } else {
+ collapsedElements.one = true
+ }
+ })
+
+ collapseOneTwo.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.one) {
+ firstTest()
+ } else {
+ collapsedElements.one = true
+ }
+ })
+
+ collapseTwoOne.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.two) {
+ secondTest()
+ } else {
+ collapsedElements.two = true
+ }
+ })
+
+ collapseTwoTwo.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.two) {
+ secondTest()
+ } else {
+ collapsedElements.two = true
+ }
+ })
+
+ trigger.click()
+ })
+ })
+
+ it('should collapse accordion children but not nested accordion children', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" data-bs-parent="#accordion" class="collapse" role="tabpanel" aria-labelledby="headingThree">',
+ ' <div id="nestedAccordion">',
+ ' <div class="item">',
+ ' <a id="nestedLinkTrigger" data-bs-toggle="collapse" href="#nestedCollapseOne" aria-expanded="false" aria-controls="nestedCollapseOne"></a>',
+ ' <div id="nestedCollapseOne" data-bs-parent="#nestedAccordion" class="collapse" role="tabpanel" aria-labelledby="headingThree"></div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" data-bs-parent="#accordion" class="collapse show" role="tabpanel" aria-labelledby="headingTwo"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const nestedTrigger = fixtureEl.querySelector('#nestedLinkTrigger')
+ const collapseOne = fixtureEl.querySelector('#collapseOne')
+ const collapseTwo = fixtureEl.querySelector('#collapseTwo')
+ const nestedCollapseOne = fixtureEl.querySelector('#nestedCollapseOne')
+
+ function handlerCollapseOne() {
+ expect(collapseOne).toHaveClass('show')
+ expect(collapseTwo).not.toHaveClass('show')
+ expect(nestedCollapseOne).not.toHaveClass('show')
+
+ nestedCollapseOne.addEventListener('shown.bs.collapse', handlerNestedCollapseOne)
+ nestedTrigger.click()
+ collapseOne.removeEventListener('shown.bs.collapse', handlerCollapseOne)
+ }
+
+ function handlerNestedCollapseOne() {
+ expect(collapseOne).toHaveClass('show')
+ expect(collapseTwo).not.toHaveClass('show')
+ expect(nestedCollapseOne).toHaveClass('show')
+
+ collapseTwo.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne).not.toHaveClass('show')
+ expect(collapseTwo).toHaveClass('show')
+ expect(nestedCollapseOne).toHaveClass('show')
+ resolve()
+ })
+
+ triggerTwo.click()
+ nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne)
+ }
+
+ collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne)
+ trigger.click()
+ })
+ })
+
+ it('should add "collapsed" class and set aria-expanded to triggers only when all the targeted collapse are hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a id="trigger1" role="button" data-bs-toggle="collapse" href="#test1"></a>',
+ '<a id="trigger2" role="button" data-bs-toggle="collapse" href="#test2"></a>',
+ '<a id="trigger3" role="button" data-bs-toggle="collapse" href=".multi"></a>',
+ '<div id="test1" class="multi"></div>',
+ '<div id="test2" class="multi"></div>'
+ ].join('')
+
+ const trigger1 = fixtureEl.querySelector('#trigger1')
+ const trigger2 = fixtureEl.querySelector('#trigger2')
+ const trigger3 = fixtureEl.querySelector('#trigger3')
+ const target1 = fixtureEl.querySelector('#test1')
+ const target2 = fixtureEl.querySelector('#test2')
+
+ const target2Shown = () => {
+ expect(trigger1).not.toHaveClass('collapsed')
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger2).not.toHaveClass('collapsed')
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger3).not.toHaveClass('collapsed')
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('true')
+
+ target2.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger1).not.toHaveClass('collapsed')
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger2).toHaveClass('collapsed')
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger3).not.toHaveClass('collapsed')
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('true')
+
+ target1.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger1).toHaveClass('collapsed')
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger2).toHaveClass('collapsed')
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger3).toHaveClass('collapsed')
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('false')
+ resolve()
+ })
+
+ trigger1.click()
+ })
+
+ trigger2.click()
+ }
+
+ target2.addEventListener('shown.bs.collapse', target2Shown)
+ trigger3.click()
+ })
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a collapse', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.collapse.call(jQueryMock)
+
+ expect(Collapse.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a collapse', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.collapse.call(jQueryMock)
+
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.collapse.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+ expect(Collapse.getInstance(div)).toBeInstanceOf(Collapse)
+ })
+
+ it('should return null when there is no collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ expect(Collapse.getOrCreateInstance(div)).toEqual(collapse)
+ expect(Collapse.getInstance(div)).toEqual(Collapse.getOrCreateInstance(div, {}))
+ expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
+ })
+
+ it('should return new instance when there is no collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toBeNull()
+ expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
+ })
+
+ it('should return new instance when there is no collapse instance with given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toBeNull()
+ const collapse = Collapse.getOrCreateInstance(div, {
+ toggle: false
+ })
+ expect(collapse).toBeInstanceOf(Collapse)
+
+ expect(collapse._config.toggle).toBeFalse()
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div, {
+ toggle: false
+ })
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+
+ const collapse2 = Collapse.getOrCreateInstance(div, {
+ toggle: true
+ })
+ expect(collapse).toBeInstanceOf(Collapse)
+ expect(collapse2).toEqual(collapse)
+
+ expect(collapse2._config.toggle).toBeFalse()
+ })
+ })
+})
diff --git a/js/tests/unit/dom/data.spec.js b/js/tests/unit/dom/data.spec.js
new file mode 100644
index 0000000..e898cbb
--- /dev/null
+++ b/js/tests/unit/dom/data.spec.js
@@ -0,0 +1,106 @@
+import Data from '../../../src/dom/data'
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('Data', () => {
+ const TEST_KEY = 'bs.test'
+ const UNKNOWN_KEY = 'bs.unknown'
+ const TEST_DATA = {
+ test: 'bsData'
+ }
+
+ let fixtureEl
+ let div
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ beforeEach(() => {
+ fixtureEl.innerHTML = '<div></div>'
+ div = fixtureEl.querySelector('div')
+ })
+
+ afterEach(() => {
+ Data.remove(div, TEST_KEY)
+ clearFixture()
+ })
+
+ it('should return null for unknown elements', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+
+ expect(Data.get(null)).toBeNull()
+ expect(Data.get(undefined)).toBeNull()
+ expect(Data.get(document.createElement('div'), TEST_KEY)).toBeNull()
+ })
+
+ it('should return null for unknown keys', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+
+ expect(Data.get(div, null)).toBeNull()
+ expect(Data.get(div, undefined)).toBeNull()
+ expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
+ })
+
+ it('should store data for an element with a given key and return it', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+
+ expect(Data.get(div, TEST_KEY)).toEqual(data)
+ })
+
+ it('should overwrite data if something is already stored', () => {
+ const data = { ...TEST_DATA }
+ const copy = { ...data }
+
+ Data.set(div, TEST_KEY, data)
+ Data.set(div, TEST_KEY, copy)
+
+ // Using `toBe` since spread creates a shallow copy
+ expect(Data.get(div, TEST_KEY)).not.toBe(data)
+ expect(Data.get(div, TEST_KEY)).toBe(copy)
+ })
+
+ it('should do nothing when an element has nothing stored', () => {
+ Data.remove(div, TEST_KEY)
+
+ expect().nothing()
+ })
+
+ it('should remove nothing for an unknown key', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+ Data.remove(div, UNKNOWN_KEY)
+
+ expect(Data.get(div, TEST_KEY)).toEqual(data)
+ })
+
+ it('should remove data for a given key', () => {
+ const data = { ...TEST_DATA }
+
+ Data.set(div, TEST_KEY, data)
+ Data.remove(div, TEST_KEY)
+
+ expect(Data.get(div, TEST_KEY)).toBeNull()
+ })
+
+ /* eslint-disable no-console */
+ it('should console.error a message if called with multiple keys', () => {
+ console.error = jasmine.createSpy('console.error')
+
+ const data = { ...TEST_DATA }
+ const copy = { ...data }
+
+ Data.set(div, TEST_KEY, data)
+ Data.set(div, UNKNOWN_KEY, copy)
+
+ expect(console.error).toHaveBeenCalled()
+ expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
+ })
+ /* eslint-enable no-console */
+})
diff --git a/js/tests/unit/dom/event-handler.spec.js b/js/tests/unit/dom/event-handler.spec.js
new file mode 100644
index 0000000..623b9c1
--- /dev/null
+++ b/js/tests/unit/dom/event-handler.spec.js
@@ -0,0 +1,480 @@
+import EventHandler from '../../../src/dom/event-handler'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import { noop } from '../../../src/util'
+
+describe('EventHandler', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('on', () => {
+ it('should not add event listener if the event is not a string', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, null, noop)
+ EventHandler.on(null, 'click', noop)
+
+ expect().nothing()
+ })
+
+ it('should add event listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'click', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ div.click()
+ })
+ })
+
+ it('should add namespaced event listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'bs.namespace', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ EventHandler.trigger(div, 'bs.namespace')
+ })
+ })
+
+ it('should add native namespaced event listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'click.namespace', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ EventHandler.trigger(div, 'click')
+ })
+ })
+
+ it('should handle event delegation', () => {
+ return new Promise(resolve => {
+ EventHandler.on(document, 'click', '.test', () => {
+ expect().nothing()
+ resolve()
+ })
+
+ fixtureEl.innerHTML = '<div class="test"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ div.click()
+ })
+ })
+
+ it('should handle mouseenter/mouseleave like the native counterpart', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="outer">',
+ '<div class="inner">',
+ '<div class="nested">',
+ '<div class="deep"></div>',
+ '</div>',
+ '</div>',
+ '<div class="sibling"></div>',
+ '</div>'
+ ].join('')
+
+ const outer = fixtureEl.querySelector('.outer')
+ const inner = fixtureEl.querySelector('.inner')
+ const nested = fixtureEl.querySelector('.nested')
+ const deep = fixtureEl.querySelector('.deep')
+ const sibling = fixtureEl.querySelector('.sibling')
+
+ const enterSpy = jasmine.createSpy('mouseenter')
+ const leaveSpy = jasmine.createSpy('mouseleave')
+ const delegateEnterSpy = jasmine.createSpy('mouseenter')
+ const delegateLeaveSpy = jasmine.createSpy('mouseleave')
+
+ EventHandler.on(inner, 'mouseenter', enterSpy)
+ EventHandler.on(inner, 'mouseleave', leaveSpy)
+ EventHandler.on(outer, 'mouseenter', '.inner', delegateEnterSpy)
+ EventHandler.on(outer, 'mouseleave', '.inner', delegateLeaveSpy)
+
+ EventHandler.on(sibling, 'mouseenter', () => {
+ expect(enterSpy.calls.count()).toEqual(2)
+ expect(leaveSpy.calls.count()).toEqual(2)
+ expect(delegateEnterSpy.calls.count()).toEqual(2)
+ expect(delegateLeaveSpy.calls.count()).toEqual(2)
+ resolve()
+ })
+
+ const moveMouse = (from, to) => {
+ from.dispatchEvent(new MouseEvent('mouseout', {
+ bubbles: true,
+ relatedTarget: to
+ }))
+
+ to.dispatchEvent(new MouseEvent('mouseover', {
+ bubbles: true,
+ relatedTarget: from
+ }))
+ }
+
+ // from outer to deep and back to outer (nested)
+ moveMouse(outer, inner)
+ moveMouse(inner, nested)
+ moveMouse(nested, deep)
+ moveMouse(deep, nested)
+ moveMouse(nested, inner)
+ moveMouse(inner, outer)
+
+ setTimeout(() => {
+ expect(enterSpy.calls.count()).toEqual(1)
+ expect(leaveSpy.calls.count()).toEqual(1)
+ expect(delegateEnterSpy.calls.count()).toEqual(1)
+ expect(delegateLeaveSpy.calls.count()).toEqual(1)
+
+ // from outer to inner to sibling (adjacent)
+ moveMouse(outer, inner)
+ moveMouse(inner, sibling)
+ }, 20)
+ })
+ })
+ })
+
+ describe('one', () => {
+ it('should call listener just once', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ let called = 0
+ const div = fixtureEl.querySelector('div')
+ const obj = {
+ oneListener() {
+ called++
+ }
+ }
+
+ EventHandler.one(div, 'bootstrap', obj.oneListener)
+
+ EventHandler.trigger(div, 'bootstrap')
+ EventHandler.trigger(div, 'bootstrap')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should call delegated listener just once', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ let called = 0
+ const div = fixtureEl.querySelector('div')
+ const obj = {
+ oneListener() {
+ called++
+ }
+ }
+
+ EventHandler.one(fixtureEl, 'bootstrap', 'div', obj.oneListener)
+
+ EventHandler.trigger(div, 'bootstrap')
+ EventHandler.trigger(div, 'bootstrap')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+ })
+
+ describe('off', () => {
+ it('should not remove a listener', () => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.off(div, null, noop)
+ EventHandler.off(null, 'click', noop)
+ expect().nothing()
+ })
+
+ it('should remove a listener', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+ const handler = () => {
+ called++
+ }
+
+ EventHandler.on(div, 'foobar', handler)
+ EventHandler.trigger(div, 'foobar')
+
+ EventHandler.off(div, 'foobar', handler)
+ EventHandler.trigger(div, 'foobar')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove all the events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'foobar', () => {
+ called++
+ })
+ EventHandler.on(div, 'foobar', () => {
+ called++
+ })
+ EventHandler.trigger(div, 'foobar')
+
+ EventHandler.off(div, 'foobar')
+ EventHandler.trigger(div, 'foobar')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove all the namespaced listeners if namespace is passed', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'foobar.namespace', () => {
+ called++
+ })
+ EventHandler.on(div, 'foofoo.namespace', () => {
+ called++
+ })
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ EventHandler.off(div, '.namespace')
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the namespaced listeners', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let calledCallback1 = 0
+ let calledCallback2 = 0
+
+ EventHandler.on(div, 'foobar.namespace', () => {
+ calledCallback1++
+ })
+ EventHandler.on(div, 'foofoo.namespace', () => {
+ calledCallback2++
+ })
+
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.off(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foobar.namespace')
+
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ setTimeout(() => {
+ expect(calledCallback1).toEqual(1)
+ expect(calledCallback2).toEqual(1)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the all the namespaced listeners for native events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'click.namespace', () => {
+ called++
+ })
+ EventHandler.on(div, 'click.namespace2', () => {
+ called++
+ })
+
+ EventHandler.trigger(div, 'click')
+ EventHandler.off(div, 'click')
+ EventHandler.trigger(div, 'click')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the specified namespaced listeners for native events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called1 = 0
+ let called2 = 0
+
+ EventHandler.on(div, 'click.namespace', () => {
+ called1++
+ })
+ EventHandler.on(div, 'click.namespace2', () => {
+ called2++
+ })
+ EventHandler.trigger(div, 'click')
+
+ EventHandler.off(div, 'click.namespace')
+ EventHandler.trigger(div, 'click')
+
+ setTimeout(() => {
+ expect(called1).toEqual(1)
+ expect(called2).toEqual(2)
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove a listener registered by .one', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const handler = () => {
+ reject(new Error('called'))
+ }
+
+ EventHandler.one(div, 'foobar', handler)
+ EventHandler.off(div, 'foobar', handler)
+
+ EventHandler.trigger(div, 'foobar')
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 20)
+ })
+ })
+
+ it('should remove the correct delegated event listener', () => {
+ const element = document.createElement('div')
+ const subelement = document.createElement('span')
+ element.append(subelement)
+
+ const anchor = document.createElement('a')
+ element.append(anchor)
+
+ let i = 0
+ const handler = () => {
+ i++
+ }
+
+ EventHandler.on(element, 'click', 'a', handler)
+ EventHandler.on(element, 'click', 'span', handler)
+
+ fixtureEl.append(element)
+
+ EventHandler.trigger(anchor, 'click')
+ EventHandler.trigger(subelement, 'click')
+
+ // first listeners called
+ expect(i).toEqual(2)
+
+ EventHandler.off(element, 'click', 'span', handler)
+ EventHandler.trigger(subelement, 'click')
+
+ // removed listener not called
+ expect(i).toEqual(2)
+
+ EventHandler.trigger(anchor, 'click')
+
+ // not removed listener called
+ expect(i).toEqual(3)
+
+ EventHandler.on(element, 'click', 'span', handler)
+ EventHandler.trigger(anchor, 'click')
+ EventHandler.trigger(subelement, 'click')
+
+ // listener re-registered
+ expect(i).toEqual(5)
+
+ EventHandler.off(element, 'click', 'span')
+ EventHandler.trigger(subelement, 'click')
+
+ // listener removed again
+ expect(i).toEqual(5)
+ })
+ })
+
+ describe('general functionality', () => {
+ it('should hydrate properties, and make them configurable', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div id="div1">',
+ ' <div id="div2"></div>',
+ ' <div id="div3"></div>',
+ '</div>'
+ ].join('')
+
+ const div1 = fixtureEl.querySelector('#div1')
+ const div2 = fixtureEl.querySelector('#div2')
+
+ EventHandler.on(div1, 'click', event => {
+ expect(event.currentTarget).toBe(div2)
+ expect(event.delegateTarget).toBe(div1)
+ expect(event.originalTarget).toBeNull()
+
+ Object.defineProperty(event, 'currentTarget', {
+ configurable: true,
+ get() {
+ return div1
+ }
+ })
+
+ expect(event.currentTarget).toBe(div1)
+ resolve()
+ })
+
+ expect(() => {
+ EventHandler.trigger(div1, 'click', { originalTarget: null, currentTarget: div2 })
+ }).not.toThrowError(TypeError)
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/dom/manipulator.spec.js b/js/tests/unit/dom/manipulator.spec.js
new file mode 100644
index 0000000..4561e2e
--- /dev/null
+++ b/js/tests/unit/dom/manipulator.spec.js
@@ -0,0 +1,135 @@
+import Manipulator from '../../../src/dom/manipulator'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+
+describe('Manipulator', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('setDataAttribute', () => {
+ it('should set data attribute prefixed with bs', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.setDataAttribute(div, 'key', 'value')
+ expect(div.getAttribute('data-bs-key')).toEqual('value')
+ })
+
+ it('should set data attribute in kebab case', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.setDataAttribute(div, 'testKey', 'value')
+ expect(div.getAttribute('data-bs-test-key')).toEqual('value')
+ })
+ })
+
+ describe('removeDataAttribute', () => {
+ it('should only remove bs-prefixed data attribute', () => {
+ fixtureEl.innerHTML = '<div data-bs-key="value" data-key-bs="postfixed" data-key="value"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.removeDataAttribute(div, 'key')
+ expect(div.getAttribute('data-bs-key')).toBeNull()
+ expect(div.getAttribute('data-key-bs')).toEqual('postfixed')
+ expect(div.getAttribute('data-key')).toEqual('value')
+ })
+
+ it('should remove data attribute in kebab case', () => {
+ fixtureEl.innerHTML = '<div data-bs-test-key="value"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.removeDataAttribute(div, 'testKey')
+ expect(div.getAttribute('data-bs-test-key')).toBeNull()
+ })
+ })
+
+ describe('getDataAttributes', () => {
+ it('should return an empty object for null', () => {
+ expect(Manipulator.getDataAttributes(null)).toEqual({})
+ expect().nothing()
+ })
+
+ it('should get only bs-prefixed data attributes without bs namespace', () => {
+ fixtureEl.innerHTML = '<div data-bs-toggle="tabs" data-bs-target="#element" data-another="value" data-target-bs="#element" data-in-bs-out="in-between"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttributes(div)).toEqual({
+ toggle: 'tabs',
+ target: '#element'
+ })
+ })
+
+ it('should omit `bs-config` data attribute', () => {
+ fixtureEl.innerHTML = '<div data-bs-toggle="tabs" data-bs-target="#element" data-bs-config=\'{"testBool":false}\'></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttributes(div)).toEqual({
+ toggle: 'tabs',
+ target: '#element'
+ })
+ })
+ })
+
+ describe('getDataAttribute', () => {
+ it('should only get bs-prefixed data attribute', () => {
+ fixtureEl.innerHTML = '<div data-bs-key="value" data-test-bs="postFixed" data-toggle="tab"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'key')).toEqual('value')
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeNull()
+ expect(Manipulator.getDataAttribute(div, 'toggle')).toBeNull()
+ })
+
+ it('should get data attribute in kebab case', () => {
+ fixtureEl.innerHTML = '<div data-bs-test-key="value" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'testKey')).toEqual('value')
+ })
+
+ it('should normalize data', () => {
+ fixtureEl.innerHTML = '<div data-bs-test="false" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeFalse()
+
+ div.setAttribute('data-bs-test', 'true')
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeTrue()
+
+ div.setAttribute('data-bs-test', '1')
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(1)
+ })
+
+ it('should normalize json data', () => {
+ fixtureEl.innerHTML = '<div data-bs-test=\'{"delay":{"show":100,"hide":10}}\'></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual({ delay: { show: 100, hide: 10 } })
+
+ const objectData = { 'Super Hero': ['Iron Man', 'Super Man'], testNum: 90, url: 'http://localhost:8080/test?foo=bar' }
+ const dataStr = JSON.stringify(objectData)
+ div.setAttribute('data-bs-test', encodeURIComponent(dataStr))
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData)
+
+ div.setAttribute('data-bs-test', dataStr)
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData)
+ })
+ })
+})
diff --git a/js/tests/unit/dom/selector-engine.spec.js b/js/tests/unit/dom/selector-engine.spec.js
new file mode 100644
index 0000000..0245896
--- /dev/null
+++ b/js/tests/unit/dom/selector-engine.spec.js
@@ -0,0 +1,236 @@
+import SelectorEngine from '../../../src/dom/selector-engine'
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('SelectorEngine', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('find', () => {
+ it('should find elements', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(SelectorEngine.find('div', fixtureEl)).toEqual([div])
+ })
+
+ it('should find elements globally', () => {
+ fixtureEl.innerHTML = '<div id="test"></div>'
+
+ const div = fixtureEl.querySelector('#test')
+
+ expect(SelectorEngine.find('#test')).toEqual([div])
+ })
+
+ it('should handle :scope selectors', () => {
+ fixtureEl.innerHTML = [
+ '<ul>',
+ ' <li></li>',
+ ' <li>',
+ ' <a href="#" class="active">link</a>',
+ ' </li>',
+ ' <li></li>',
+ '</ul>'
+ ].join('')
+
+ const listEl = fixtureEl.querySelector('ul')
+ const aActive = fixtureEl.querySelector('.active')
+
+ expect(SelectorEngine.find(':scope > li > .active', listEl)).toEqual([aActive])
+ })
+ })
+
+ describe('findOne', () => {
+ it('should return one element', () => {
+ fixtureEl.innerHTML = '<div id="test"></div>'
+
+ const div = fixtureEl.querySelector('#test')
+
+ expect(SelectorEngine.findOne('#test')).toEqual(div)
+ })
+ })
+
+ describe('children', () => {
+ it('should find children', () => {
+ fixtureEl.innerHTML = [
+ '<ul>',
+ ' <li></li>',
+ ' <li></li>',
+ ' <li></li>',
+ '</ul>'
+ ].join('')
+
+ const list = fixtureEl.querySelector('ul')
+ const liList = [].concat(...fixtureEl.querySelectorAll('li'))
+ const result = SelectorEngine.children(list, 'li')
+
+ expect(result).toEqual(liList)
+ })
+ })
+
+ describe('parents', () => {
+ it('should return parents', () => {
+ expect(SelectorEngine.parents(fixtureEl, 'body')).toHaveSize(1)
+ })
+ })
+
+ describe('prev', () => {
+ it('should return previous element', () => {
+ fixtureEl.innerHTML = '<div class="test"></div><button class="btn"></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+
+ it('should return previous element with an extra element between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<span></span>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+
+ it('should return previous element with comments or text nodes between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<div class="test"></div>',
+ '<!-- Comment-->',
+ 'Text',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelectorAll('.test')[1]
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+ })
+
+ describe('next', () => {
+ it('should return next element', () => {
+ fixtureEl.innerHTML = '<div class="test"></div><button class="btn"></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
+ })
+
+ it('should return next element with an extra element between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<span></span>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
+ })
+
+ it('should return next element with comments or text nodes between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<!-- Comment-->',
+ 'Text',
+ '<button class="btn"></button>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn])
+ })
+ })
+
+ describe('focusableChildren', () => {
+ it('should return only elements with specific tag names', () => {
+ fixtureEl.innerHTML = [
+ '<div>lorem</div>',
+ '<span>lorem</span>',
+ '<a>lorem</a>',
+ '<button>lorem</button>',
+ '<input>',
+ '<textarea></textarea>',
+ '<select></select>',
+ '<details>lorem</details>'
+ ].join('')
+
+ const expectedElements = [
+ fixtureEl.querySelector('a'),
+ fixtureEl.querySelector('button'),
+ fixtureEl.querySelector('input'),
+ fixtureEl.querySelector('textarea'),
+ fixtureEl.querySelector('select'),
+ fixtureEl.querySelector('details')
+ ]
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should return any element with non negative tab index', () => {
+ fixtureEl.innerHTML = [
+ '<div tabindex>lorem</div>',
+ '<div tabindex="0">lorem</div>',
+ '<div tabindex="10">lorem</div>'
+ ].join('')
+
+ const expectedElements = [
+ fixtureEl.querySelector('[tabindex]'),
+ fixtureEl.querySelector('[tabindex="0"]'),
+ fixtureEl.querySelector('[tabindex="10"]')
+ ]
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should return not return elements with negative tab index', () => {
+ fixtureEl.innerHTML = '<button tabindex="-1">lorem</button>'
+
+ const expectedElements = []
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should return contenteditable elements', () => {
+ fixtureEl.innerHTML = '<div contenteditable="true">lorem</div>'
+
+ const expectedElements = [fixtureEl.querySelector('[contenteditable="true"]')]
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should not return disabled elements', () => {
+ fixtureEl.innerHTML = '<button disabled="true">lorem</button>'
+
+ const expectedElements = []
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+
+ it('should not return invisible elements', () => {
+ fixtureEl.innerHTML = '<button style="display:none;">lorem</button>'
+
+ const expectedElements = []
+
+ expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements)
+ })
+ })
+})
+
diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js
new file mode 100644
index 0000000..2bbd7c0
--- /dev/null
+++ b/js/tests/unit/dropdown.spec.js
@@ -0,0 +1,2430 @@
+import Dropdown from '../../src/dropdown'
+import EventHandler from '../../src/dom/event-handler'
+import { noop } from '../../src/util/index'
+import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Dropdown', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Dropdown.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Dropdown.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type config', () => {
+ expect(Dropdown.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Dropdown.DATA_KEY).toEqual('bs.dropdown')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownBySelector = new Dropdown('[data-bs-toggle="dropdown"]')
+ const dropdownByElement = new Dropdown(btnDropdown)
+
+ expect(dropdownBySelector._element).toEqual(btnDropdown)
+ expect(dropdownByElement._element).toEqual(btnDropdown)
+ })
+
+ it('should work on invalid markup', () => {
+ return new Promise(resolve => {
+ // TODO: REMOVE in v6
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const dropdownElem = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(dropdownElem)
+
+ dropdownElem.addEventListener('shown.bs.dropdown', () => {
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should create offset modifier correctly when offset option is a function', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const getOffset = jasmine.createSpy('getOffset').and.returnValue([10, 20])
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ offset: getOffset,
+ popperConfig: {
+ onFirstUpdate(state) {
+ expect(getOffset).toHaveBeenCalledWith({
+ popper: state.rects.popper,
+ reference: state.rects.reference,
+ placement: state.placement
+ }, btnDropdown)
+ resolve()
+ }
+ }
+ })
+ const offset = dropdown._getOffset()
+
+ expect(typeof offset).toEqual('function')
+
+ dropdown.show()
+ })
+ })
+
+ it('should create offset modifier correctly when offset option is a string into data attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-offset="10,20">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ expect(dropdown._getOffset()).toEqual([10, 20])
+ })
+
+ it('should allow to pass config to Popper with `popperConfig`', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ popperConfig: {
+ placement: 'left'
+ }
+ })
+
+ const popperConfig = dropdown._getPopperConfig()
+
+ expect(popperConfig.placement).toEqual('left')
+ })
+
+ it('should allow to pass config to Popper with `popperConfig` as a function', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-placement="right">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const getPopperConfig = jasmine.createSpy('getPopperConfig').and.returnValue({ placement: 'left' })
+ const dropdown = new Dropdown(btnDropdown, {
+ popperConfig: getPopperConfig
+ })
+
+ const popperConfig = dropdown._getPopperConfig()
+
+ expect(getPopperConfig).toHaveBeenCalled()
+ expect(popperConfig.placement).toEqual('left')
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should destroy old popper references on toggle', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="first dropdown">',
+ ' <button class="firstBtn btn" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>',
+ '<div class="second dropdown">',
+ ' <button class="secondBtn btn" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown1 = fixtureEl.querySelector('.firstBtn')
+ const btnDropdown2 = fixtureEl.querySelector('.secondBtn')
+ const firstDropdownEl = fixtureEl.querySelector('.first')
+ const secondDropdownEl = fixtureEl.querySelector('.second')
+ const dropdown1 = new Dropdown(btnDropdown1)
+
+ firstDropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown1).toHaveClass('show')
+ spyOn(dropdown1._popper, 'destroy')
+ btnDropdown2.click()
+ })
+
+ secondDropdownEl.addEventListener('shown.bs.dropdown', () => setTimeout(() => {
+ expect(dropdown1._popper.destroy).toHaveBeenCalled()
+ resolve()
+ }))
+
+ dropdown1.toggle()
+ })
+ })
+
+ it('should toggle a dropdown and add/remove event listener on mobile', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const defaultValueOnTouchStart = document.documentElement.ontouchstart
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ document.documentElement.ontouchstart = noop
+ const spy = spyOn(EventHandler, 'on')
+ const spyOff = spyOn(EventHandler, 'off')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ expect(spy).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+
+ dropdown.toggle()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(btnDropdown).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(spyOff).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+
+ document.documentElement.ontouchstart = defaultValueOnTouchStart
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown at the right', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu dropdown-menu-end">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a centered dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown-center">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropup', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropup centered', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup-center">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup-center')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropup at the right', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu dropdown-menu-end">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropend', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropend">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropendEl = fixtureEl.querySelector('.dropend')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropendEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropstart', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropstart">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropstartEl = fixtureEl.querySelector('.dropstart')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropstartEl.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with parent reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: 'parent'
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with a dom node reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: fixtureEl
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with a jquery object reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: { 0: fixtureEl, jquery: 'jQuery' }
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ })
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should toggle a dropdown with a valid virtual element reference', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle visually-hidden" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const virtualElement = {
+ nodeType: 1,
+ getBoundingClientRect() {
+ return {
+ width: 0,
+ height: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0
+ }
+ }
+ }
+
+ expect(() => new Dropdown(btnDropdown, {
+ reference: {}
+ })).toThrowError(TypeError, 'DROPDOWN: Option "reference" provided type "object" without a required "getBoundingClientRect" method.')
+
+ expect(() => new Dropdown(btnDropdown, {
+ reference: {
+ getBoundingClientRect: 'not-a-function'
+ }
+ })).toThrowError(TypeError, 'DROPDOWN: Option "reference" provided type "object" without a required "getBoundingClientRect" method.')
+
+ // use onFirstUpdate as Poppers internal update is executed async
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: virtualElement,
+ popperConfig: {
+ onFirstUpdate() {
+ expect(spy).toHaveBeenCalled()
+ expect(btnDropdown).toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ resolve()
+ }
+ }
+ })
+
+ const spy = spyOn(virtualElement, 'getBoundingClientRect').and.callThrough()
+
+ dropdown.toggle()
+ })
+ })
+
+ it('should not toggle a dropdown if the element is disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+
+ it('should not toggle a dropdown if the element contains .disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle disabled" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+
+ it('should not toggle a dropdown if the menu is shown', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+
+ it('should not toggle a dropdown if show event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('show.bs.dropdown', event => {
+ event.preventDefault()
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should show a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(btnDropdown).toHaveClass('show')
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should not show a dropdown if the element is disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not show a dropdown if the element contains .disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle disabled" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not show a dropdown if the menu is shown', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not show a dropdown if show event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('show.bs.dropdown', event => {
+ event.preventDefault()
+ })
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ reject(new Error('should not throw shown.bs.dropdown event'))
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a dropdown', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="true">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownMenu).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ resolve()
+ })
+
+ dropdown.hide()
+ })
+ })
+
+ it('should hide a dropdown and destroy popper', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ spyOn(dropdown._popper, 'destroy')
+ dropdown.hide()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdown._popper.destroy).toHaveBeenCalled()
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+
+ it('should not hide a dropdown if the element is disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu).toHaveClass('show')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not hide a dropdown if the element contains .disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle disabled" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu).toHaveClass('show')
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not hide a dropdown if the menu is not shown', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not hide a dropdown if hide event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ btnDropdown.addEventListener('hide.bs.dropdown', event => {
+ event.preventDefault()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ reject(new Error('should not throw hidden.bs.dropdown event'))
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu).toHaveClass('show')
+ resolve()
+ })
+ })
+ })
+
+ it('should remove event listener on touch-enabled device that was added in show method', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Dropdown item</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const defaultValueOnTouchStart = document.documentElement.ontouchstart
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ document.documentElement.ontouchstart = noop
+ const spy = spyOn(EventHandler, 'off')
+
+ btnDropdown.addEventListener('shown.bs.dropdown', () => {
+ dropdown.hide()
+ })
+
+ btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+ expect(btnDropdown).not.toHaveClass('show')
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(spy).toHaveBeenCalled()
+
+ document.documentElement.ontouchstart = defaultValueOnTouchStart
+ resolve()
+ })
+
+ dropdown.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose dropdown', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+
+ const dropdown = new Dropdown(btnDropdown)
+
+ expect(dropdown._popper).toBeNull()
+ expect(dropdown._menu).not.toBeNull()
+ expect(dropdown._element).not.toBeNull()
+ const spy = spyOn(EventHandler, 'off')
+
+ dropdown.dispose()
+
+ expect(dropdown._menu).toBeNull()
+ expect(dropdown._element).toBeNull()
+ expect(spy).toHaveBeenCalledWith(btnDropdown, Dropdown.EVENT_KEY)
+ })
+
+ it('should dispose dropdown with Popper', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdown.toggle()
+
+ expect(dropdown._popper).not.toBeNull()
+ expect(dropdown._menu).not.toBeNull()
+ expect(dropdown._element).not.toBeNull()
+
+ dropdown.dispose()
+