diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2023-01-24 12:33:51 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2023-01-24 12:33:51 +0000 |
commit | 3ea39841c8049525e31e9f4d6300f0c60cdb42de (patch) | |
tree | 855de60a8872eafb5911acd303aedcdbfe713a73 | |
parent | Inital commit. (diff) | |
download | bootstrap-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>
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** — check if the issue has already been + reported. + +2. **Check if the issue has been fixed** — 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** — 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 @@ -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 + +[](https://github.com/twbs/bootstrap/actions?query=workflow%3AJS+Tests+branch%3Amain) +[](https://www.npmjs.com/package/bootstrap) +[](https://rubygems.org/gems/bootstrap) +[](https://atmospherejs.com/twbs/bootstrap) +[](https://packagist.org/packages/twbs/bootstrap) +[](https://www.nuget.org/packages/bootstrap/absoluteLatest) +[](https://coveralls.io/github/twbs/bootstrap?branch=main) +[](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css) +[](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css) +[](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js) +[](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js) +[](https://www.browserstack.com/automate/public-build/SkxZcStBeExEdVJqQ2hWYnlWckpkNmNEY213SFp6WHFETWk2bGFuY3pCbz0tLXhqbHJsVlZhQnRBdEpod3NLSDMzaHc9PQ==--3d0b75245708616eb93113221beece33e680b229) +[](#backers) +[](#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)] + +[](https://opencollective.com/bootstrap/sponsor/0/website) +[](https://opencollective.com/bootstrap/sponsor/1/website) +[](https://opencollective.com/bootstrap/sponsor/2/website) +[](https://opencollective.com/bootstrap/sponsor/3/website) +[](https://opencollective.com/bootstrap/sponsor/4/website) +[](https://opencollective.com/bootstrap/sponsor/5/website) +[](https://opencollective.com/bootstrap/sponsor/6/website) +[](https://opencollective.com/bootstrap/sponsor/7/website) +[](https://opencollective.com/bootstrap/sponsor/8/website) +[](https://opencollective.com/bootstrap/sponsor/9/website) + + +## Backers + +Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/bootstrap#backer)] + +[](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() + |