diff --git a/.babelrc.js b/.babelrc.js index 6fdc98a4e77c..51399697f4e5 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -1,15 +1,12 @@ module.exports = { presets: [ [ - '@babel/env', + '@babel/preset-env', { loose: true, - modules: false, - exclude: ['transform-typeof-symbol'] + bugfixes: true, + modules: false } ] - ], - plugins: [ - '@babel/plugin-proposal-object-rest-spread' ] }; diff --git a/.browserslistrc b/.browserslistrc index 66df8f4f839c..cddd23005246 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,13 +1,12 @@ # https://github.com/browserslist/browserslist#readme ->= 1% -last 1 major version +>= 0.5% +last 2 major versions not dead Chrome >= 60 Firefox >= 60 -Edge >= 15.15063 -Explorer 11 -iOS >= 10 -Safari >= 10 -Android >= 6 -not ExplorerMobile <= 11 +Firefox ESR +iOS >= 12 +Safari >= 12 +not Explorer <= 11 +not kaios <= 2.5 # fix floating label issues in Firefox (see https://github.com/postcss/autoprefixer/issues/1533) diff --git a/bundlesize.config.json b/.bundlewatch.config.json similarity index 67% rename from bundlesize.config.json rename to .bundlewatch.config.json index eda41a22d17a..6f680664ca67 100644 --- a/bundlesize.config.json +++ b/.bundlewatch.config.json @@ -6,55 +6,61 @@ }, { "path": "./dist/css/bootstrap-grid.min.css", - "maxSize": "6 kB" + "maxSize": "6.0 kB" }, { "path": "./dist/css/bootstrap-reboot.css", - "maxSize": "2 kB" + "maxSize": "3.5 kB" }, { "path": "./dist/css/bootstrap-reboot.min.css", - "maxSize": "2 kB" + "maxSize": "3.25 kB" }, { "path": "./dist/css/bootstrap-utilities.css", - "maxSize": "8 kB" + "maxSize": "11.75 kB" }, { "path": "./dist/css/bootstrap-utilities.min.css", - "maxSize": "7 kB" + "maxSize": "10.75 kB" }, { "path": "./dist/css/bootstrap.css", - "maxSize": "25 kB" + "maxSize": "32.5 kB" }, { "path": "./dist/css/bootstrap.min.css", - "maxSize": "23 kB" + "maxSize": "30.25 kB" }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "51 kB" + "maxSize": "43.0 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "24 kB" + "maxSize": "23.5 kB" }, { "path": "./dist/js/bootstrap.esm.js", - "maxSize": "28 kB" + "maxSize": "28.0 kB" }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "19 kB" + "maxSize": "18.25 kB" }, { "path": "./dist/js/bootstrap.js", - "maxSize": "29 kB" + "maxSize": "28.75 kB" }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "17.5 kB" + "maxSize": "16.25 kB" } - ] + ], + "ci": { + "trackBranches": [ + "main", + "v4-dev" + ] + } } diff --git a/.cspell.json b/.cspell.json new file mode 100644 index 000000000000..d2434c30a608 --- /dev/null +++ b/.cspell.json @@ -0,0 +1,131 @@ +{ + "version": "0.2", + "words": [ + "affordance", + "allowfullscreen", + "Analyser", + "autohide", + "autohiding", + "autoplay", + "autoplays", + "autoplaying", + "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", + "dgst", + "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", + "sassrc", + "screenreaders", + "scrollbars", + "scrollspy", + "Segoe", + "semibold", + "socio", + "srcset", + "stackblitz", + "stickied", + "Stylelint", + "subnav", + "tabbable", + "textareas", + "toggleable", + "topbar", + "touchend", + "twbs", + "unitless", + "unstylable", + "unstyled", + "Uppercased", + "urlize", + "urlquery", + "vbtn", + "viewports", + "Vite", + "vstack", + "walkthroughs", + "WCAG", + "zindex" + ], + "language": "en-US", + "ignorePaths": [ + ".cspell.json", + "dist/", + "*.min.*", + "**/*rtl*", + "**/tests/**" + ], + "useGitignore": true +} diff --git a/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index 8451f8185f29..000000000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,20 +0,0 @@ -# https://dependabot.com/docs/config-file/ -version: 1 -update_configs: - # Keep package.json (& lockfiles) up to date as soon as - # new versions are published to the npm registry - - package_manager: "javascript" - directory: "/" - update_schedule: "weekly" - # Apply default reviewer and label to created pull requests - default_reviewers: - - "Johann-S" - - "XhmikosR" - default_labels: - - "dependencies" - - "v5" - version_requirement_updates: "increase_versions" - ignored_updates: - - match: - dependency_name: "karma-browserstack-launcher" - version_requirement: "1.5.1" diff --git a/.eslintignore b/.eslintignore index 6d874bc3658d..e42161487a5b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,7 +1,9 @@ **/*.min.js **/dist/ **/vendor/ -/_gh_pages/ +/_site/ +/site/public/ /js/coverage/ /site/static/sw.js -/package.js +/site/static/docs/**/assets/sw.js +/site/layouts/partials/ diff --git a/.eslintrc.json b/.eslintrc.json index 422546a5809e..017a6d13f80e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,15 +1,48 @@ { "root": true, - "parser": "babel-eslint", "extends": [ "plugin:import/errors", "plugin:import/warnings", "plugin:unicorn/recommended", - "xo/esnext", + "xo", "xo/browser" ], "rules": { + "arrow-body-style": "off", "capitalized-comments": "off", + "comma-dangle": [ + "error", + "never" + ], + "import/extensions": [ + "error", + "ignorePackages", + { + "js": "always" + } + ], + "import/first": "error", + "import/newline-after-import": "error", + "import/no-absolute-path": "error", + "import/no-amd": "error", + "import/no-cycle": [ + "error", + { + "ignoreExternal": true + } + ], + "import/no-duplicates": "error", + "import/no-extraneous-dependencies": "error", + "import/no-mutable-exports": "error", + "import/no-named-as-default": "error", + "import/no-named-as-default-member": "error", + "import/no-named-default": "error", + "import/no-self-import": "error", + "import/no-unassigned-import": [ + "error" + ], + "import/no-useless-path-segments": "error", + "import/order": "error", "indent": [ "error", 2, @@ -18,6 +51,7 @@ "SwitchCase": 1 } ], + "logical-assignment-operators": "off", "max-params": [ "warn", 5 @@ -26,27 +60,181 @@ "error", "always-multiline" ], - "new-cap": "off", + "new-cap": [ + "error", + { + "properties": false + } + ], "no-console": "error", + "no-negated-condition": "off", "object-curly-spacing": [ "error", "always" ], + "operator-linebreak": [ + "error", + "after" + ], + "prefer-object-has-own": "off", + "prefer-template": "error", "semi": [ "error", "never" ], - "unicorn/consistent-function-scoping": "off", + "strict": "error", "unicorn/explicit-length-check": "off", - "unicorn/import-index": "off", - "unicorn/no-for-loop": "off", + "unicorn/filename-case": "off", + "unicorn/no-anonymous-default-export": "off", + "unicorn/no-array-callback-reference": "off", + "unicorn/no-array-method-this-argument": "off", + "unicorn/no-null": "off", + "unicorn/no-typeof-undefined": "off", "unicorn/no-unused-properties": "error", - "unicorn/prefer-dataset": "off", - "unicorn/prefer-includes": "off", - "unicorn/prefer-node-append": "off", - "unicorn/prefer-node-remove": "off", + "unicorn/numeric-separators-style": "off", + "unicorn/prefer-array-flat": "off", + "unicorn/prefer-at": "off", + "unicorn/prefer-dom-node-dataset": "off", + "unicorn/prefer-module": "off", "unicorn/prefer-query-selector": "off", - "unicorn/prefer-text-content": "off", + "unicorn/prefer-spread": "off", + "unicorn/prefer-string-raw": "off", + "unicorn/prefer-string-replace-all": "off", + "unicorn/prefer-structured-clone": "off", "unicorn/prevent-abbreviations": "off" - } + }, + "overrides": [ + { + "files": [ + "build/**" + ], + "env": { + "browser": false, + "node": true + }, + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "no-console": "off", + "unicorn/prefer-top-level-await": "off" + } + }, + { + "files": [ + "js/**" + ], + "parserOptions": { + "sourceType": "module" + } + }, + { + "files": [ + "js/tests/*.js", + "js/tests/integration/rollup*.js" + ], + "env": { + "node": true + }, + "parserOptions": { + "sourceType": "script" + } + }, + { + "files": [ + "js/tests/unit/**" + ], + "env": { + "jasmine": true + }, + "rules": { + "no-console": "off", + "unicorn/consistent-function-scoping": "off", + "unicorn/no-useless-undefined": "off", + "unicorn/prefer-add-event-listener": "off" + } + }, + { + "files": [ + "js/tests/visual/**" + ], + "plugins": [ + "html" + ], + "settings": { + "html/html-extensions": [ + ".html" + ] + }, + "rules": { + "no-console": "off", + "no-new": "off", + "unicorn/no-array-for-each": "off" + } + }, + { + "files": [ + "scss/tests/**" + ], + "env": { + "node": true + }, + "parserOptions": { + "sourceType": "script" + } + }, + { + "files": [ + "site/**" + ], + "env": { + "browser": true, + "node": false + }, + "parserOptions": { + "sourceType": "script", + "ecmaVersion": 2019 + }, + "rules": { + "no-new": "off", + "unicorn/no-array-for-each": "off" + } + }, + { + "files": [ + "site/src/assets/application.js", + "site/src/assets/partials/*.js", + "site/src/assets/search.js", + "site/src/assets/snippets.js", + "site/src/assets/stackblitz.js", + "site/src/plugins/*.js" + ], + "parserOptions": { + "sourceType": "module", + "ecmaVersion": 2020 + } + }, + { + "files": [ + "**/*.md" + ], + "plugins": [ + "markdown" + ], + "processor": "markdown/markdown" + }, + { + "files": [ + "**/*.md/*.js", + "**/*.md/*.mjs" + ], + "extends": "plugin:markdown/recommended-legacy", + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "unicorn/prefer-node-protocol": "off" + } + } + ] } diff --git a/.gitattributes b/.gitattributes index 39813c758947..40b1c37421a0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,18 +1,8 @@ # Enforce Unix newlines -*.css text eol=lf -*.html text eol=lf -*.js text eol=lf -*.json text eol=lf -*.md text eol=lf -*.rb text eol=lf -*.scss text eol=lf -*.svg text eol=lf -*.txt text eol=lf -*.xml text eol=lf -*.yml text eol=lf +* text=auto eol=lf # Don't diff or textually merge source maps -*.map binary +*.map binary bootstrap.css linguist-vendored=false bootstrap.js linguist-vendored=false diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 82a84350b51c..a37b01210b5c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Bootstrap -Looking to contribute something to Bootstrap? **Here's how you can help.** +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. @@ -18,24 +18,29 @@ the preferred channel for [bug reports](#bug-reports), [features requests](#feat 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-4`](https://stackoverflow.com/questions/tagged/bootstrap-4) tag), - [Slack](https://bootstrap-slack.herokuapp.com/) or [IRC](README.md#community) are better places to get help. +- 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 +- 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:". +- 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 . +- Please **do not** open issues regarding the official themes offered on . Instead, please email any questions or feedback regarding those themes to `themes AT getbootstrap DOT com`. +## Issues assignment + +The core team will be looking at the open issues, analyze them, and provide guidance on how to proceed. **Issues won’t be assigned to anyone outside the core team.** However, contributors are welcome to participate in the discussion and provide their input on how to best solve the issue, and even submit a PR if they want to. Please wait that the issue is ready to be worked on before submitting a PR, we don’t want to waste your time. + +Please keep in mind that the core team is small, has limited resources and that we are not always able to respond immediately. We will try to provide feedback as soon as possible, but please be patient. If you don’t get a response immediately, it doesn’t mean that we are ignoring you or that we don’t care about your issue or PR. We will get back to you as soon as we can. + + ## 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: +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. @@ -58,21 +63,21 @@ 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. +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 `master` or development branch in the repository. + 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. - [This JS Bin](https://jsbin.com/lolome/edit?html,output) is a helpful template. + 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 +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 @@ -101,19 +106,19 @@ Example: 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
https://bugreport.apple.com/ | 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 | EdgeHTML | https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/ | | +| Vendor(s) | Browser(s) | Rendering engine | Bug reporting website(s) | Notes | +| ------------- | ---------------------------- | ---------------- | ------------------------------------------------------ | -------------------------------------------------------- | +| Mozilla | Firefox | Gecko | | "Core" is normally the right product option to choose. | +| Apple | Safari | WebKit | | In Apple’s bug reporter, choose "Safari" as the product. | +| Google, Opera | Chrome, Chromium, Opera v15+ | Blink | | Click the "New issue" button. | +| Microsoft | Edge | Blink | | 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 +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. @@ -123,23 +128,25 @@ 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. +**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. +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` -directly!** Those files are automatically generated. You should edit the -source files in [`/bootstrap/scss/`](https://github.com/twbs/bootstrap/tree/master/scss) -and/or [`/bootstrap/js/src/`](https://github.com/twbs/bootstrap/tree/master/js/src) instead. +**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 +Similarly, when contributing to Bootstrap’s documentation, you should edit the documentation source files in -[the `/bootstrap/site/content/docs/` directory of the `master` branch](https://github.com/twbs/bootstrap/tree/master/site/content/docs). +[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. @@ -161,37 +168,51 @@ included in the project: 2. If you cloned a while ago, get the latest changes from upstream: ```bash - git checkout master - git pull upstream master + git checkout main + git pull upstream main + ``` + +3. Install or update project dependencies with npm: + + ```bash + npm install ``` -3. Create a new topic branch (off the main project development branch) to +4. Create a new topic branch (off the main project development branch) to contain your feature, change, or fix: ```bash git checkout -b ``` -4. Commit your changes in logical chunks. Please adhere to these [git commit +5. 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 + 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: +6. Ensure your changes compile the dist CSS and JS files in the `dist/` directory. Verify + the build succeeds locally without errors. + + ```bash + npm run dist + ``` + +7. Locally merge (or rebase) the upstream development branch into your topic branch: ```bash - git pull [--rebase] upstream master + git pull [--rebase] upstream main ``` -6. Push your topic branch up to your fork: +8. Commit your changes, but **do not push compiled CSS and JS files in `dist` and `js/dist`**. + Push your topic branch up to your fork: ```bash git push origin ``` -7. [Open a Pull Request](https://help.github.com/articles/about-pull-requests/) - with a clear title and description against the `master` branch. +9. [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 @@ -207,15 +228,15 @@ includes code changes) and under the terms of the [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 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://a11yproject.com/posts/never-remove-css-outlines/) for more details. +- When feasible, default color palettes should comply with [WCAG color contrast guidelines](https://www.w3.org/TR/WCAG/#distinguishable). +- 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 @@ -234,4 +255,4 @@ Run `npm run test` before committing to ensure your changes follow our coding st 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. +Prior to v3.1.0, Bootstrap’s code was released under the Apache License v2.0. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 43ea984bab4f..000000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# These are supported funding model platforms - -open_collective: bootstrap diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 8971d44adbcc..000000000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,11 +0,0 @@ -Before opening: - -- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue) -- [Validate](https://html5.validator.nu/) and [lint](https://github.com/twbs/bootlint#in-the-browser) any HTML to avoid common problems -- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/.github/CONTRIBUTING.md) - -Bug reports must include: - -- Operating system and version (Windows, macOS, Android, iOS, Win10 Mobile) -- Browser and version (Chrome, Firefox, Safari, IE, MS Edge, Opera 15+, Android Browser) -- [Reduced test case](https://css-tricks.com/reduced-test-cases/) and suggested fix using [CodePen](https://codepen.io/) or [JS Bin](https://jsbin.com/) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 2d77bb50ef9e..000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Bug report -about: Tell us about a bug you may have identified in Bootstrap. - ---- - -Before opening: - -- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue) -- [Validate](https://html5.validator.nu/) and [lint](https://github.com/twbs/bootlint#in-the-browser) any HTML to avoid common problems -- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/.github/CONTRIBUTING.md) - -Bug reports must include: - -- Operating system and version (Windows, macOS, Android, iOS, Win10 Mobile) -- Browser and version (Chrome, Firefox, Safari, IE, MS Edge, Opera 15+, Android Browser) -- [Reduced test case](https://css-tricks.com/reduced-test-cases/) and suggested fix using [CodePen](https://codepen.io/) or [JS Bin](https://jsbin.com/) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000000..3e3d6b9e55f7 --- /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 000000000000..f1520711335c --- /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.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 4c13c86a5cb6..000000000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,9 +0,0 @@ -Before opening: - -- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue) -- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/.github/CONTRIBUTING.md) - -Feature requests must include: - -- As much detail as possible for what we should add and why it's important to Bootstrap -- Relevant links to prior art, screenshots, or live demos whenever possible diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index ed71d397dee5..000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for a new feature in Bootstrap. - ---- - -Before opening: - -- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue) -- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/.github/CONTRIBUTING.md) - -Feature requests must include: - -- As much detail as possible for what we should add and why it's important to Bootstrap -- Relevant links to prior art, screenshots, or live demos whenever possible diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000000..4b757b1d6753 --- /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 000000000000..04df74f36a56 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,38 @@ +### Description + + + +### Motivation & Context + + + +### Type of changes + + + +- [ ] 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 + + + + +- [ ] 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 + + + +- + +### Related issues + + diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index de3c4b552e9a..26b3be42c5b5 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -6,6 +6,6 @@ See the [contributing guidelines](CONTRIBUTING.md) for sharing bug reports. For general troubleshooting or help getting started: -- Join [the official Slack room](https://bootstrap-slack.herokuapp.com/). -- Chat with fellow Bootstrappers in IRC. On the `irc.freenode.net` server, in the `##bootstrap` channel. -- Ask and explore Stack Overflow with the [`bootstrap-4`](https://stackoverflow.com/questions/tagged/bootstrap-4) tag. +- 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/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000000..957877282f68 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,3 @@ +name: "CodeQL config" +paths-ignore: + - dist diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..77d364498f50 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + day: tuesday + time: "12:00" + timezone: Europe/Athens + - package-ecosystem: npm + directory: "/" + labels: + - dependencies + - v5 + schedule: + interval: weekly + day: tuesday + time: "12:00" + timezone: Europe/Athens + versioning-strategy: increase + rebase-strategy: disabled + groups: + production-dependencies: + dependency-type: "production" + development-dependencies: + dependency-type: "development" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000000..0289984bec06 --- /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 000000000000..a53c233cc671 --- /dev/null +++ b/.github/workflows/browserstack.yml @@ -0,0 +1,46 @@ +name: BrowserStack + +on: + push: + branches: + - "**" + - "!dependabot/**" + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + browserstack: + runs-on: ubuntu-latest + if: github.repository == 'twbs/bootstrap' + timeout-minutes: 30 + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 000000000000..99ba06063414 --- /dev/null +++ b/.github/workflows/bundlewatch.yml @@ -0,0 +1,43 @@ +name: Bundlewatch + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + bundlewatch: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 000000000000..08987b3aae69 --- /dev/null +++ b/.github/workflows/calibreapp-image-actions.yml @@ -0,0 +1,32 @@ +name: Compress Images + +on: + pull_request: + paths: + - '**.jpg' + - '**.jpeg' + - '**.png' + - '**.webp' + +permissions: + contents: read + +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 + permissions: + # allow calibreapp/image-actions to update PRs + pull-requests: write + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - 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 000000000000..dd7f6e7ef8d5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: "CodeQL" + +on: + push: + branches: + - main + - v4-dev + - "!dependabot/**" + pull_request: + branches: + - main + - v4-dev + - "!dependabot/**" + schedule: + - cron: "0 2 * * 4" + workflow_dispatch: + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + config-file: ./.github/codeql/codeql-config.yml + languages: "javascript" + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:javascript" diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml new file mode 100644 index 000000000000..8708673b131c --- /dev/null +++ b/.github/workflows/cspell.yml @@ -0,0 +1,36 @@ +name: cspell + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + +permissions: + contents: read + +jobs: + cspell: + permissions: + # allow streetsidesoftware/cspell-action to fetch files for commits and PRs + contents: read + pull-requests: read + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Run cspell + uses: streetsidesoftware/cspell-action@v6 + with: + config: ".cspell.json" + files: "**/*.{md,mdx}" + inline: error + incremental_files_only: false diff --git a/.github/workflows/css.yml b/.github/workflows/css.yml new file mode 100644 index 000000000000..52e93e2b2354 --- /dev/null +++ b/.github/workflows/css.yml @@ -0,0 +1,40 @@ +name: CSS + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + css: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "${{ env.NODE }}" + cache: npm + + - name: Install npm dependencies + run: npm ci + + - name: Build CSS + run: npm run css + + - name: Run CSS tests + run: npm run css-test diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000000..082220563791 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: Docs + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + docs: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 000000000000..b5000d8b4350 --- /dev/null +++ b/.github/workflows/issue-close-require.yml @@ -0,0 +1,26 @@ +name: Close Issue Awaiting Reply + +on: + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +jobs: + issue-close-require: + permissions: + # allow actions-cool/issues-helper to update issues and PRs + issues: write + pull-requests: write + 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 000000000000..584879dd80cf --- /dev/null +++ b/.github/workflows/issue-labeled.yml @@ -0,0 +1,26 @@ +name: Issue Labeled + +on: + issues: + types: [labeled] + +permissions: + contents: read + +jobs: + issue-labeled: + permissions: + # allow actions-cool/issues-helper to update issues and PRs + issues: write + pull-requests: write + 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 000000000000..1b672aa3097f --- /dev/null +++ b/.github/workflows/js.yml @@ -0,0 +1,52 @@ +name: JS Tests + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + run: + permissions: + # allow coverallsapp/github-action to create new checks issues and fetch code + checks: write + contents: read + name: JS Tests + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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@v2 + if: ${{ !github.event.repository.fork }} + 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 000000000000..213f9ec65909 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,37 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 000000000000..493cc35fde98 --- /dev/null +++ b/.github/workflows/node-sass.yml @@ -0,0 +1,49 @@ +name: CSS (node-sass) + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + FORCE_COLOR: 2 + NODE: 20 + +permissions: + contents: read + +jobs: + css: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + 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 + + - name: Check built CSS files for Sass variables + shell: bash + run: | + SASS_VARS_FOUND=$(find "dist-sass/css/" -type f -name "*.css" -print0 | xargs -0 --no-run-if-empty grep -F "\$" || true) + if [[ -z "$SASS_VARS_FOUND" ]]; then + echo "All good, no Sass variables found!" + exit 0 + else + echo "Found $(echo "$SASS_VARS_FOUND" | wc -l | bc) Sass variables:" + echo "$SASS_VARS_FOUND" + exit 1 + fi diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 000000000000..813956af2033 --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,23 @@ +name: Release notes + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + update_release_draft: + permissions: + # allow release-drafter/release-drafter to create GitHub releases and add labels to PRs + contents: write + pull-requests: write + runs-on: ubuntu-latest + if: github.repository == 'twbs/bootstrap' + steps: + - uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 96d74522ca20..000000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Tests -on: [push, pull_request] -env: - CI: true - -jobs: - run: - name: Node ${{ matrix.node }} - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - node: [10, 12] - - steps: - - name: Clone repository - uses: actions/checkout@v1 - with: - fetch-depth: 3 - - - name: Set Node.js version - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node }} - - - run: node --version - - run: npm --version - - run: java -version - - - name: Install npm dependencies - run: npm ci - - - name: Run tests - run: npm test - - - name: Run bundlesize - run: npm run bundlesize - if: matrix.node == 10 - env: - BUNDLESIZE_GITHUB_TOKEN: "${{ secrets.BUNDLESIZE_GITHUB_TOKEN }}" - - - name: Run BrowserStack tests - run: npm run js-test-cloud - if: matrix.node == 10 && github.repository == 'twbs/bootstrap' && github.event_name == 'push' - env: - BROWSER_STACK_ACCESS_KEY: "${{ secrets.BROWSER_STACK_ACCESS_KEY }}" - BROWSER_STACK_USERNAME: "${{ secrets.BROWSER_STACK_USERNAME }}" - - - name: Run Coveralls - run: npm run coveralls - if: matrix.node == 10 && github.repository == 'twbs/bootstrap' && github.event_name == 'push' - env: - COVERALLS_REPO_TOKEN: "${{ secrets.COVERALLS_REPO_TOKEN }}" - COVERALLS_GIT_BRANCH: "${{ github.ref }}" diff --git a/.gitignore b/.gitignore index dff85a65c0ef..bb95ca04070c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,5 @@ # Ignore docs files -/_gh_pages/ -# This is the old Jekyll docs dist folder; -# keeping it here so that when we switch branches it doesn't show up -/site/docs/**/dist/ -# Hugo resources folder -/resources/ - -# Ignore ruby/bundler files; -# keeping them here so that when we switch branches they don't show up -/.bundle/ -/vendor/ -/.ruby-version +/_site/ # Numerous always-ignore extensions *.diff @@ -37,11 +26,21 @@ *.sublime-workspace nbproject Thumbs.db +/.vscode/ +# Local Netlify folder +.netlify # Komodo .komodotools *.komodoproject # Folders to ignore +/dist-sass/ /js/coverage/ /node_modules/ + +# Site +/site/dist +/site/node_modules +/site/.astro +/site/public diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000000..32442e87a0db --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +# Prettier is only used for the website + +site/.astro +site/dist +site/public +site/src/assets +site/src/scss +site/src/pages/**/*.md +site/src/pages/**/*.mdx +site/src/content/**/*.mdx +site/src/layouts/RedirectLayout.astro +site/static diff --git a/.stylelintignore b/.stylelintignore index e42e88938400..b7013de7ef81 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,5 +1,6 @@ **/*.min.css **/dist/ **/vendor/ -/_gh_pages/ +/_site/ +/site/public/ /js/coverage/ diff --git a/.stylelintrc b/.stylelintrc deleted file mode 100644 index 80d0eb5f9700..000000000000 --- a/.stylelintrc +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": [ - "stylelint-config-twbs-bootstrap/scss" - ], - "rules": { - "property-blacklist": [ - "border-radius", - "border-top-left-radius", - "border-top-right-radius", - "border-bottom-right-radius", - "border-bottom-left-radius", - "transition" - ], - "function-blacklist": ["calc"], - 'scss/dollar-variable-default': [ - true, - { - 'ignore': 'local' - } - ] - } -} diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 000000000000..589884aae7ab --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,60 @@ +{ + "extends": [ + "stylelint-config-twbs-bootstrap" + ], + "reportInvalidScopeDisables": true, + "reportNeedlessDisables": true, + "overrides": [ + { + "files": "**/*.scss", + "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 + } + }, + { + "files": "scss/**/*.{test,spec}.scss", + "rules": { + "scss/dollar-variable-default": null, + "declaration-no-important": null + } + }, + { + "files": "site/**/*.scss", + "rules": { + "scss/dollar-variable-default": null + } + }, + { + "files": "site/**/examples/**/*.css", + "rules": { + "comment-empty-line-before": null, + "property-no-vendor-prefix": null, + "selector-no-qualifying-type": null, + "value-no-vendor-prefix": null + } + } + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9d9922f25f4d..756298316059 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,45 +2,131 @@ ## Our Pledge -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +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 creating a positive environment include: +Examples of behavior that contributes to a positive environment for our +community include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- 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 by participants include: +Examples of unacceptable behavior include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +- 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 -## Our Responsibilities +## Enforcement Responsibilities -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. +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. -Project maintainers 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, or to ban temporarily or permanently any contributor for other behaviors 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 both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +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 by contacting the project team at mdo@getbootstrap.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +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. -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. +**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 1.4, available at [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html][version] +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/ -[version]: https://www.contributor-covenant.org/version/1/4/code-of-conduct.html +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/LICENSE b/LICENSE index 7d1e2311f44f..fa7c00bc4ebd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,6 @@ The MIT License (MIT) -Copyright (c) 2011-2019 Twitter, Inc. -Copyright (c) 2011-2019 The Bootstrap Authors +Copyright (c) 2011-2025 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 diff --git a/README.md b/README.md index 06a76c050882..154c3f2d48e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Bootstrap logo + Bootstrap logo

@@ -9,12 +9,12 @@

Sleek, intuitive, and powerful front-end framework for faster and easier web development.
- Explore Bootstrap docs » + Explore Bootstrap docs »

- Report bug + Report bug · - Request feature + Request feature · Themes · @@ -22,11 +22,16 @@

+## 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) +- [What’s included](#whats-included) - [Bugs and feature requests](#bugs-and-feature-requests) - [Documentation](#documentation) - [Contributing](#contributing) @@ -41,95 +46,114 @@ Several quick start options are available: -- [Download the latest release.](https://github.com/twbs/bootstrap/archive/v4.3.1.zip) +- [Download the latest release](https://github.com/twbs/bootstrap/archive/v5.3.5.zip) - Clone the repo: `git clone https://github.com/twbs/bootstrap.git` -- Install with [npm](https://www.npmjs.com/): `npm install bootstrap` -- Install with [yarn](https://yarnpkg.com/): `yarn add bootstrap@4.3.1` -- Install with [Composer](https://getcomposer.org/): `composer require twbs/bootstrap:4.3.1` +- Install with [npm](https://www.npmjs.com/): `npm install bootstrap@v5.3.5` +- Install with [yarn](https://yarnpkg.com/): `yarn add bootstrap@v5.3.5` +- Install with [Bun](https://bun.sh/): `bun add bootstrap@v5.3.5` +- Install with [Composer](https://getcomposer.org/): `composer require twbs/bootstrap:5.3.5` - 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/4.3/getting-started/introduction/) for information on the framework contents, templates and examples, and more. +Read the [Getting started page](https://getbootstrap.com/docs/5.3/getting-started/introduction/) for information on the framework contents, templates, examples, and more. ## Status -[![Build Status](https://github.com/twbs/bootstrap/workflows/Tests/badge.svg)](https://github.com/twbs/bootstrap/actions?workflow=Tests) -[![npm version](https://img.shields.io/npm/v/bootstrap.svg)](https://www.npmjs.com/package/bootstrap) -[![Gem version](https://img.shields.io/gem/v/bootstrap.svg)](https://rubygems.org/gems/bootstrap) -[![Meteor Atmosphere](https://img.shields.io/badge/meteor-twbs%3Abootstrap-blue.svg)](https://atmospherejs.com/twbs/bootstrap) -[![Packagist Prerelease](https://img.shields.io/packagist/vpre/twbs/bootstrap.svg)](https://packagist.org/packages/twbs/bootstrap) -[![NuGet](https://img.shields.io/nuget/vpre/bootstrap.svg)](https://www.nuget.org/packages/bootstrap/absoluteLatest) -[![peerDependencies Status](https://img.shields.io/david/peer/twbs/bootstrap.svg)](https://david-dm.org/twbs/bootstrap?type=peer) -[![devDependency Status](https://img.shields.io/david/dev/twbs/bootstrap.svg)](https://david-dm.org/twbs/bootstrap?type=dev) -[![Coverage Status](https://img.shields.io/coveralls/github/twbs/bootstrap/master.svg)](https://coveralls.io/github/twbs/bootstrap?branch=master) -[![CSS gzip size](https://img.badgesize.io/twbs/bootstrap/master/dist/css/bootstrap.min.css?compression=gzip&label=CSS+gzip+size)](https://github.com/twbs/bootstrap/tree/master/dist/css/bootstrap.min.css) -[![JS gzip size](https://img.badgesize.io/twbs/bootstrap/master/dist/js/bootstrap.min.js?compression=gzip&label=JS+gzip+size)](https://github.com/twbs/bootstrap/tree/master/dist/js/bootstrap.min.js) -[![BrowserStack Status](https://www.browserstack.com/automate/badge.svg?badge_key=SkxZcStBeExEdVJqQ2hWYnlWckpkNmNEY213SFp6WHFETWk2bGFuY3pCbz0tLXhqbHJsVlZhQnRBdEpod3NLSDMzaHc9PQ==--3d0b75245708616eb93113221beece33e680b229)](https://www.browserstack.com/automate/public-build/SkxZcStBeExEdVJqQ2hWYnlWckpkNmNEY213SFp6WHFETWk2bGFuY3pCbz0tLXhqbHJsVlZhQnRBdEpod3NLSDMzaHc9PQ==--3d0b75245708616eb93113221beece33e680b229) -[![Backers on Open Collective](https://img.shields.io/opencollective/backers/bootstrap.svg)](#backers) -[![Sponsors on Open Collective](https://img.shields.io/opencollective/sponsors/bootstrap.svg)](#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. You'll see something like this: - -```text -bootstrap/ -└── dist/ - ├── css/ - │ ├── bootstrap-grid.css - │ ├── bootstrap-grid.css.map - │ ├── bootstrap-grid.min.css - │ ├── bootstrap-grid.min.css.map - │ ├── bootstrap-reboot.css - │ ├── bootstrap-reboot.css.map - │ ├── bootstrap-reboot.min.css - │ ├── bootstrap-reboot.min.css.map - │ ├── bootstrap-utilities.css - │ ├── bootstrap-utilities.css.map - │ ├── bootstrap-utilities.min.css - │ ├── bootstrap-utilities.min.css.map - │ ├── bootstrap.css - │ ├── bootstrap.css.map - │ ├── bootstrap.min.css - │ └── bootstrap.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 -``` - -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/). +[![Build Status](https://img.shields.io/github/actions/workflow/status/twbs/bootstrap/js.yml?branch=main&label=JS%20Tests&logo=github)](https://github.com/twbs/bootstrap/actions/workflows/js.yml?query=workflow%3AJS+branch%3Amain) +[![npm version](https://img.shields.io/npm/v/bootstrap?logo=npm&logoColor=fff)](https://www.npmjs.com/package/bootstrap) +[![Gem version](https://img.shields.io/gem/v/bootstrap?logo=rubygems&logoColor=fff)](https://rubygems.org/gems/bootstrap) +[![Meteor Atmosphere](https://img.shields.io/badge/meteor-twbs%3Abootstrap-blue?logo=meteor&logoColor=fff)](https://atmospherejs.com/twbs/bootstrap) +[![Packagist Prerelease](https://img.shields.io/packagist/vpre/twbs/bootstrap?logo=packagist&logoColor=fff)](https://packagist.org/packages/twbs/bootstrap) +[![NuGet](https://img.shields.io/nuget/vpre/bootstrap?logo=nuget&logoColor=fff)](https://www.nuget.org/packages/bootstrap/absoluteLatest) +[![Coverage Status](https://img.shields.io/coveralls/github/twbs/bootstrap/main?logo=coveralls&logoColor=fff)](https://coveralls.io/github/twbs/bootstrap?branch=main) +[![CSS gzip size](https://img.badgesize.io/twbs/bootstrap/main/dist/css/bootstrap.min.css?compression=gzip&label=CSS%20gzip%20size)](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css) +[![CSS Brotli size](https://img.badgesize.io/twbs/bootstrap/main/dist/css/bootstrap.min.css?compression=brotli&label=CSS%20Brotli%20size)](https://github.com/twbs/bootstrap/blob/main/dist/css/bootstrap.min.css) +[![JS gzip size](https://img.badgesize.io/twbs/bootstrap/main/dist/js/bootstrap.min.js?compression=gzip&label=JS%20gzip%20size)](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js) +[![JS Brotli size](https://img.badgesize.io/twbs/bootstrap/main/dist/js/bootstrap.min.js?compression=brotli&label=JS%20Brotli%20size)](https://github.com/twbs/bootstrap/blob/main/dist/js/bootstrap.min.js) +[![Backers on Open Collective](https://img.shields.io/opencollective/backers/bootstrap?logo=opencollective&logoColor=fff)](#backers) +[![Sponsors on Open Collective](https://img.shields.io/opencollective/sponsors/bootstrap?logo=opencollective&logoColor=fff)](#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. + +
+ Download contents + + ```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 + ``` +
+ +We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified CSS and JS (`bootstrap.min.*`). [Source maps](https://web.dev/articles/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/docs/v2/). ## Bugs and feature requests -Have a bug or a feature request? Please first read the [issue guidelines](https://github.com/twbs/bootstrap/blob/master/.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). +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 . The docs may also be run locally. +Bootstrap’s documentation, included in this repo in the root directory, is built with [Astro](https://astro.build/) and publicly hosted on GitHub Pages at . The docs may also be run locally. -Documentation search is powered by [Algolia's DocSearch](https://community.algolia.com/docsearch/). Working on our search? Be sure to set `debug: true` in `site/assets/js/src/search.js` file. +Documentation search is powered by [Algolia's DocSearch](https://docsearch.algolia.com/). ### Running documentation locally -1. Run `npm install` to install the Node.js dependencies, including Hugo (the site builder). +1. Run `npm install` to install the Node.js dependencies, including Astro (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/). +Learn more about using Astro by reading its [documentation](https://docs.astro.build/en/getting-started/). ### Documentation for previous releases @@ -140,22 +164,23 @@ You can find all our previous releases docs on . +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 . ## Community -Get updates on Bootstrap's development and chat with the project maintainers and community members. +Get updates on Bootstrap’s development and chat with the project maintainers and community members. -- Follow [@getbootstrap on Twitter](https://twitter.com/getbootstrap). +- Follow [@getbootstrap on X](https://x.com/getbootstrap). - Read and subscribe to [The Official Bootstrap Blog](https://blog.getbootstrap.com/). -- Join [the official Slack room](https://bootstrap-slack.herokuapp.com/). -- Chat with fellow Bootstrappers in IRC. On the `irc.freenode.net` server, in the `##bootstrap` channel. -- Implementation help may be found at Stack Overflow (tagged [`bootstrap-4`](https://stackoverflow.com/questions/tagged/bootstrap-4)). +- Ask questions and explore [our GitHub Discussions](https://github.com/twbs/bootstrap/discussions). +- Discuss, ask questions, and more on [the community Discord](https://discord.gg/bZUvakRU3M) or [Bootstrap subreddit](https://www.reddit.com/r/bootstrap/). +- 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. @@ -170,47 +195,53 @@ See [the Releases section of our GitHub project](https://github.com/twbs/bootstr **Mark Otto** -- +- - **Jacob Thornton** -- +- - ## Thanks - BrowserStack Logo + BrowserStack Thanks to [BrowserStack](https://www.browserstack.com/) for providing the infrastructure that allows us to test in real browsers! + + Netlify + -## Backers - -Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/bootstrap#backer)] - -[![Bakers](https://opencollective.com/bootstrap/backers.svg?width=890)](https://opencollective.com/bootstrap#backers) +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/avatar.svg)](https://opencollective.com/bootstrap/sponsor/0/website) -[![](https://opencollective.com/bootstrap/sponsor/1/avatar.svg)](https://opencollective.com/bootstrap/sponsor/1/website) -[![](https://opencollective.com/bootstrap/sponsor/2/avatar.svg)](https://opencollective.com/bootstrap/sponsor/2/website) -[![](https://opencollective.com/bootstrap/sponsor/3/avatar.svg)](https://opencollective.com/bootstrap/sponsor/3/website) -[![](https://opencollective.com/bootstrap/sponsor/4/avatar.svg)](https://opencollective.com/bootstrap/sponsor/4/website) -[![](https://opencollective.com/bootstrap/sponsor/5/avatar.svg)](https://opencollective.com/bootstrap/sponsor/5/website) -[![](https://opencollective.com/bootstrap/sponsor/6/avatar.svg)](https://opencollective.com/bootstrap/sponsor/6/website) -[![](https://opencollective.com/bootstrap/sponsor/7/avatar.svg)](https://opencollective.com/bootstrap/sponsor/7/website) -[![](https://opencollective.com/bootstrap/sponsor/8/avatar.svg)](https://opencollective.com/bootstrap/sponsor/8/website) -[![](https://opencollective.com/bootstrap/sponsor/9/avatar.svg)](https://opencollective.com/bootstrap/sponsor/9/website) +[![OC sponsor 0](https://opencollective.com/bootstrap/sponsor/0/avatar.svg)](https://opencollective.com/bootstrap/sponsor/0/website) +[![OC sponsor 1](https://opencollective.com/bootstrap/sponsor/1/avatar.svg)](https://opencollective.com/bootstrap/sponsor/1/website) +[![OC sponsor 2](https://opencollective.com/bootstrap/sponsor/2/avatar.svg)](https://opencollective.com/bootstrap/sponsor/2/website) +[![OC sponsor 3](https://opencollective.com/bootstrap/sponsor/3/avatar.svg)](https://opencollective.com/bootstrap/sponsor/3/website) +[![OC sponsor 4](https://opencollective.com/bootstrap/sponsor/4/avatar.svg)](https://opencollective.com/bootstrap/sponsor/4/website) +[![OC sponsor 5](https://opencollective.com/bootstrap/sponsor/5/avatar.svg)](https://opencollective.com/bootstrap/sponsor/5/website) +[![OC sponsor 6](https://opencollective.com/bootstrap/sponsor/6/avatar.svg)](https://opencollective.com/bootstrap/sponsor/6/website) +[![OC sponsor 7](https://opencollective.com/bootstrap/sponsor/7/avatar.svg)](https://opencollective.com/bootstrap/sponsor/7/website) +[![OC sponsor 8](https://opencollective.com/bootstrap/sponsor/8/avatar.svg)](https://opencollective.com/bootstrap/sponsor/8/website) +[![OC sponsor 9](https://opencollective.com/bootstrap/sponsor/9/avatar.svg)](https://opencollective.com/bootstrap/sponsor/9/website) + + +## Backers + +Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/bootstrap#backer)] + +[![Backers](https://opencollective.com/bootstrap/backers.svg?width=890)](https://opencollective.com/bootstrap#backers) ## Copyright and license -Code and documentation copyright 2011-2019 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/master/LICENSE). Docs released under [Creative Commons](https://creativecommons.org/licenses/by/3.0/). +Code and documentation copyright 2011-2025 the [Bootstrap Authors](https://github.com/twbs/bootstrap/graphs/contributors). 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/build/.eslintrc.json b/build/.eslintrc.json deleted file mode 100644 index 08169ca4cca8..000000000000 --- a/build/.eslintrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "env": { - "browser": false, - "node": true - }, - "parserOptions": { - "sourceType": "script" - }, - "extends": "../.eslintrc.json", - "rules": { - "no-console": "off" - } -} diff --git a/build/banner.js b/build/banner.js deleted file mode 100644 index 453fc3b6e0b2..000000000000 --- a/build/banner.js +++ /dev/null @@ -1,14 +0,0 @@ -'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/master/LICENSE) - */` -} - -module.exports = getBanner diff --git a/build/banner.mjs b/build/banner.mjs new file mode 100644 index 000000000000..3fea93c8f1d2 --- /dev/null +++ b/build/banner.mjs @@ -0,0 +1,20 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const pkgJson = path.join(__dirname, '../package.json') +const pkg = JSON.parse(await fs.readFile(pkgJson, 'utf8')) + +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) + */` +} + +export default getBanner diff --git a/build/build-plugins.js b/build/build-plugins.js deleted file mode 100644 index bf64021de343..000000000000 --- a/build/build-plugins.js +++ /dev/null @@ -1,183 +0,0 @@ -/*! - * Script to build our plugins to use them separately. - * Copyright 2019 The Bootstrap Authors - * Copyright 2019 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -'use strict' - -const path = require('path') -const rollup = require('rollup') -const babel = require('rollup-plugin-babel') -const banner = require('./banner.js') - -const plugins = [ - babel({ - // Only transpile our source code - exclude: 'node_modules/**', - // Include only required helpers - externalHelpersWhitelist: [ - 'defineProperties', - 'createClass', - 'inheritsLoose', - 'defineProperty', - 'objectSpread2' - ] - }) -] -const bsPlugins = { - Data: path.resolve(__dirname, '../js/src/dom/data.js'), - EventHandler: path.resolve(__dirname, '../js/src/dom/event-handler.js'), - Manipulator: path.resolve(__dirname, '../js/src/dom/manipulator.js'), - Polyfill: path.resolve(__dirname, '../js/src/dom/polyfill.js'), - SelectorEngine: path.resolve(__dirname, '../js/src/dom/selector-engine.js'), - Alert: path.resolve(__dirname, '../js/src/alert.js'), - Button: path.resolve(__dirname, '../js/src/button.js'), - Carousel: path.resolve(__dirname, '../js/src/carousel.js'), - Collapse: path.resolve(__dirname, '../js/src/collapse.js'), - Dropdown: path.resolve(__dirname, '../js/src/dropdown.js'), - Modal: path.resolve(__dirname, '../js/src/modal.js'), - Popover: path.resolve(__dirname, '../js/src/popover.js'), - ScrollSpy: path.resolve(__dirname, '../js/src/scrollspy.js'), - Tab: path.resolve(__dirname, '../js/src/tab.js'), - Toast: path.resolve(__dirname, '../js/src/toast.js'), - Tooltip: path.resolve(__dirname, '../js/src/tooltip.js') -} -const rootPath = path.resolve(__dirname, '../js/dist/') - -const defaultPluginConfig = { - external: [ - bsPlugins.Data, - bsPlugins.EventHandler, - bsPlugins.SelectorEngine - ], - globals: { - [bsPlugins.Data]: 'Data', - [bsPlugins.EventHandler]: 'EventHandler', - [bsPlugins.SelectorEngine]: 'SelectorEngine' - } -} - -function getConfigByPluginKey(pluginKey) { - if ( - pluginKey === 'Data' || - pluginKey === 'Manipulator' || - pluginKey === 'EventHandler' || - pluginKey === 'Polyfill' || - pluginKey === 'SelectorEngine' || - pluginKey === 'Util' || - pluginKey === 'Sanitizer' - ) { - return { - external: [bsPlugins.Polyfill], - globals: { - [bsPlugins.Polyfill]: 'Polyfill' - } - } - } - - if (pluginKey === 'Alert' || pluginKey === 'Tab') { - return defaultPluginConfig - } - - if ( - pluginKey === 'Button' || - pluginKey === 'Carousel' || - pluginKey === 'Collapse' || - pluginKey === 'Modal' || - pluginKey === 'ScrollSpy' - ) { - const config = Object.assign(defaultPluginConfig) - config.external.push(bsPlugins.Manipulator) - config.globals[bsPlugins.Manipulator] = 'Manipulator' - return config - } - - if (pluginKey === 'Dropdown' || pluginKey === 'Tooltip') { - const config = Object.assign(defaultPluginConfig) - config.external.push(bsPlugins.Manipulator, 'popper.js') - config.globals[bsPlugins.Manipulator] = 'Manipulator' - config.globals['popper.js'] = 'Popper' - return config - } - - if (pluginKey === 'Popover') { - return { - external: [ - bsPlugins.Data, - bsPlugins.SelectorEngine, - bsPlugins.Tooltip - ], - globals: { - [bsPlugins.Data]: 'Data', - [bsPlugins.SelectorEngine]: 'SelectorEngine', - [bsPlugins.Tooltip]: 'Tooltip' - } - } - } - - if (pluginKey === 'Toast') { - return { - external: [ - bsPlugins.Data, - bsPlugins.EventHandler, - bsPlugins.Manipulator - ], - globals: { - [bsPlugins.Data]: 'Data', - [bsPlugins.EventHandler]: 'EventHandler', - [bsPlugins.Manipulator]: 'Manipulator' - } - } - } -} - -const utilObjects = [ - 'Util', - 'Sanitizer' -] - -const domObjects = [ - 'Data', - 'EventHandler', - 'Manipulator', - 'Polyfill', - 'SelectorEngine' -] - -function build(plugin) { - console.log(`Building ${plugin} plugin...`) - - const { external, globals } = getConfigByPluginKey(plugin) - const pluginFilename = path.basename(bsPlugins[plugin]) - let pluginPath = rootPath - - if (utilObjects.includes(plugin)) { - pluginPath = `${rootPath}/util/` - } - - if (domObjects.includes(plugin)) { - pluginPath = `${rootPath}/dom/` - } - - rollup.rollup({ - input: bsPlugins[plugin], - plugins, - external - }).then(bundle => { - bundle.write({ - banner: banner(pluginFilename), - format: 'umd', - name: plugin, - sourcemap: true, - globals, - file: path.resolve(__dirname, `${pluginPath}/${pluginFilename}`) - }) - .then(() => console.log(`Building ${plugin} plugin... Done!`)) - .catch(error => console.error(`${plugin}: ${error}`)) - }) -} - -Object.keys(bsPlugins) - .forEach(plugin => build(plugin)) diff --git a/build/build-plugins.mjs b/build/build-plugins.mjs new file mode 100644 index 000000000000..f56cd213f511 --- /dev/null +++ b/build/build-plugins.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/*! + * Script to build our plugins to use them separately. + * Copyright 2020-2025 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ + +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { babel } from '@rollup/plugin-babel' +import { globby } from 'globby' +import { rollup } from 'rollup' +import banner from './banner.mjs' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const sourcePath = path.resolve(__dirname, '../js/src/').replace(/\\/g, '/') +const jsFiles = await globby(`${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, + dist: file.replace('src', 'dist'), + fileName: path.basename(file), + className: filenameToEntity(path.basename(file)) + // safeClassName: filenameToEntity(path.relative(sourcePath, file)) + }) +} + +const build = async plugin => { + /** + * @type {import('rollup').GlobalsOption} + */ + const globals = {} + + const bundle = await 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 deleted file mode 100755 index 8b349a68ad5d..000000000000 --- a/build/change-version.js +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env node - -/*! - * Script to update version number references in the project. - * Copyright 2017-2019 The Bootstrap Authors - * Copyright 2017-2019 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -'use strict' - -const fs = require('fs') -const path = require('path') -const sh = require('shelljs') - -sh.config.fatal = 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, '$$') -} - -const DRY_RUN = false - -function walkAsync(directory, excludedDirectories, fileCallback, errback) { - if (excludedDirectories.has(path.parse(directory).base)) { - return - } - - fs.readdir(directory, (err, names) => { - if (err) { - errback(err) - return - } - - names.forEach(name => { - const filepath = path.join(directory, name) - fs.lstat(filepath, (err, stats) => { - if (err) { - process.nextTick(errback, err) - return - } - - if (stats.isDirectory()) { - process.nextTick(walkAsync, filepath, excludedDirectories, fileCallback, errback) - } else if (stats.isFile()) { - process.nextTick(fileCallback, filepath) - } - }) - }) - }) -} - -function replaceRecursively(directory, excludedDirectories, allowedExtensions, original, replacement) { - original = new RegExp(regExpQuote(original), 'g') - replacement = regExpQuoteReplacement(replacement) - const updateFile = DRY_RUN ? - filepath => { - if (allowedExtensions.has(path.parse(filepath).ext)) { - console.log(`FILE: ${filepath}`) - } else { - console.log(`EXCLUDED:${filepath}`) - } - } : - filepath => { - if (allowedExtensions.has(path.parse(filepath).ext)) { - sh.sed('-i', original, replacement, filepath) - } - } - - walkAsync(directory, excludedDirectories, updateFile, err => { - console.error('ERROR while traversing directory!:') - console.error(err) - process.exit(1) - }) -} - -function main(args) { - if (args.length !== 2) { - console.error('USAGE: change-version old_version new_version') - console.error('Got arguments:', args) - process.exit(1) - } - - const oldVersion = args[0] - const newVersion = args[1] - const EXCLUDED_DIRS = new Set([ - '.git', - '_gh_pages', - 'node_modules', - 'vendor' - ]) - const INCLUDED_EXTENSIONS = new Set([ - // This extension whitelist is how we avoid modifying binary files - '', - '.css', - '.html', - '.js', - '.json', - '.md', - '.scss', - '.txt', - '.yml' - ]) - replaceRecursively('.', EXCLUDED_DIRS, INCLUDED_EXTENSIONS, oldVersion, newVersion) -} - -main(process.argv.slice(2)) diff --git a/build/change-version.mjs b/build/change-version.mjs new file mode 100644 index 000000000000..1400274c446e --- /dev/null +++ b/build/change-version.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +/*! + * Script to update version number references in the project. + * Copyright 2017-2025 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ + +import { execFile } from 'node:child_process' +import fs from 'node:fs/promises' +import process from 'node:process' + +const VERBOSE = process.argv.includes('--verbose') +const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run') + +// These are the files we only care about replacing the version +const FILES = [ + 'README.md', + 'config.yml', + 'js/src/base-component.js', + 'package.js', + 'scss/mixins/_banner.scss', + 'site/data/docs-versions.yml' +] + +// 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) + ) + // Also replace the version used by the rubygem, + // which is using periods (`.`) instead of hyphens (`-`) + .replace( + new RegExp(regExpQuote(oldVersion.replace(/-/g, '.')), 'g'), + regExpQuoteReplacement(newVersion.replace(/-/g, '.')) + ) + + // No need to move any further if the strings are identical + if (originalString === newString) { + return + } + + if (VERBOSE) { + console.log(`Found ${oldVersion} in ${file}`) + } + + if (DRY_RUN) { + return + } + + await fs.writeFile(file, newString, 'utf8') +} + +function bumpNpmVersion(newVersion) { + if (DRY_RUN) { + return + } + + execFile('npm', ['version', newVersion, '--no-git-tag'], { shell: true }, error => { + if (error) { + console.error(error) + process.exit(1) + } + }) +} + +function showUsage(args) { + console.error('USAGE: change-version old_version new_version [--verbose] [--dry[-run]]') + console.error('Got arguments:', args) + process.exit(1) +} + +async function main(args) { + let [oldVersion, newVersion] = args + + if (!oldVersion || !newVersion) { + showUsage(args) + } + + // Strip any leading `v` from arguments because + // otherwise we will end up with duplicate `v`s + [oldVersion, newVersion] = [oldVersion, newVersion].map(arg => { + return arg.startsWith('v') ? arg.slice(1) : arg + }) + + if (oldVersion === newVersion) { + showUsage(args) + } + + bumpNpmVersion(newVersion) + + try { + 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 deleted file mode 100644 index 267419dda004..000000000000 --- a/build/generate-sri.js +++ /dev/null @@ -1,60 +0,0 @@ -#!/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-2019 The Bootstrap Authors - * Copyright 2017-2019 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -'use strict' - -const crypto = require('crypto') -const fs = require('fs') -const path = require('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/js/bootstrap.min.js', - configPropertyName: 'js_hash' - }, - { - file: 'dist/js/bootstrap.bundle.min.js', - configPropertyName: 'js_bundle_hash' - }, - { - file: 'node_modules/popper.js/dist/umd/popper.min.js', - configPropertyName: 'popper_hash' - } -] - -files.forEach(file => { - fs.readFile(file.file, 'utf8', (err, data) => { - if (err) { - throw err - } - - 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}$3`, configFile) - }) -}) diff --git a/build/generate-sri.mjs b/build/generate-sri.mjs new file mode 100644 index 000000000000..5622843f3479 --- /dev/null +++ b/build/generate-sri.mjs @@ -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-2025 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ + +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sh from 'shelljs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +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, configPropertyName } of files) { + fs.readFile(file, 'utf8', (error, data) => { + if (error) { + throw error + } + + const algorithm = 'sha384' + const hash = crypto.createHash(algorithm).update(data, 'utf8').digest('base64') + const integrity = `${algorithm}-${hash}` + + console.log(`${configPropertyName}: ${integrity}`) + + sh.sed('-i', new RegExp(`^(\\s+${configPropertyName}:\\s+["'])\\S*(["'])`), `$1${integrity}$2`, configFile) + }) +} diff --git a/build/postcss.config.js b/build/postcss.config.js deleted file mode 100644 index 56b7d94cc1fa..000000000000 --- a/build/postcss.config.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict' - -module.exports = ctx => ({ - map: ctx.file.dirname.includes('examples') ? - false : - { - inline: false, - annotation: true, - sourcesContent: true - }, - plugins: { - autoprefixer: { - cascade: false - } - } -}) diff --git a/build/postcss.config.mjs b/build/postcss.config.mjs new file mode 100644 index 000000000000..7717cfc3f1f6 --- /dev/null +++ b/build/postcss.config.mjs @@ -0,0 +1,17 @@ +const mapConfig = { + inline: false, + annotation: true, + sourcesContent: true +} + +export default 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 deleted file mode 100644 index 2f0badb3d2c8..000000000000 --- a/build/rollup.config.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict' - -const path = require('path') -const babel = require('rollup-plugin-babel') -const resolve = require('rollup-plugin-node-resolve') -const banner = require('./banner.js') - -const BUNDLE = process.env.BUNDLE === 'true' -const ESM = process.env.ESM === 'true' - -let fileDest = `bootstrap${ESM ? '.esm' : ''}` -const external = ['popper.js'] -const plugins = [ - babel({ - // Only transpile our source code - exclude: 'node_modules/**', - // Include only required helpers - externalHelpersWhitelist: [ - 'defineProperties', - 'createClass', - 'inheritsLoose', - 'defineProperty', - 'objectSpread2' - ] - }) -] -const globals = { - 'popper.js': 'Popper' -} - -if (BUNDLE) { - fileDest += '.bundle' - // Remove last entry in external array to bundle Popper - external.pop() - delete globals['popper.js'] - plugins.push(resolve()) -} - -const rollupConfig = { - input: path.resolve(__dirname, `../js/index.${ESM ? 'esm' : 'umd'}.js`), - output: { - banner, - file: path.resolve(__dirname, `../dist/js/${fileDest}.js`), - format: ESM ? 'esm' : 'umd', - globals - }, - external, - plugins -} - -if (!ESM) { - rollupConfig.output.name = 'bootstrap' -} - -module.exports = rollupConfig diff --git a/build/rollup.config.mjs b/build/rollup.config.mjs new file mode 100644 index 000000000000..dd6c7d13e66f --- /dev/null +++ b/build/rollup.config.mjs @@ -0,0 +1,59 @@ +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { babel } from '@rollup/plugin-babel' +import { nodeResolve } from '@rollup/plugin-node-resolve' +import replace from '@rollup/plugin-replace' +import banner from './banner.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const BUNDLE = process.env.BUNDLE === 'true' +const ESM = process.env.ESM === 'true' + +let destinationFile = `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) { + destinationFile += '.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: banner(), + file: path.resolve(__dirname, `../dist/js/${destinationFile}.js`), + format: ESM ? 'esm' : 'umd', + globals, + generatedCode: 'es2015' + }, + external, + plugins +} + +if (!ESM) { + rollupConfig.output.name = 'bootstrap' +} + +export default rollupConfig diff --git a/build/ship.sh b/build/ship.sh deleted file mode 100755 index acce9b922885..000000000000 --- a/build/ship.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# -# Usage -# --------------- -# 1. Clone second version of Bootstrap in sibling directory named `bs-docs`. -# 2. Within `bs-docs` copy, switch to `gh-pages` branch. -# 3. Pull latest, re-bundle, re-npm. -# 4. Run script. - -red=$'\e[1;31m' -green=$'\e[1;32m' -#blue=$'\e[1;34m' -magenta=$'\e[1;35m' -#cyan=$'\e[1;36m' -end=$'\e[0m' - -# Get current version from package.json -current_version=$(node -p "require('./package.json').version") - -if [[ $# -lt 1 ]]; then - printf "\n%s⚠️ Shipping aborted. You must specify a version.\n%s" $red $end - exit 1 -fi - -# Pulling latest changes, just to be sure -printf "\n%s=======================================================%s" $magenta $end -printf "\n%sPulling latest changes...%s" $magenta $end -printf "\n%s=======================================================\n\n%s" $magenta $end -git pull origin master - -# Update version number -printf "\n%s=======================================================%s" $magenta $end -printf "\n%sUpdating version number...%s" $magenta $end -printf "\n%s=======================================================\n%s" $magenta $end -npm run release-version "$current_version" "$1" - -# Build release -printf "\n%s=======================================================%s" $magenta $end -printf "\n%sBuilding release...%s" $magenta $end -printf "\n%s=======================================================\n%s" $magenta $end -npm run release - -# Copy the contents of the built docs site over to `bs-docs` repo -printf "\n%s=======================================================%s" $magenta $end -printf "\n%sCopy it over...%s" $magenta $end -printf "\n%s=======================================================\n%s" $magenta $end -cp -rf _gh_pages/. ../bs-docs/ -printf "\nDone!\n" - -printf "\n%s=======================================================%s" $green $end -printf "\n%sSuccess, $1 is ready to review and publish.%s" $green $end -printf "\n%s=======================================================\n\n%s" $green $end diff --git a/build/svgo.yml b/build/svgo.yml deleted file mode 100644 index 67940d393ee3..000000000000 --- a/build/svgo.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Usage: -# install svgo globally: `npm i -g svgo` -# svgo --config=build/svgo.yml --input=foo.svg - -# https://github.com/svg/svgo/blob/master/docs/how-it-works/en.md -# replace default config - -multipass: true -#full: true - -# https://github.com/svg/svgo/blob/master/lib/svgo/js2svg.js#L6 for more config options - -js2svg: - pretty: true - indent: 2 - -plugins: -# - addAttributesToSVGElement: -# attributes: -# - focusable: false - - cleanupAttrs: true - - cleanupEnableBackground: true - - cleanupIDs: true - - cleanupListOfValues: true - - cleanupNumericValues: true - - collapseGroups: true - - convertColors: true - - convertPathData: true - - convertShapeToPath: true - - convertStyleToAttrs: true - - convertTransform: true - - inlineStyles: true - - mergePaths: true - - minifyStyles: true - - moveElemsAttrsToGroup: true - - moveGroupAttrsToElems: true - - removeAttrs: - attrs: - - "data-name" - - removeComments: true - - removeDesc: true - - removeDoctype: true - - removeEditorsNSData: true - - removeEmptyAttrs: true - - removeEmptyContainers: true - - removeEmptyText: true - - removeHiddenElems: true - - removeMetadata: true - - removeNonInheritableGroupAttrs: true - - removeTitle: false - - removeUnknownsAndDefaults: - keepRoleAttr: true - - removeUnusedNS: true - - removeUselessDefs: true - - removeUselessStrokeAndFill: true - - removeViewBox: false - - removeXMLNS: false - - removeXMLProcInst: true - - sortAttrs: true diff --git a/build/vnu-jar.js b/build/vnu-jar.js deleted file mode 100644 index 8c5190dbeabc..000000000000 --- a/build/vnu-jar.js +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node - -/*! - * Script to run vnu-jar if Java is available. - * Copyright 2017-2019 The Bootstrap Authors - * Copyright 2017-2019 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -'use strict' - -const childProcess = require('child_process') -const vnu = require('vnu-jar') - -childProcess.exec('java -version', (error, stdout, stderr) => { - if (error) { - console.error('Skipping vnu-jar test; Java is missing.') - return - } - - const is32bitJava = !stderr.match(/64-Bit/) - - // vnu-jar accepts multiple ignores joined with a `|`. - // Also note that the ignores are regular expressions. - const ignores = [ - // "autocomplete" is included in - + diff --git a/js/tests/integration/rollup.bundle-modularity.js b/js/tests/integration/rollup.bundle-modularity.js index 68313275bdd9..63d6515251cc 100644 --- a/js/tests/integration/rollup.bundle-modularity.js +++ b/js/tests/integration/rollup.bundle-modularity.js @@ -1,7 +1,7 @@ -/* eslint-env node */ +'use strict' -const commonjs = require('rollup-plugin-commonjs') -const configRollup = require('./rollup.bundle') +const commonjs = require('@rollup/plugin-commonjs') +const configRollup = require('./rollup.bundle.js') const config = { ...configRollup, diff --git a/js/tests/integration/rollup.bundle.js b/js/tests/integration/rollup.bundle.js index 4aa1dcde94a9..8b3c578a255a 100644 --- a/js/tests/integration/rollup.bundle.js +++ b/js/tests/integration/rollup.bundle.js @@ -1,7 +1,8 @@ -/* eslint-env node */ +'use strict' -const resolve = require('rollup-plugin-node-resolve') -const babel = require('rollup-plugin-babel') +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', @@ -10,9 +11,14 @@ module.exports = { format: 'iife' }, plugins: [ - resolve(), + replace({ + 'process.env.NODE_ENV': '"production"', + preventAssignment: true + }), + nodeResolve(), babel({ - exclude: 'node_modules/**' + exclude: 'node_modules/**', + babelHelpers: 'bundled' }) ] } diff --git a/js/tests/karma.conf.js b/js/tests/karma.conf.js index 624f5f638a76..36bf7f2df8fe 100644 --- a/js/tests/karma.conf.js +++ b/js/tests/karma.conf.js @@ -1,18 +1,18 @@ -/* eslint-env node */ -const path = require('path') +'use strict' + +const path = require('node:path') const ip = require('ip') -const babel = require('rollup-plugin-babel') +const { babel } = require('@rollup/plugin-babel') const istanbul = require('rollup-plugin-istanbul') -const resolve = require('rollup-plugin-node-resolve') +const { nodeResolve } = require('@rollup/plugin-node-resolve') +const replace = require('@rollup/plugin-replace') +const { browsers } = require('./browsers.js') -const { - browsers, - browsersKeys -} = require('./browsers') +const ENV = process.env +const BROWSERSTACK = Boolean(ENV.BROWSERSTACK) +const DEBUG = Boolean(ENV.DEBUG) +const JQUERY_TEST = Boolean(ENV.JQUERY) -const { env } = process -const browserStack = env.BROWSER === 'true' -const debug = env.DEBUG === 'true' const frameworks = [ 'jasmine' ] @@ -27,85 +27,105 @@ const reporters = ['dots'] const detectBrowsers = { usePhantomJS: false, postDetection(availableBrowser) { - if (env.CI === true || availableBrowser.includes('Chrome')) { - return debug ? ['Chrome'] : ['ChromeHeadless'] + // On CI just use Chrome + if (ENV.CI === true) { + return ['ChromeHeadless'] } - if (availableBrowser.includes('Firefox')) { - return debug ? ['Firefox'] : ['FirefoxHeadless'] + if (availableBrowser.includes('Chrome')) { + return DEBUG ? ['Chrome'] : ['ChromeHeadless'] } - throw new Error('Please install Firefox or Chrome') - } -} + if (availableBrowser.includes('Chromium')) { + return DEBUG ? ['Chromium'] : ['ChromiumHeadless'] + } -const customLaunchers = { - FirefoxHeadless: { - base: 'Firefox', - flags: ['-headless'] + if (availableBrowser.includes('Firefox')) { + return DEBUG ? ['Firefox'] : ['FirefoxHeadless'] + } + + throw new Error('Please install Chrome, Chromium or Firefox') } } -const conf = { +const config = { basePath: '../..', port: 9876, colors: true, autoWatch: false, singleRun: true, - concurrency: Infinity, + concurrency: Number.POSITIVE_INFINITY, client: { clearContext: false }, files: [ 'node_modules/hammer-simulator/index.js', - { pattern: 'js/tests/unit/**/*.spec.js', watched: !browserStack } + { + 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: ['js/tests/unit/**/*.spec.js', 'js/tests/helpers/**/*.js'] + exclude: [ + 'node_modules/**', + 'js/tests/unit/**/*.spec.js', + 'js/tests/helpers/**/*.js' + ] }), babel({ // Only transpile our source code exclude: 'node_modules/**', - // Include only required helpers - externalHelpersWhitelist: [ - 'defineProperties', - 'createClass', - 'inheritsLoose', - 'defineProperty', - 'objectSpread2' - ], - plugins: [ - '@babel/plugin-proposal-object-rest-spread' - ] + // Inline the required helpers in each file + babelHelpers: 'inline' }), - resolve() + nodeResolve() ], output: { format: 'iife', name: 'bootstrapTest', - sourcemap: 'inline' + sourcemap: 'inline', + generatedCode: 'es2015' } } } -if (browserStack) { - conf.hostname = ip.address() - conf.browserStack = { - username: env.BROWSER_STACK_USERNAME, - accessKey: env.BROWSER_STACK_ACCESS_KEY, - build: `bootstrap-${new Date().toISOString()}`, +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') - conf.customLaunchers = browsers - conf.browsers = browsersKeys + 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( @@ -115,47 +135,35 @@ if (browserStack) { 'karma-coverage-istanbul-reporter' ) reporters.push('coverage-istanbul') - conf.customLaunchers = customLaunchers - conf.detectBrowsers = detectBrowsers - conf.coverageIstanbulReporter = { + config.detectBrowsers = detectBrowsers + config.coverageIstanbulReporter = { dir: path.resolve(__dirname, '../coverage/'), reports: ['lcov', 'text-summary'], thresholds: { emitWarning: false, global: { statements: 90, - branches: 90, + branches: 89, functions: 90, lines: 90 - }, - each: { - overrides: { - 'js/src/dom/polyfill.js': { - statements: 39, - lines: 37, - branches: 19, - functions: 50 - } - } } } } - if (debug) { - conf.hostname = ip.address() + if (DEBUG) { + config.hostname = ip.address() plugins.push('karma-jasmine-html-reporter') reporters.push('kjhtml') - conf.singleRun = false - conf.autoWatch = true + config.singleRun = false + config.autoWatch = true } } -conf.frameworks = frameworks -conf.plugins = plugins -conf.reporters = reporters +config.frameworks = frameworks +config.plugins = plugins +config.reporters = reporters module.exports = karmaConfig => { - // possible values: karmaConfig.LOG_DISABLE || karmaConfig.LOG_ERROR || karmaConfig.LOG_WARN || karmaConfig.LOG_INFO || karmaConfig.LOG_DEBUG - conf.logLevel = karmaConfig.LOG_ERROR || karmaConfig.LOG_WARN - karmaConfig.set(conf) + config.logLevel = karmaConfig.LOG_ERROR + karmaConfig.set(config) } diff --git a/js/tests/unit/.eslintrc.json b/js/tests/unit/.eslintrc.json deleted file mode 100644 index a8c1a6ae3427..000000000000 --- a/js/tests/unit/.eslintrc.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "root": true, - "extends": [ - "../../../.eslintrc.json" - ], - "overrides": [ - { - "files": ["**/*.spec.js"], - "env": { - "jasmine": true - } - } - ] -} diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js index 32f11b618850..97cc3cc5325b 100644 --- a/js/tests/unit/alert.spec.js +++ b/js/tests/unit/alert.spec.js @@ -1,8 +1,6 @@ -import Alert from '../../src/alert' -import { makeArray, getTransitionDurationFromElement } from '../../src/util/index' - -/** Test helpers */ -import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture' +import Alert from '../../src/alert.js' +import { getTransitionDurationFromElement } from '../../src/util/index.js' +import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js' describe('Alert', () => { let fixtureEl @@ -15,98 +13,116 @@ describe('Alert', () => { clearFixture() }) + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '
' + + 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(typeof Alert.VERSION).toEqual('string') + 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 instantiate it manually', () => { + it('should close an alert without instantiating it manually', () => { fixtureEl.innerHTML = [ '
', - ' ', + ' ', '
' ].join('') const button = document.querySelector('button') button.click() - expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0) + expect(document.querySelectorAll('.alert')).toHaveSize(0) }) - it('should close an alert without instantiate it manually with the parent selector', () => { + it('should close an alert without instantiating it manually with the parent selector', () => { fixtureEl.innerHTML = [ '
', - ' ', + ' ', '
' ].join('') const button = document.querySelector('button') button.click() - expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0) + expect(document.querySelectorAll('.alert')).toHaveSize(0) }) }) describe('close', () => { - it('should close an alert', done => { - const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) - fixtureEl.innerHTML = '
' + it('should close an alert', () => { + return new Promise(resolve => { + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + fixtureEl.innerHTML = '
' - const alertEl = document.querySelector('.alert') - const alert = new Alert(alertEl) + const alertEl = document.querySelector('.alert') + const alert = new Alert(alertEl) - alertEl.addEventListener('closed.bs.alert', () => { - expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0) - expect(spy).not.toHaveBeenCalled() - done() - }) + alertEl.addEventListener('closed.bs.alert', () => { + expect(document.querySelectorAll('.alert')).toHaveSize(0) + expect(spy).not.toHaveBeenCalled() + resolve() + }) - alert.close() + alert.close() + }) }) - it('should close alert with fade class', done => { - fixtureEl.innerHTML = '
' + it('should close alert with fade class', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const alertEl = document.querySelector('.alert') - const alert = new Alert(alertEl) + const alertEl = document.querySelector('.alert') + const alert = new Alert(alertEl) - alertEl.addEventListener('transitionend', () => { - expect().nothing() - }) + alertEl.addEventListener('transitionend', () => { + expect().nothing() + }) - alertEl.addEventListener('closed.bs.alert', () => { - expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0) - done() - }) + alertEl.addEventListener('closed.bs.alert', () => { + expect(document.querySelectorAll('.alert')).toHaveSize(0) + resolve() + }) - alert.close() + alert.close() + }) }) - it('should not remove alert if close event is prevented', done => { - fixtureEl.innerHTML = '
' - - const alertEl = document.querySelector('.alert') - const alert = new Alert(alertEl) + it('should not remove alert if close event is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = '
' - const endTest = () => { - setTimeout(() => { - expect(alert._removeElement).not.toHaveBeenCalled() - done() - }, 10) - } + const getAlert = () => document.querySelector('.alert') + const alertEl = getAlert() + const alert = new Alert(alertEl) - spyOn(alert, '_removeElement') + alertEl.addEventListener('close.bs.alert', event => { + event.preventDefault() + setTimeout(() => { + expect(getAlert()).not.toBeNull() + resolve() + }, 10) + }) - alertEl.addEventListener('close.bs.alert', event => { - event.preventDefault() - endTest() - }) + alertEl.addEventListener('closed.bs.alert', () => { + reject(new Error('should not fire closed event')) + }) - alertEl.addEventListener('closed.bs.alert', () => { - endTest() + alert.close() }) - - alert.close() }) }) @@ -117,7 +133,7 @@ describe('Alert', () => { const alertEl = document.querySelector('.alert') const alert = new Alert(alertEl) - expect(Alert.getInstance(alertEl)).toBeDefined() + expect(Alert.getInstance(alertEl)).not.toBeNull() alert.dispose() @@ -132,14 +148,14 @@ describe('Alert', () => { const alertEl = fixtureEl.querySelector('.alert') const alert = new Alert(alertEl) - spyOn(alert, 'close') + const spy = spyOn(alert, 'close') jQueryMock.fn.alert = Alert.jQueryInterface jQueryMock.elements = [alertEl] jQueryMock.fn.alert.call(jQueryMock, 'close') - expect(alert.close).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() }) it('should create new alert instance and call close', () => { @@ -150,9 +166,9 @@ describe('Alert', () => { jQueryMock.fn.alert = Alert.jQueryInterface jQueryMock.elements = [alertEl] + expect(Alert.getInstance(alertEl)).toBeNull() jQueryMock.fn.alert.call(jQueryMock, 'close') - expect(Alert.getInstance(alertEl)).toBeDefined() expect(fixtureEl.querySelector('.alert')).toBeNull() }) @@ -166,8 +182,78 @@ describe('Alert', () => { jQueryMock.fn.alert.call(jQueryMock) - expect(Alert.getInstance(alertEl)).toBeDefined() + expect(Alert.getInstance(alertEl)).not.toBeNull() expect(fixtureEl.querySelector('.alert')).not.toBeNull() }) + + it('should throw an error on undefined method', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + 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 = '
' + + 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 = '
' + + const div = fixtureEl.querySelector('div') + + expect(Alert.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return alert instance', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + 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 000000000000..5b7d52e2331a --- /dev/null +++ b/js/tests/unit/base-component.spec.js @@ -0,0 +1,168 @@ +import BaseComponent from '../../src/base-component.js' +import EventHandler from '../../src/dom/event-handler.js' +import { noop } from '../../src/util/index.js' +import { clearFixture, getFixture } from '../helpers/fixture.js' + +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 = '
' + 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 = [ + '
', + '
' + ].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 = '
' + + 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 = '
' + 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 index a3c95be1ba77..6624fee7c98e 100644 --- a/js/tests/unit/button.spec.js +++ b/js/tests/unit/button.spec.js @@ -1,13 +1,5 @@ -import Button from '../../src/button' -import EventHandler from '../../src/dom/event-handler' - -/** Test helpers */ -import { - getFixture, - clearFixture, - createEvent, - jQueryMock -} from '../helpers/fixture' +import Button from '../../src/button.js' +import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js' describe('Button', () => { let fixtureEl @@ -20,223 +12,80 @@ describe('Button', () => { clearFixture() }) + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '' + 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 = [ - '', - '' + '', + '' ].join('') const btn = fixtureEl.querySelector('.btn') const divTest = fixtureEl.querySelector('.test') const btnTestParent = fixtureEl.querySelector('.testParent') - expect(btn.classList.contains('active')).toEqual(false) + expect(btn).not.toHaveClass('active') btn.click() - expect(btn.classList.contains('active')).toEqual(true) + expect(btn).toHaveClass('active') btn.click() - expect(btn.classList.contains('active')).toEqual(false) + expect(btn).not.toHaveClass('active') divTest.click() - expect(btnTestParent.classList.contains('active')).toEqual(true) - }) - - it('should trigger input change event when toggled button has input field', done => { - fixtureEl.innerHTML = [ - '
', - ' ', - '
' - ].join('') - - const input = fixtureEl.querySelector('input') - const label = fixtureEl.querySelector('label') - - input.addEventListener('change', () => { - expect().nothing() - done() - }) - - label.click() - }) - - it('should not trigger input change event when input already checked and button is active', () => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const button = fixtureEl.querySelector('button') - - spyOn(EventHandler, 'trigger') - - button.click() - - expect(EventHandler.trigger).not.toHaveBeenCalled() - }) - - it('should remove active when an other radio button is clicked', () => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - ' ', - '
' - ].join('') - - const option1 = fixtureEl.querySelector('#option1') - const option2 = fixtureEl.querySelector('#option2') - - expect(option1.checked).toEqual(true) - expect(option1.parentElement.classList.contains('active')).toEqual(true) - - const clickEvent = createEvent('click') - - option2.dispatchEvent(clickEvent) - - expect(option1.checked).toEqual(false) - expect(option1.parentElement.classList.contains('active')).toEqual(false) - expect(option2.checked).toEqual(true) - expect(option2.parentElement.classList.contains('active')).toEqual(true) - }) - - it('should do nothing if the child is not an input', () => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - ' ', - '
' - ].join('') - - const option2 = fixtureEl.querySelector('#option2') - const clickEvent = createEvent('click') - - option2.dispatchEvent(clickEvent) - - expect().nothing() - }) - - it('should add focus class on focus event', () => { - fixtureEl.innerHTML = '' - - const btn = fixtureEl.querySelector('.btn') - const input = fixtureEl.querySelector('input') - - const focusEvent = createEvent('focus') - input.dispatchEvent(focusEvent) - - expect(btn.classList.contains('focus')).toEqual(true) - }) - - it('should not add focus class', () => { - fixtureEl.innerHTML = '' - - const btn = fixtureEl.querySelector('button') - const input = fixtureEl.querySelector('input') - - const focusEvent = createEvent('focus') - input.dispatchEvent(focusEvent) - - expect(btn.classList.contains('focus')).toEqual(false) - }) - - it('should remove focus class on blur event', () => { - fixtureEl.innerHTML = '' - - const btn = fixtureEl.querySelector('.btn') - const input = fixtureEl.querySelector('input') - - const focusEvent = createEvent('blur') - input.dispatchEvent(focusEvent) - - expect(btn.classList.contains('focus')).toEqual(false) - }) - - it('should not remove focus class on blur event', () => { - fixtureEl.innerHTML = '' - - const btn = fixtureEl.querySelector('button') - const input = fixtureEl.querySelector('input') - - const focusEvent = createEvent('blur') - input.dispatchEvent(focusEvent) - - expect(btn.classList.contains('focus')).toEqual(true) + expect(btnTestParent).toHaveClass('active') }) }) describe('toggle', () => { it('should toggle aria-pressed', () => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' const btnEl = fixtureEl.querySelector('.btn') const button = new Button(btnEl) expect(btnEl.getAttribute('aria-pressed')).toEqual('false') - expect(btnEl.classList.contains('active')).toEqual(false) + expect(btnEl).not.toHaveClass('active') button.toggle() expect(btnEl.getAttribute('aria-pressed')).toEqual('true') - expect(btnEl.classList.contains('active')).toEqual(true) - }) - - it('should handle disabled attribute on non-button elements', () => { - fixtureEl.innerHTML = [ - '
', - ' ', - '
' - ].join('') - - const btnGroupEl = fixtureEl.querySelector('.btn-group') - const btnDanger = fixtureEl.querySelector('.btn-danger') - const input = fixtureEl.querySelector('input') - - const button = new Button(btnGroupEl) - - button.toggle() - - expect(btnDanger.hasAttribute('disabled')).toEqual(true) - expect(input.checked).toEqual(false) + expect(btnEl).toHaveClass('active') }) }) describe('dispose', () => { it('should dispose a button', () => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' const btnEl = fixtureEl.querySelector('.btn') const button = new Button(btnEl) - expect(Button.getInstance(btnEl)).toBeDefined() + expect(Button.getInstance(btnEl)).not.toBeNull() button.dispose() @@ -246,23 +95,23 @@ describe('Button', () => { describe('jQueryInterface', () => { it('should handle config passed and toggle existing button', () => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' const btnEl = fixtureEl.querySelector('.btn') const button = new Button(btnEl) - spyOn(button, 'toggle') + const spy = spyOn(button, 'toggle') jQueryMock.fn.button = Button.jQueryInterface jQueryMock.elements = [btnEl] jQueryMock.fn.button.call(jQueryMock, 'toggle') - expect(button.toggle).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() }) it('should create new button instance and call toggle', () => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' const btnEl = fixtureEl.querySelector('.btn') @@ -271,12 +120,12 @@ describe('Button', () => { jQueryMock.fn.button.call(jQueryMock, 'toggle') - expect(Button.getInstance(btnEl)).toBeDefined() - expect(btnEl.classList.contains('active')).toEqual(true) + expect(Button.getInstance(btnEl)).not.toBeNull() + expect(btnEl).toHaveClass('active') }) it('should just create a button instance without calling toggle', () => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' const btnEl = fixtureEl.querySelector('.btn') @@ -285,8 +134,50 @@ describe('Button', () => { jQueryMock.fn.button.call(jQueryMock) - expect(Button.getInstance(btnEl)).toBeDefined() - expect(btnEl.classList.contains('active')).toEqual(false) + expect(Button.getInstance(btnEl)).not.toBeNull() + expect(btnEl).not.toHaveClass('active') + }) + }) + + describe('getInstance', () => { + it('should return button instance', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + const div = fixtureEl.querySelector('div') + + expect(Button.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return button instance', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + 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 index a163f9ae41e0..2960eb5ce59f 100644 --- a/js/tests/unit/carousel.spec.js +++ b/js/tests/unit/carousel.spec.js @@ -1,20 +1,21 @@ -import Carousel from '../../src/carousel' -import EventHandler from '../../src/dom/event-handler' - -/** Test helpers */ -import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' +import Carousel from '../../src/carousel.js' +import EventHandler from '../../src/dom/event-handler.js' +import { isRTL, noop } from '../../src/util/index.js' +import Swipe from '../../src/util/swipe.js' +import { + clearFixture, createEvent, getFixture, jQueryMock +} from '../helpers/fixture.js' describe('Carousel', () => { - const { Simulator, PointerEvent, MSPointerEvent } = window - const originWinPointerEvent = PointerEvent || MSPointerEvent - const supportPointerEvent = Boolean(PointerEvent || MSPointerEvent) + const { Simulator, PointerEvent } = window + const originWinPointerEvent = PointerEvent + const supportPointerEvent = Boolean(PointerEvent) - window.MSPointerEvent = null - const cssStyleCarousel = '.carousel.pointer-event { -ms-touch-action: none; touch-action: none; }' + const cssStyleCarousel = '.carousel.pointer-event { touch-action: none; }' const stylesCarousel = document.createElement('style') stylesCarousel.type = 'text/css' - stylesCarousel.appendChild(document.createTextNode(cssStyleCarousel)) + stylesCarousel.append(document.createTextNode(cssStyleCarousel)) const clearPointerEvents = () => { window.PointerEvent = null @@ -46,95 +47,166 @@ describe('Carousel', () => { }) }) + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Carousel.DATA_KEY).toEqual('bs.carousel') + }) + }) + describe('constructor', () => { - it('should go to next item if right arrow key is pressed', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '' const carouselEl = fixtureEl.querySelector('#myCarousel') - const carousel = new Carousel(carouselEl, { - keyboard: true - }) + const carouselBySelector = new Carousel('#myCarousel') + const carouselByElement = new Carousel(carouselEl) - spyOn(carousel, '_keydown').and.callThrough() + expect(carouselBySelector._element).toEqual(carouselEl) + expect(carouselByElement._element).toEqual(carouselEl) + }) - carouselEl.addEventListener('slid.bs.carousel', () => { - expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2')) - expect(carousel._keydown).toHaveBeenCalled() - done() - }) + it('should start cycling if `ride`===`carousel`', () => { + fixtureEl.innerHTML = '' - const keyDown = createEvent('keydown') - keyDown.which = 39 + const carousel = new Carousel('#myCarousel') + expect(carousel._interval).not.toBeNull() + }) + + it('should not start cycling if `ride`!==`carousel`', () => { + fixtureEl.innerHTML = '' + + const carousel = new Carousel('#myCarousel') + expect(carousel._interval).toBeNull() + }) - carouselEl.dispatchEvent(keyDown) + it('should go to next item if right arrow key is pressed', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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 go to previous item if left arrow key is pressed', done => { + it('should ignore keyboard events if data-bs-keyboard=false', () => { fixtureEl.innerHTML = [ - '' ].join('') diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js index 3122ae6f4bce..58c5367526b9 100644 --- a/js/tests/unit/collapse.spec.js +++ b/js/tests/unit/collapse.spec.js @@ -1,9 +1,6 @@ -import Collapse from '../../src/collapse' -import EventHandler from '../../src/dom/event-handler' -import { makeArray } from '../../src/util/index' - -/** Test helpers */ -import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture' +import Collapse from '../../src/collapse.js' +import EventHandler from '../../src/dom/event-handler.js' +import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js' describe('Collapse', () => { let fixtureEl @@ -28,12 +25,29 @@ describe('Collapse', () => { }) }) + 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 = '
' + + 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 = [ '
', '
', - ' Toggle item', + ' Toggle item', '
Lorem ipsum
', '
', '
' @@ -42,21 +56,21 @@ describe('Collapse', () => { const collapseEl = fixtureEl.querySelector('div.collapse') const myCollapseEl = fixtureEl.querySelector('.my-collapse') const fakejQueryObject = { - 0: myCollapseEl + 0: myCollapseEl, + jquery: 'foo' } const collapse = new Collapse(collapseEl, { parent: fakejQueryObject }) - expect(collapse._config.parent).toEqual(fakejQueryObject) - expect(collapse._getParent()).toEqual(myCollapseEl) + expect(collapse._config.parent).toEqual(myCollapseEl) }) it('should allow non jquery object in parent config', () => { fixtureEl.innerHTML = [ '
', '
', - ' Toggle item', + ' Toggle item', '
Lorem ipsum
', '
', '
' @@ -75,7 +89,7 @@ describe('Collapse', () => { fixtureEl.innerHTML = [ '
', '
', - ' Toggle item', + ' Toggle item', '
Lorem ipsum
', '
', '
' @@ -87,8 +101,7 @@ describe('Collapse', () => { parent: 'div.my-collapse' }) - expect(collapse._config.parent).toEqual('div.my-collapse') - expect(collapse._getParent()).toEqual(myCollapseEl) + expect(collapse._config.parent).toEqual(myCollapseEl) }) }) @@ -99,11 +112,11 @@ describe('Collapse', () => { const collapseEl = fixtureEl.querySelector('div') const collapse = new Collapse(collapseEl) - spyOn(collapse, 'show') + const spy = spyOn(collapse, 'show') collapse.toggle() - expect(collapse.show).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() }) it('should call hide method if show class is present', () => { @@ -114,44 +127,46 @@ describe('Collapse', () => { toggle: false }) - spyOn(collapse, 'hide') + const spy = spyOn(collapse, 'hide') collapse.toggle() - expect(collapse.hide).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() }) - it('should find collapse children if they have collapse class too not only data-parent', done => { - fixtureEl.innerHTML = [ - '
', - '
', - ' Toggle item 1', - '
Lorem ipsum 1
', - '
', - '
', - ' Toggle item 2', - '
Lorem ipsum 2
', - '
', - '
' - ].join('') - - const parent = fixtureEl.querySelector('.my-collapse') - const collapseEl1 = fixtureEl.querySelector('#collapse1') - const collapseEl2 = fixtureEl.querySelector('#collapse2') - - const collapseList = makeArray(fixtureEl.querySelectorAll('.collapse')) - .map(el => new Collapse(el, { - parent, - toggle: false - })) + it('should find collapse children if they have collapse class too not only data-bs-parent', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + ' Toggle item 1', + '
Lorem ipsum 1
', + '
', + '
', + ' Toggle item 2', + '
Lorem ipsum 2
', + '
', + '
' + ].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() + }) - collapseEl2.addEventListener('shown.bs.collapse', () => { - expect(collapseEl2.classList.contains('show')).toEqual(true) - expect(collapseEl1.classList.contains('show')).toEqual(false) - done() + collapseList[1].toggle() }) - - collapseList[1].toggle() }) }) @@ -159,7 +174,7 @@ describe('Collapse', () => { it('should do nothing if is transitioning', () => { fixtureEl.innerHTML = '
' - spyOn(EventHandler, 'trigger') + const spy = spyOn(EventHandler, 'trigger') const collapseEl = fixtureEl.querySelector('div') const collapse = new Collapse(collapseEl, { @@ -169,13 +184,13 @@ describe('Collapse', () => { collapse._isTransitioning = true collapse.show() - expect(EventHandler.trigger).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() }) it('should do nothing if already shown', () => { fixtureEl.innerHTML = '
' - spyOn(EventHandler, 'trigger') + const spy = spyOn(EventHandler, 'trigger') const collapseEl = fixtureEl.querySelector('div') const collapse = new Collapse(collapseEl, { @@ -184,99 +199,218 @@ describe('Collapse', () => { collapse.show() - expect(EventHandler.trigger).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() }) - it('should show a collapsed element', done => { - fixtureEl.innerHTML = '
' + it('should show a collapsed element', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const collapseEl = fixtureEl.querySelector('div') - const collapse = new Collapse(collapseEl, { - toggle: false - }) + 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.classList.contains('show')).toEqual(true) - expect(collapseEl.style.height).toEqual('') - done() - }) + 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() + collapse.show() + }) }) - it('should show a collapsed element on width', done => { - fixtureEl.innerHTML = '
' + it('should show a collapsed element on width', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const collapseEl = fixtureEl.querySelector('div') - const collapse = new Collapse(collapseEl, { - toggle: false - }) + 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.classList.contains('show')).toEqual(true) - expect(collapseEl.style.width).toEqual('') - done() - }) + 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() + collapse.show() + }) }) - it('should collapse only the first collapse', done => { - fixtureEl.innerHTML = [ - '
', - '
', - '
', - '
', - '
', - '
' - ].join('') + it('should collapse only the first collapse', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + '
', + '
', + '
', + '
' + ].join('') + + const el1 = fixtureEl.querySelector('#collapse1') + const el2 = fixtureEl.querySelector('#collapse2') + const collapse = new Collapse(el1, { + toggle: false + }) - 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() + }) - el1.addEventListener('shown.bs.collapse', () => { - expect(el1.classList.contains('show')).toEqual(true) - expect(el2.classList.contains('show')).toEqual(true) - done() + collapse.show() }) - - collapse.show() }) - it('should not fire shown when show is prevented', done => { - fixtureEl.innerHTML = '
' + it('should be able to handle toggling of other children siblings', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + ' ', + '
', + '
', + '
', + '
', + '
', + '
', + ' ', + '
', + '
', + '
content
', + '
', + '
', + '
', + '
', + ' ', + '
', + '
', + '
content
', + '
', + '
', + '
', + '
', + '
', + '
' + ].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() + }) - const collapseEl = fixtureEl.querySelector('div') - const collapse = new Collapse(collapseEl, { - toggle: false + parentBtn.click() }) + }) - const expectEnd = () => { - setTimeout(() => { - expect().nothing() - done() - }, 10) - } + it('should not change tab tabpanels descendants on accordion', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + '

', + ' ', + '

', + '
', + '
', + ' ', + ' ', + '
', + '
', + '
', + '
' + ].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() + }) - collapseEl.addEventListener('show.bs.collapse', e => { - e.preventDefault() - expectEnd() - }) + el.addEventListener('shown.bs.collapse', () => { + expect(activeTabPane).toHaveClass('show') + times++ + if (times === 2) { + resolve() + } - collapseEl.addEventListener('shown.bs.collapse', () => { - throw new Error('should not fire shown event') + collapse.hide() + }) + + collapse.show() }) + }) - collapse.show() + it('should not fire shown when show is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = '
' + + 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() + }) }) }) @@ -284,7 +418,7 @@ describe('Collapse', () => { it('should do nothing if is transitioning', () => { fixtureEl.innerHTML = '
' - spyOn(EventHandler, 'trigger') + const spy = spyOn(EventHandler, 'trigger') const collapseEl = fixtureEl.querySelector('div') const collapse = new Collapse(collapseEl, { @@ -294,13 +428,13 @@ describe('Collapse', () => { collapse._isTransitioning = true collapse.hide() - expect(EventHandler.trigger).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() }) it('should do nothing if already shown', () => { fixtureEl.innerHTML = '
' - spyOn(EventHandler, 'trigger') + const spy = spyOn(EventHandler, 'trigger') const collapseEl = fixtureEl.querySelector('div') const collapse = new Collapse(collapseEl, { @@ -309,51 +443,55 @@ describe('Collapse', () => { collapse.hide() - expect(EventHandler.trigger).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() }) - it('should hide a collapsed element', done => { - fixtureEl.innerHTML = '
' + it('should hide a collapsed element', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const collapseEl = fixtureEl.querySelector('div') - const collapse = new Collapse(collapseEl, { - toggle: false - }) + const collapseEl = fixtureEl.querySelector('div') + const collapse = new Collapse(collapseEl, { + toggle: false + }) - collapseEl.addEventListener('hidden.bs.collapse', () => { - expect(collapseEl.classList.contains('show')).toEqual(false) - expect(collapseEl.style.height).toEqual('') - done() - }) + collapseEl.addEventListener('hidden.bs.collapse', () => { + expect(collapseEl).not.toHaveClass('show') + expect(collapseEl.style.height).toEqual('') + resolve() + }) - collapse.hide() + collapse.hide() + }) }) - it('should not fire hidden when hide is prevented', done => { - fixtureEl.innerHTML = '
' + it('should not fire hidden when hide is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = '
' - const collapseEl = fixtureEl.querySelector('div') - const collapse = new Collapse(collapseEl, { - toggle: false - }) + const collapseEl = fixtureEl.querySelector('div') + const collapse = new Collapse(collapseEl, { + toggle: false + }) - const expectEnd = () => { - setTimeout(() => { - expect().nothing() - done() - }, 10) - } + const expectEnd = () => { + setTimeout(() => { + expect().nothing() + resolve() + }, 10) + } - collapseEl.addEventListener('hide.bs.collapse', e => { - e.preventDefault() - expectEnd() - }) + collapseEl.addEventListener('hide.bs.collapse', event => { + event.preventDefault() + expectEnd() + }) - collapseEl.addEventListener('hidden.bs.collapse', () => { - throw new Error('should not fire hidden event') - }) + collapseEl.addEventListener('hidden.bs.collapse', () => { + reject(new Error('should not fire hidden event')) + }) - collapse.hide() + collapse.hide() + }) }) }) @@ -370,393 +508,438 @@ describe('Collapse', () => { collapse.dispose() - expect(Collapse.getInstance(collapseEl)).toEqual(null) + expect(Collapse.getInstance(collapseEl)).toBeNull() }) }) describe('data-api', () => { - it('should show multiple collapsed elements', done => { - fixtureEl.innerHTML = [ - '', - '
', - '
' - ].join('') - - const trigger = fixtureEl.querySelector('a') - const collapse1 = fixtureEl.querySelector('#collapse1') - const collapse2 = fixtureEl.querySelector('#collapse2') + it('should prevent url change if click on nested elements', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
' + ].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() + }) - collapse2.addEventListener('shown.bs.collapse', () => { - expect(trigger.getAttribute('aria-expanded')).toEqual('true') - expect(trigger.classList.contains('collapsed')).toEqual(false) - expect(collapse1.classList.contains('show')).toEqual(true) - expect(collapse1.classList.contains('show')).toEqual(true) - done() + nestedTriggerEl.click() }) - - trigger.click() }) - it('should hide multiple collapsed elements', done => { - fixtureEl.innerHTML = [ - '', - '
', - '
' - ].join('') - - const trigger = fixtureEl.querySelector('a') - const collapse1 = fixtureEl.querySelector('#collapse1') - const collapse2 = fixtureEl.querySelector('#collapse2') + it('should show multiple collapsed elements', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
', + '
' + ].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() + }) - collapse2.addEventListener('hidden.bs.collapse', () => { - expect(trigger.getAttribute('aria-expanded')).toEqual('false') - expect(trigger.classList.contains('collapsed')).toEqual(true) - expect(collapse1.classList.contains('show')).toEqual(false) - expect(collapse1.classList.contains('show')).toEqual(false) - done() + trigger.click() }) - - trigger.click() }) - it('should remove "collapsed" class from target when collapse is shown', done => { - fixtureEl.innerHTML = [ - '', + '
', + '
' + ].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() + }) - collapseTest1.addEventListener('shown.bs.collapse', () => { - expect(link1.getAttribute('aria-expanded')).toEqual('true') - expect(link2.getAttribute('aria-expanded')).toEqual('true') - expect(link1.classList.contains('collapsed')).toEqual(false) - expect(link2.classList.contains('collapsed')).toEqual(false) - done() + trigger.click() }) - - link1.click() }) - it('should add "collapsed" class to target when collapse is hidden', done => { - fixtureEl.innerHTML = [ - '', - '', - '
' - ].join('') - - const link1 = fixtureEl.querySelector('#link1') - const link2 = fixtureEl.querySelector('#link2') - const collapseTest1 = fixtureEl.querySelector('#test1') + it('should remove "collapsed" class from target when collapse is shown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '', + '
' + ].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() + }) - collapseTest1.addEventListener('hidden.bs.collapse', () => { - expect(link1.getAttribute('aria-expanded')).toEqual('false') - expect(link2.getAttribute('aria-expanded')).toEqual('false') - expect(link1.classList.contains('collapsed')).toEqual(true) - expect(link2.classList.contains('collapsed')).toEqual(true) - done() + link1.click() }) - - link1.click() }) - it('should allow accordion to use children other than card', done => { - fixtureEl.innerHTML = [ - '
', - '
', - ' ', - '
', - '
', - '
', - ' ', - '
', - '
', - '
' - ].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.classList.contains('show')).toEqual(true) - expect(collapseTwo.classList.contains('show')).toEqual(false) - - collapseTwo.addEventListener('shown.bs.collapse', () => { - expect(collapseOne.classList.contains('show')).toEqual(false) - expect(collapseTwo.classList.contains('show')).toEqual(true) - done() + it('should add "collapsed" class to target when collapse is hidden', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '', + '
' + ].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() }) - triggerTwo.click() + link1.click() }) - - trigger.click() }) - it('should not prevent event for input', done => { - fixtureEl.innerHTML = [ - '', - '
' - ].join('') + it('should allow accordion to use children other than card', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + ' ', + '
', + '
', + '
', + ' ', + '
', + '
', + '
' + ].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() + }) - const target = fixtureEl.querySelector('input') - const collapseEl = fixtureEl.querySelector('#collapsediv1') + triggerTwo.click() + }) - collapseEl.addEventListener('shown.bs.collapse', () => { - expect(collapseEl.classList.contains('show')).toEqual(true) - expect(target.checked).toEqual(true) - done() + trigger.click() }) - - target.click() }) - it('should allow accordion to contain nested elements', done => { - fixtureEl.innerHTML = [ - '
', - '
', - '
', - '
', - ' ', - '
', - '
', - '
', - '
', - '
', - ' ', - '
', - '
', - '
', - '
', - '
' - ].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.classList.contains('show')).toEqual(true) - expect(triggerEl.classList.contains('collapsed')).toEqual(false) - expect(triggerEl.getAttribute('aria-expanded')).toEqual('true') + it('should not prevent event for input', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') - expect(collapseTwoEl.classList.contains('show')).toEqual(false) - expect(triggerTwoEl.classList.contains('collapsed')).toEqual(true) - expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('false') + const target = fixtureEl.querySelector('input') + const collapseEl = fixtureEl.querySelector('#collapsediv1') - collapseTwoEl.addEventListener('shown.bs.collapse', () => { - expect(collapseOneEl.classList.contains('show')).toEqual(false) - expect(triggerEl.classList.contains('collapsed')).toEqual(true) - expect(triggerEl.getAttribute('aria-expanded')).toEqual('false') - - expect(collapseTwoEl.classList.contains('show')).toEqual(true) - expect(triggerTwoEl.classList.contains('collapsed')).toEqual(false) - expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('true') - done() + collapseEl.addEventListener('shown.bs.collapse', () => { + expect(collapseEl).toHaveClass('show') + expect(target.checked).toBeTrue() + resolve() }) - triggerTwoEl.click() + target.click() }) - - triggerEl.click() }) - it('should allow accordion to target multiple elements', done => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - '
', - '
', - '
', - '
', - '
' - ].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.classList.contains('show')).toEqual(true) - expect(collapseOneTwo.classList.contains('show')).toEqual(true) - - expect(collapseTwoOne.classList.contains('show')).toEqual(false) - expect(collapseTwoTwo.classList.contains('show')).toEqual(false) - - triggerTwo.click() - } - - function secondTest() { - expect(collapseOneOne.classList.contains('show')).toEqual(false) - expect(collapseOneTwo.classList.contains('show')).toEqual(false) + it('should allow accordion to contain nested elements', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + '
', + '
', + ' ', + '
', + '
', + '
', + '
', + '
', + ' ', + '
', + '
', + '
', + '
', + '
' + ].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() + }) - expect(collapseTwoOne.classList.contains('show')).toEqual(true) - expect(collapseTwoTwo.classList.contains('show')).toEqual(true) - done() - } + triggerTwoEl.click() + }) - collapseOneOne.addEventListener('shown.bs.collapse', () => { - if (collapsedElements.one) { - firstTest() - } else { - collapsedElements.one = true - } + triggerEl.click() }) + }) - collapseOneTwo.addEventListener('shown.bs.collapse', () => { - if (collapsedElements.one) { - firstTest() - } else { - collapsedElements.one = true + it('should allow accordion to target multiple elements', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + '
', + '
', + '
', + '
', + '
' + ].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 } - }) - collapseTwoOne.addEventListener('shown.bs.collapse', () => { - if (collapsedElements.two) { - secondTest() - } else { - collapsedElements.two = true - } - }) + function firstTest() { + expect(collapseOneOne).toHaveClass('show') + expect(collapseOneTwo).toHaveClass('show') + + expect(collapseTwoOne).not.toHaveClass('show') + expect(collapseTwoTwo).not.toHaveClass('show') - collapseTwoTwo.addEventListener('shown.bs.collapse', () => { - if (collapsedElements.two) { - secondTest() - } else { - collapsedElements.two = true + triggerTwo.click() } - }) - trigger.click() - }) + function secondTest() { + expect(collapseOneOne).not.toHaveClass('show') + expect(collapseOneTwo).not.toHaveClass('show') - it('should collapse accordion children but not nested accordion children', done => { - fixtureEl.innerHTML = [ - '
', - '
', - ' ', - '
', - '
', - '
', - ' ', - '
', - '
', - '
', - '
', - '
', - '
', - ' ', - '
', - '
', - '
' - ].join('') + expect(collapseTwoOne).toHaveClass('show') + expect(collapseTwoTwo).toHaveClass('show') + resolve() + } - 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.classList.contains('show')).toEqual(true) - expect(collapseTwo.classList.contains('show')).toEqual(false) - expect(nestedCollapseOne.classList.contains('show')).toEqual(false) - - nestedCollapseOne.addEventListener('shown.bs.collapse', handlerNestedCollapseOne) - nestedTrigger.click() - collapseOne.removeEventListener('shown.bs.collapse', handlerCollapseOne) - } + collapseOneOne.addEventListener('shown.bs.collapse', () => { + if (collapsedElements.one) { + firstTest() + } else { + collapsedElements.one = true + } + }) - function handlerNestedCollapseOne() { - expect(collapseOne.classList.contains('show')).toEqual(true) - expect(collapseTwo.classList.contains('show')).toEqual(false) - expect(nestedCollapseOne.classList.contains('show')).toEqual(true) + collapseOneTwo.addEventListener('shown.bs.collapse', () => { + if (collapsedElements.one) { + firstTest() + } else { + collapsedElements.one = true + } + }) - collapseTwo.addEventListener('shown.bs.collapse', () => { - expect(collapseOne.classList.contains('show')).toEqual(false) - expect(collapseTwo.classList.contains('show')).toEqual(true) - expect(nestedCollapseOne.classList.contains('show')).toEqual(true) - done() + collapseTwoOne.addEventListener('shown.bs.collapse', () => { + if (collapsedElements.two) { + secondTest() + } else { + collapsedElements.two = true + } }) - triggerTwo.click() - nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne) - } + collapseTwoTwo.addEventListener('shown.bs.collapse', () => { + if (collapsedElements.two) { + secondTest() + } else { + collapsedElements.two = true + } + }) - collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne) - trigger.click() + trigger.click() + }) }) - it('should add "collapsed" class and set aria-expanded to triggers only when all the targeted collapse are hidden', done => { - fixtureEl.innerHTML = [ - '', - '', - '', - '
', - '
' - ].join('') + it('should collapse accordion children but not nested accordion children', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + ' ', + '
', + '
', + '
', + ' ', + '
', + '
', + '
', + '
', + '
', + '
', + ' ', + '
', + '
', + '
' + ].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) + } - 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') + function handlerNestedCollapseOne() { + expect(collapseOne).toHaveClass('show') + expect(collapseTwo).not.toHaveClass('show') + expect(nestedCollapseOne).toHaveClass('show') - const target2Shown = () => { - expect(trigger1.classList.contains('collapsed')).toEqual(false) - expect(trigger1.getAttribute('aria-expanded')).toEqual('true') + collapseTwo.addEventListener('shown.bs.collapse', () => { + expect(collapseOne).not.toHaveClass('show') + expect(collapseTwo).toHaveClass('show') + expect(nestedCollapseOne).toHaveClass('show') + resolve() + }) - expect(trigger2.classList.contains('collapsed')).toEqual(false) - expect(trigger2.getAttribute('aria-expanded')).toEqual('true') + triggerTwo.click() + nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne) + } - expect(trigger3.classList.contains('collapsed')).toEqual(false) - expect(trigger3.getAttribute('aria-expanded')).toEqual('true') + collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne) + trigger.click() + }) + }) - target2.addEventListener('hidden.bs.collapse', () => { - expect(trigger1.classList.contains('collapsed')).toEqual(false) + 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 = [ + '', + '', + '', + '
', + '
' + ].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(`#${CSS.escape('0/my/id')}`) + + const target2Shown = () => { + expect(trigger1).not.toHaveClass('collapsed') expect(trigger1.getAttribute('aria-expanded')).toEqual('true') - expect(trigger2.classList.contains('collapsed')).toEqual(true) - expect(trigger2.getAttribute('aria-expanded')).toEqual('false') + expect(trigger2).not.toHaveClass('collapsed') + expect(trigger2.getAttribute('aria-expanded')).toEqual('true') - expect(trigger3.classList.contains('collapsed')).toEqual(false) + expect(trigger3).not.toHaveClass('collapsed') expect(trigger3.getAttribute('aria-expanded')).toEqual('true') - target1.addEventListener('hidden.bs.collapse', () => { - expect(trigger1.classList.contains('collapsed')).toEqual(true) - expect(trigger1.getAttribute('aria-expanded')).toEqual('false') + target2.addEventListener('hidden.bs.collapse', () => { + expect(trigger1).not.toHaveClass('collapsed') + expect(trigger1.getAttribute('aria-expanded')).toEqual('true') - expect(trigger2.classList.contains('collapsed')).toEqual(true) + expect(trigger2).toHaveClass('collapsed') expect(trigger2.getAttribute('aria-expanded')).toEqual('false') - expect(trigger3.classList.contains('collapsed')).toEqual(true) - expect(trigger3.getAttribute('aria-expanded')).toEqual('false') - done() - }) + expect(trigger3).not.toHaveClass('collapsed') + expect(trigger3.getAttribute('aria-expanded')).toEqual('true') - trigger1.click() - }) + target1.addEventListener('hidden.bs.collapse', () => { + expect(trigger1).toHaveClass('collapsed') + expect(trigger1.getAttribute('aria-expanded')).toEqual('false') - trigger2.click() - } + 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() + target2.addEventListener('shown.bs.collapse', target2Shown) + trigger3.click() + }) }) }) @@ -771,7 +954,7 @@ describe('Collapse', () => { jQueryMock.fn.collapse.call(jQueryMock) - expect(Collapse.getInstance(div)).toBeDefined() + expect(Collapse.getInstance(div)).not.toBeNull() }) it('should not re create a collapse', () => { @@ -797,11 +980,9 @@ describe('Collapse', () => { jQueryMock.fn.collapse = Collapse.jQueryInterface jQueryMock.elements = [div] - try { + expect(() => { jQueryMock.fn.collapse.call(jQueryMock, action) - } catch (error) { - expect(error.message).toEqual(`No method named "${action}"`) - } + }).toThrowError(TypeError, `No method named "${action}"`) }) }) @@ -813,6 +994,7 @@ describe('Collapse', () => { 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', () => { @@ -820,7 +1002,61 @@ describe('Collapse', () => { const div = fixtureEl.querySelector('div') - expect(Collapse.getInstance(div)).toEqual(null) + expect(Collapse.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return collapse instance', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + 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 = '
' + + 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 = '
' + + 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 index ab3240b9bb82..04e57a8bced4 100644 --- a/js/tests/unit/dom/data.spec.js +++ b/js/tests/unit/dom/data.spec.js @@ -1,131 +1,104 @@ -import Data from '../../../src/dom/data' - -/** Test helpers */ -import { getFixture, clearFixture } from '../../helpers/fixture' +import Data from '../../../src/dom/data.js' +import { clearFixture, getFixture } from '../../helpers/fixture.js' 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 = fixtureEl.querySelector('div') + }) + afterEach(() => { + Data.remove(div, TEST_KEY) clearFixture() }) - describe('setData', () => { - it('should set data in an element by adding a key attribute', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + it('should return null for unknown elements', () => { + const data = { ...TEST_DATA } - Data.setData(div, 'test', data) - expect(div.key).toBeDefined() - }) + Data.set(div, TEST_KEY, data) - it('should change data if something is already stored', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } - - Data.setData(div, 'test', data) - - data.test = 'bsData2' - Data.setData(div, 'test', data) - - expect(div.key).toBeDefined() - }) + expect(Data.get(null)).toBeNull() + expect(Data.get(undefined)).toBeNull() + expect(Data.get(document.createElement('div'), TEST_KEY)).toBeNull() }) - describe('getData', () => { - it('should return stored data', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + it('should return null for unknown keys', () => { + const data = { ...TEST_DATA } - Data.setData(div, 'test', data) - expect(Data.getData(div, 'test')).toEqual(data) - }) + Data.set(div, TEST_KEY, data) - it('should return null on undefined element', () => { - expect(Data.getData(null)).toEqual(null) - expect(Data.getData(undefined)).toEqual(null) - }) - - it('should return null when an element have nothing stored', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - - expect(Data.getData(div, 'test')).toEqual(null) - }) - - it('should return null when an element have nothing stored with the provided key', () => { - fixtureEl.innerHTML = '
' + expect(Data.get(div, null)).toBeNull() + expect(Data.get(div, undefined)).toBeNull() + expect(Data.get(div, UNKNOWN_KEY)).toBeNull() + }) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + it('should store data for an element with a given key and return it', () => { + const data = { ...TEST_DATA } - Data.setData(div, 'test', data) + Data.set(div, TEST_KEY, data) - expect(Data.getData(div, 'test2')).toEqual(null) - }) + expect(Data.get(div, TEST_KEY)).toEqual(data) }) - describe('removeData', () => { - it('should do nothing when an element have nothing stored', () => { - fixtureEl.innerHTML = '
' + it('should overwrite data if something is already stored', () => { + const data = { ...TEST_DATA } + const copy = { ...data } - const div = fixtureEl.querySelector('div') + Data.set(div, TEST_KEY, data) + Data.set(div, TEST_KEY, copy) - Data.removeData(div, 'test') - expect().nothing() - }) + // 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 should do nothing if it\'s not a valid key provided', () => { - fixtureEl.innerHTML = '
' + it('should do nothing when an element has nothing stored', () => { + Data.remove(div, TEST_KEY) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + expect().nothing() + }) - Data.setData(div, 'test', data) + it('should remove nothing for an unknown key', () => { + const data = { ...TEST_DATA } - expect(div.key).toBeDefined() + Data.set(div, TEST_KEY, data) + Data.remove(div, UNKNOWN_KEY) - Data.removeData(div, 'test2') + expect(Data.get(div, TEST_KEY)).toEqual(data) + }) - expect(div.key).toBeDefined() - }) + it('should remove data for a given key', () => { + const data = { ...TEST_DATA } - it('should remove data if something is stored', () => { - fixtureEl.innerHTML = '
' + Data.set(div, TEST_KEY, data) + Data.remove(div, TEST_KEY) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + expect(Data.get(div, TEST_KEY)).toBeNull() + }) - Data.setData(div, 'test', data) + it('should console.error a message if called with multiple keys', () => { + console.error = jasmine.createSpy('console.error') - expect(div.key).toBeDefined() + const data = { ...TEST_DATA } + const copy = { ...data } - Data.removeData(div, 'test') + Data.set(div, TEST_KEY, data) + Data.set(div, UNKNOWN_KEY, copy) - expect(div.key).toBeUndefined() - }) + expect(console.error).toHaveBeenCalled() + expect(Data.get(div, UNKNOWN_KEY)).toBeNull() }) }) diff --git a/js/tests/unit/dom/event-handler.spec.js b/js/tests/unit/dom/event-handler.spec.js index 5551ddaa30f7..7f99c4122373 100644 --- a/js/tests/unit/dom/event-handler.spec.js +++ b/js/tests/unit/dom/event-handler.spec.js @@ -1,7 +1,6 @@ -import EventHandler from '../../../src/dom/event-handler' - -/** Test helpers */ -import { getFixture, clearFixture } from '../../helpers/fixture' +import EventHandler from '../../../src/dom/event-handler.js' +import { noop } from '../../../src/util/index.js' +import { clearFixture, getFixture } from '../../helpers/fixture.js' describe('EventHandler', () => { let fixtureEl @@ -20,86 +19,190 @@ describe('EventHandler', () => { const div = fixtureEl.querySelector('div') - EventHandler.on(div, null, () => {}) - EventHandler.on(null, 'click', () => {}) + EventHandler.on(div, null, noop) + EventHandler.on(null, 'click', noop) expect().nothing() }) - it('should add event listener', done => { - fixtureEl.innerHTML = '
' + it('should add event listener', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('div') - EventHandler.on(div, 'click', () => { - expect().nothing() - done() - }) + EventHandler.on(div, 'click', () => { + expect().nothing() + resolve() + }) - div.click() + div.click() + }) }) - it('should add namespaced event listener', done => { - fixtureEl.innerHTML = '
' + it('should add namespaced event listener', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('div') - EventHandler.on(div, 'bs.namespace', () => { - expect().nothing() - done() - }) + EventHandler.on(div, 'bs.namespace', () => { + expect().nothing() + resolve() + }) - EventHandler.trigger(div, 'bs.namespace') + EventHandler.trigger(div, 'bs.namespace') + }) }) - it('should add native namespaced event listener', done => { - fixtureEl.innerHTML = '
' + it('should add native namespaced event listener', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('div') - EventHandler.on(div, 'click.namespace', () => { - expect().nothing() - done() - }) + EventHandler.on(div, 'click.namespace', () => { + expect().nothing() + resolve() + }) - EventHandler.trigger(div, 'click') + EventHandler.trigger(div, 'click') + }) }) - it('should handle event delegation', done => { - EventHandler.on(document, 'click', '.test', () => { - expect().nothing() - done() - }) + it('should handle event delegation', () => { + return new Promise(resolve => { + EventHandler.on(document, 'click', '.test', () => { + expect().nothing() + resolve() + }) - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('div') + + div.click() + }) + }) - div.click() + it('should handle mouseenter/mouseleave like the native counterpart', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + '
', + '
', + '
', + '
', + '
', + '
' + ].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 one', done => { - fixtureEl.innerHTML = '
' + it('should call listener just once', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + + let called = 0 + const div = fixtureEl.querySelector('div') + const obj = { + oneListener() { + called++ + } + } - 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 = '
' + + let called = 0 + const div = fixtureEl.querySelector('div') + const obj = { + oneListener() { + called++ + } } - } - EventHandler.one(div, 'bootstrap', obj.oneListener) + EventHandler.one(fixtureEl, 'bootstrap', 'div', obj.oneListener) - EventHandler.trigger(div, 'bootstrap') - EventHandler.trigger(div, 'bootstrap') + EventHandler.trigger(div, 'bootstrap') + EventHandler.trigger(div, 'bootstrap') - setTimeout(() => { - expect(called).toEqual(1) - done() - }, 20) + setTimeout(() => { + expect(called).toEqual(1) + resolve() + }, 20) + }) }) }) @@ -108,180 +211,194 @@ describe('EventHandler', () => { fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') - EventHandler.off(div, null, () => {}) - EventHandler.off(null, 'click', () => {}) + EventHandler.off(div, null, noop) + EventHandler.off(null, 'click', noop) expect().nothing() }) - it('should remove a listener', done => { - fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + it('should remove a listener', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + const div = fixtureEl.querySelector('div') - let called = 0 - const handler = () => { - called++ - } + let called = 0 + const handler = () => { + called++ + } - EventHandler.on(div, 'foobar', handler) - EventHandler.trigger(div, 'foobar') + EventHandler.on(div, 'foobar', handler) + EventHandler.trigger(div, 'foobar') - EventHandler.off(div, 'foobar', handler) - EventHandler.trigger(div, 'foobar') + EventHandler.off(div, 'foobar', handler) + EventHandler.trigger(div, 'foobar') - setTimeout(() => { - expect(called).toEqual(1) - done() - }, 20) + setTimeout(() => { + expect(called).toEqual(1) + resolve() + }, 20) + }) }) - it('should remove all the events', done => { - fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + it('should remove all the events', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + const div = fixtureEl.querySelector('div') - let called = 0 + let called = 0 - EventHandler.on(div, 'foobar', () => { - called++ - }) - EventHandler.on(div, 'foobar', () => { - called++ - }) - EventHandler.trigger(div, 'foobar') + EventHandler.on(div, 'foobar', () => { + called++ + }) + EventHandler.on(div, 'foobar', () => { + called++ + }) + EventHandler.trigger(div, 'foobar') - EventHandler.off(div, 'foobar') - EventHandler.trigger(div, 'foobar') + EventHandler.off(div, 'foobar') + EventHandler.trigger(div, 'foobar') - setTimeout(() => { - expect(called).toEqual(2) - done() - }, 20) + setTimeout(() => { + expect(called).toEqual(2) + resolve() + }, 20) + }) }) - it('should remove all the namespaced listeners if namespace is passed', done => { - fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + it('should remove all the namespaced listeners if namespace is passed', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + const div = fixtureEl.querySelector('div') - let called = 0 + let called = 0 - EventHandler.on(div, 'foobar.namespace', () => { - called++ - }) - EventHandler.on(div, 'foofoo.namespace', () => { - called++ + 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) }) - 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) - done() - }, 20) }) - it('should remove the namespaced listeners', done => { - fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + it('should remove the namespaced listeners', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + const div = fixtureEl.querySelector('div') - let calledCallback1 = 0 - let calledCallback2 = 0 + let calledCallback1 = 0 + let calledCallback2 = 0 - EventHandler.on(div, 'foobar.namespace', () => { - calledCallback1++ - }) - EventHandler.on(div, 'foofoo.namespace', () => { - calledCallback2++ - }) + 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, 'foobar.namespace') + EventHandler.off(div, 'foobar.namespace') + EventHandler.trigger(div, 'foobar.namespace') - EventHandler.trigger(div, 'foofoo.namespace') + EventHandler.trigger(div, 'foofoo.namespace') - setTimeout(() => { - expect(calledCallback1).toEqual(1) - expect(calledCallback2).toEqual(1) - done() - }, 20) + setTimeout(() => { + expect(calledCallback1).toEqual(1) + expect(calledCallback2).toEqual(1) + resolve() + }, 20) + }) }) - it('should remove the all the namespaced listeners for native events', done => { - fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') + it('should remove the all the namespaced listeners for native events', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + const div = fixtureEl.querySelector('div') - let called = 0 + let called = 0 - EventHandler.on(div, 'click.namespace', () => { - called++ - }) - EventHandler.on(div, 'click.namespace2', () => { - called++ - }) + EventHandler.on(div, 'click.namespace', () => { + called++ + }) + EventHandler.on(div, 'click.namespace2', () => { + called++ + }) - EventHandler.trigger(div, 'click') - EventHandler.off(div, 'click') - EventHandler.trigger(div, 'click') + EventHandler.trigger(div, 'click') + EventHandler.off(div, 'click') + EventHandler.trigger(div, 'click') - setTimeout(() => { - expect(called).toEqual(2) - done() - }, 20) + setTimeout(() => { + expect(called).toEqual(2) + resolve() + }, 20) + }) }) - it('should remove the specified namespaced listeners for native events', done => { - fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') - - let called1 = 0 - let called2 = 0 - - EventHandler.on(div, 'click.namespace', () => { - called1++ - }) - EventHandler.on(div, 'click.namespace2', () => { - called2++ + it('should remove the specified namespaced listeners for native events', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '
' + 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) }) - EventHandler.trigger(div, 'click') - - EventHandler.off(div, 'click.namespace') - EventHandler.trigger(div, 'click') - - setTimeout(() => { - expect(called1).toEqual(1) - expect(called2).toEqual(2) - done() - }, 20) }) - it('should remove a listener registered by .one', done => { - fixtureEl.innerHTML = '
' + it('should remove a listener registered by .one', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = '
' - const div = fixtureEl.querySelector('div') - const handler = () => { - throw new Error('called') - } + const div = fixtureEl.querySelector('div') + const handler = () => { + reject(new Error('called')) + } - EventHandler.one(div, 'foobar', handler) - EventHandler.off(div, 'foobar', handler) + EventHandler.one(div, 'foobar', handler) + EventHandler.off(div, 'foobar', handler) - EventHandler.trigger(div, 'foobar') - setTimeout(() => { - expect().nothing() - done() - }, 20) + 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.appendChild(subelement) + element.append(subelement) const anchor = document.createElement('a') - element.appendChild(anchor) + element.append(anchor) let i = 0 const handler = () => { @@ -291,37 +408,73 @@ describe('EventHandler', () => { EventHandler.on(element, 'click', 'a', handler) EventHandler.on(element, 'click', 'span', handler) - fixtureEl.appendChild(element) + fixtureEl.append(element) EventHandler.trigger(anchor, 'click') EventHandler.trigger(subelement, 'click') // first listeners called - expect(i === 2).toEqual(true) + expect(i).toEqual(2) EventHandler.off(element, 'click', 'span', handler) EventHandler.trigger(subelement, 'click') // removed listener not called - expect(i === 2).toEqual(true) + expect(i).toEqual(2) EventHandler.trigger(anchor, 'click') // not removed listener called - expect(i === 3).toEqual(true) + expect(i).toEqual(3) EventHandler.on(element, 'click', 'span', handler) EventHandler.trigger(anchor, 'click') EventHandler.trigger(subelement, 'click') // listener re-registered - expect(i === 5).toEqual(true) + expect(i).toEqual(5) EventHandler.off(element, 'click', 'span') EventHandler.trigger(subelement, 'click') // listener removed again - expect(i === 5).toEqual(true) + expect(i).toEqual(5) + }) + }) + + describe('general functionality', () => { + it('should hydrate properties, and make them configurable', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '
', + '
', + '
' + ].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 index eec3ced52b1e..9d0be3218f86 100644 --- a/js/tests/unit/dom/manipulator.spec.js +++ b/js/tests/unit/dom/manipulator.spec.js @@ -1,7 +1,5 @@ -import Manipulator from '../../../src/dom/manipulator' - -/** Test helpers */ -import { getFixture, clearFixture } from '../../helpers/fixture' +import Manipulator from '../../../src/dom/manipulator.js' +import { clearFixture, getFixture } from '../../helpers/fixture.js' describe('Manipulator', () => { let fixtureEl @@ -15,13 +13,13 @@ describe('Manipulator', () => { }) describe('setDataAttribute', () => { - it('should set data attribute', () => { + it('should set data attribute prefixed with bs', () => { fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') Manipulator.setDataAttribute(div, 'key', 'value') - expect(div.getAttribute('data-key')).toEqual('value') + expect(div.getAttribute('data-bs-key')).toEqual('value') }) it('should set data attribute in kebab case', () => { @@ -30,59 +28,74 @@ describe('Manipulator', () => { const div = fixtureEl.querySelector('div') Manipulator.setDataAttribute(div, 'testKey', 'value') - expect(div.getAttribute('data-test-key')).toEqual('value') + expect(div.getAttribute('data-bs-test-key')).toEqual('value') }) }) describe('removeDataAttribute', () => { - it('should remove data attribute', () => { - fixtureEl.innerHTML = '
' + it('should only remove bs-prefixed data attribute', () => { + fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') Manipulator.removeDataAttribute(div, 'key') - expect(div.getAttribute('data-key')).toBeNull() + 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 = '
' + fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') Manipulator.removeDataAttribute(div, 'testKey') - expect(div.getAttribute('data-test-key')).toBeNull() + expect(div.getAttribute('data-bs-test-key')).toBeNull() }) }) describe('getDataAttributes', () => { - it('should return empty object for null', () => { - expect(Manipulator.getDataAttributes(null), {}) + it('should return an empty object for null', () => { + expect(Manipulator.getDataAttributes(null)).toEqual({}) expect().nothing() }) - it('should get all data attributes', () => { - fixtureEl.innerHTML = '
' + it('should get only bs-prefixed data attributes without bs namespace', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + expect(Manipulator.getDataAttributes(div)).toEqual({ + toggle: 'tabs', + target: '#element' + }) + }) + + it('should omit `bs-config` data attribute', () => { + fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') expect(Manipulator.getDataAttributes(div)).toEqual({ - test: 'js', - test2: 'js2' + toggle: 'tabs', + target: '#element' }) }) }) describe('getDataAttribute', () => { - it('should get data attribute', () => { - fixtureEl.innerHTML = '
' + it('should only get bs-prefixed data attribute', () => { + fixtureEl.innerHTML = '
' 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 = '
' + fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') @@ -90,69 +103,33 @@ describe('Manipulator', () => { }) it('should normalize data', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') - expect(Manipulator.getDataAttribute(div, 'test')).toEqual(false) + expect(Manipulator.getDataAttribute(div, 'test')).toBeFalse() - div.setAttribute('data-test', 'true') - expect(Manipulator.getDataAttribute(div, 'test')).toEqual(true) + div.setAttribute('data-bs-test', 'true') + expect(Manipulator.getDataAttribute(div, 'test')).toBeTrue() - div.setAttribute('data-test', '1') + div.setAttribute('data-bs-test', '1') expect(Manipulator.getDataAttribute(div, 'test')).toEqual(1) }) - }) - - describe('offset', () => { - it('should return object with two properties top and left, both numbers', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const offset = Manipulator.offset(div) - - expect(offset).toBeDefined() - expect(offset.top).toEqual(jasmine.any(Number)) - expect(offset.left).toEqual(jasmine.any(Number)) - }) - }) - - describe('position', () => { - it('should return object with two properties top and left, both numbers', () => { - fixtureEl.innerHTML = '
' - - const div = fixtureEl.querySelector('div') - const position = Manipulator.position(div) - - expect(position).toBeDefined() - expect(position.top).toEqual(jasmine.any(Number)) - expect(position.left).toEqual(jasmine.any(Number)) - }) - }) - - describe('toggleClass', () => { - it('should not error out if element is null or undefined', () => { - Manipulator.toggleClass(null, 'test') - Manipulator.toggleClass(undefined, 'test') - expect().nothing() - }) - it('should add class if it is missing', () => { - fixtureEl.innerHTML = '
' + it('should normalize json data', () => { + fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') - Manipulator.toggleClass(div, 'test') - expect(div.classList.contains('test')).toEqual(true) - }) - - it('should remove class if it is set', () => { - fixtureEl.innerHTML = '
' + expect(Manipulator.getDataAttribute(div, 'test')).toEqual({ delay: { show: 100, hide: 10 } }) - const div = fixtureEl.querySelector('div') + 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) - Manipulator.toggleClass(div, 'test') - expect(div.classList.contains('test')).toEqual(false) + 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 index e13438e6fdf3..95d9bf8ec9d8 100644 --- a/js/tests/unit/dom/selector-engine.spec.js +++ b/js/tests/unit/dom/selector-engine.spec.js @@ -1,8 +1,5 @@ -import SelectorEngine from '../../../src/dom/selector-engine' -import { makeArray } from '../../../src/util/index' - -/** Test helpers */ -import { getFixture, clearFixture } from '../../helpers/fixture' +import SelectorEngine from '../../../src/dom/selector-engine.js' +import { clearFixture, getFixture } from '../../helpers/fixture.js' describe('SelectorEngine', () => { let fixtureEl @@ -15,44 +12,38 @@ describe('SelectorEngine', () => { clearFixture() }) - describe('matches', () => { - it('should return matched elements', () => { - fixtureEl.innerHTML = '
' - - expect(SelectorEngine.matches(fixtureEl, 'div')).toEqual(true) - }) - }) - describe('find', () => { it('should find elements', () => { fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('div') - expect(makeArray(SelectorEngine.find('div', fixtureEl))).toEqual([div]) + expect(SelectorEngine.find('div', fixtureEl)).toEqual([div]) }) - it('should find elements globaly', () => { + it('should find elements globally', () => { fixtureEl.innerHTML = '
' const div = fixtureEl.querySelector('#test') - expect(makeArray(SelectorEngine.find('#test'))).toEqual([div]) + expect(SelectorEngine.find('#test')).toEqual([div]) }) it('should handle :scope selectors', () => { - fixtureEl.innerHTML = `` + fixtureEl.innerHTML = [ + '
    ', + '
  • ', + '
  • ', + ' link', + '
  • ', + '
  • ', + '
' + ].join('') const listEl = fixtureEl.querySelector('ul') const aActive = fixtureEl.querySelector('.active') - expect(makeArray(SelectorEngine.find(':scope > li > .active', listEl))).toEqual([aActive]) + expect(SelectorEngine.find(':scope > li > .active', listEl)).toEqual([aActive]) }) }) @@ -68,15 +59,17 @@ describe('SelectorEngine', () => { describe('children', () => { it('should find children', () => { - fixtureEl.innerHTML = `
    -
  • -
  • -
  • -
` + fixtureEl.innerHTML = [ + '
    ', + '
  • ', + '
  • ', + '
  • ', + '
' + ].join('') const list = fixtureEl.querySelector('ul') - const liList = makeArray(fixtureEl.querySelectorAll('li')) - const result = makeArray(SelectorEngine.children(list, 'li')) + const liList = [].concat(...fixtureEl.querySelectorAll('li')) + const result = SelectorEngine.children(list, 'li') expect(result).toEqual(liList) }) @@ -84,7 +77,7 @@ describe('SelectorEngine', () => { describe('parents', () => { it('should return parents', () => { - expect(SelectorEngine.parents(fixtureEl, 'body').length).toEqual(1) + expect(SelectorEngine.parents(fixtureEl, 'body')).toHaveSize(1) }) }) @@ -110,6 +103,312 @@ describe('SelectorEngine', () => { expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest]) }) + + it('should return previous element with comments or text nodes between', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '', + 'Text', + '' + ].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 = '
' + + 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 = [ + '
', + '', + '' + ].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 = [ + '
', + '', + 'Text', + '', + '' + ].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 = [ + '
lorem
', + 'lorem', + 'lorem', + '', + '', + '', + '', + '
lorem
' + ].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 = [ + '
lorem
', + '
lorem
', + '
lorem
' + ].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 = '' + + const expectedElements = [] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should return contenteditable elements', () => { + fixtureEl.innerHTML = '
lorem
' + + const expectedElements = [fixtureEl.querySelector('[contenteditable="true"]')] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should not return disabled elements', () => { + fixtureEl.innerHTML = '' + + const expectedElements = [] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should not return invisible elements', () => { + fixtureEl.innerHTML = '' + + const expectedElements = [] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + }) + + describe('getSelectorFromElement', () => { + it('should get selector from data-bs-target', () => { + fixtureEl.innerHTML = [ + '
', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toEqual('.target') + }) + + it('should get selector from href if no data-bs-target set', () => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toEqual('.target') + }) + + it('should get selector from href if data-bs-target equal to #', () => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toEqual('.target') + }) + + it('should return null if a selector from a href is a url without an anchor', () => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toBeNull() + }) + + it('should return the anchor if a selector from a href is a url', () => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toEqual('#target') + }) + + it('should return null if selector not found', () => { + fixtureEl.innerHTML = '' + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toBeNull() + }) + + it('should return null if no selector', () => { + fixtureEl.innerHTML = '
' + + const testEl = fixtureEl.querySelector('div') + + expect(SelectorEngine.getSelectorFromElement(testEl)).toBeNull() + }) + }) + + describe('getElementFromSelector', () => { + it('should get element from data-bs-target', () => { + fixtureEl.innerHTML = [ + '
', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target')) + }) + + it('should get element from href if no data-bs-target set', () => { + fixtureEl.innerHTML = [ + '', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target')) + }) + + it('should return null if element not found', () => { + fixtureEl.innerHTML = '' + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getElementFromSelector(testEl)).toBeNull() + }) + + it('should return null if no selector', () => { + fixtureEl.innerHTML = '
' + + const testEl = fixtureEl.querySelector('div') + + expect(SelectorEngine.getElementFromSelector(testEl)).toBeNull() + }) + }) + + describe('getMultipleElementsFromSelector', () => { + it('should get elements from data-bs-target', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getMultipleElementsFromSelector(testEl)).toEqual(Array.from(fixtureEl.querySelectorAll('.target'))) + }) + + it('should get elements if several ids are given', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getMultipleElementsFromSelector(testEl)).toEqual(Array.from(fixtureEl.querySelectorAll('.target'))) + }) + + it('should get elements if several ids with special chars are given', () => { + fixtureEl.innerHTML = [ + '
', + '
', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getMultipleElementsFromSelector(testEl)).toEqual(Array.from(fixtureEl.querySelectorAll('.target'))) + }) + + it('should get elements in array, from href if no data-bs-target set', () => { + fixtureEl.innerHTML = [ + '', + '
', + '
' + ].join('') + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getMultipleElementsFromSelector(testEl)).toEqual(Array.from(fixtureEl.querySelectorAll('.target'))) + }) + + it('should return empty array if elements not found', () => { + fixtureEl.innerHTML = '' + + const testEl = fixtureEl.querySelector('#test') + + expect(SelectorEngine.getMultipleElementsFromSelector(testEl)).toHaveSize(0) + }) + + it('should return empty array if no selector', () => { + fixtureEl.innerHTML = '
' + + const testEl = fixtureEl.querySelector('div') + + expect(SelectorEngine.getMultipleElementsFromSelector(testEl)).toHaveSize(0) + }) + }) +}) diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js index 7491e38c0d75..63ae4bd102bc 100644 --- a/js/tests/unit/dropdown.spec.js +++ b/js/tests/unit/dropdown.spec.js @@ -1,10 +1,9 @@ -import Popper from 'popper.js' - -import Dropdown from '../../src/dropdown' -import EventHandler from '../../src/dom/event-handler' - -/** Test helpers */ -import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' +import EventHandler from '../../src/dom/event-handler.js' +import Dropdown from '../../src/dropdown.js' +import { noop } from '../../src/util/index.js' +import { + clearFixture, createEvent, getFixture, jQueryMock +} from '../helpers/fixture.js' describe('Dropdown', () => { let fixtureEl @@ -35,82 +34,115 @@ describe('Dropdown', () => { }) }) + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Dropdown.DATA_KEY).toEqual('bs.dropdown') + }) + }) + describe('constructor', () => { - it('should create offset modifier correctly when offset option is a function', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { fixtureEl.innerHTML = [ '' ].join('') - const getOffset = offsets => offsets - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = new Dropdown(btnDropdown, { - offset: getOffset - }) - - const offset = dropdown._getOffset() + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownBySelector = new Dropdown('[data-bs-toggle="dropdown"]') + const dropdownByElement = new Dropdown(btnDropdown) - expect(offset.offset).toBeUndefined() - expect(typeof offset.fn).toEqual('function') + expect(dropdownBySelector._element).toEqual(btnDropdown) + expect(dropdownByElement._element).toEqual(btnDropdown) }) - it('should create offset modifier correctly when offset option is not a function', () => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should work on invalid markup', () => { + return new Promise(resolve => { + // TODO: REMOVE in v6 + fixtureEl.innerHTML = [ + '' + ].join('') - const myOffset = 7 - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = new Dropdown(btnDropdown, { - offset: myOffset - }) + const dropdownElem = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(dropdownElem) - const offset = dropdown._getOffset() + dropdownElem.addEventListener('shown.bs.dropdown', () => { + resolve() + }) + + expect().nothing() + dropdown.show() + }) + }) - expect(offset.offset).toEqual(myOffset) - expect(offset.fn).toBeUndefined() + it('should create offset modifier correctly when offset option is a function', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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 add a listener on trigger which do not have data-toggle="dropdown"', () => { + it('should create offset modifier correctly when offset option is a string into data attribute', () => { fixtureEl.innerHTML = [ '' ].join('') - const btnDropdown = fixtureEl.querySelector('.btn') + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') const dropdown = new Dropdown(btnDropdown) - spyOn(dropdown, 'toggle') - - btnDropdown.click() - - expect(dropdown.toggle).toHaveBeenCalled() + expect(dropdown._getOffset()).toEqual([10, 20]) }) - it('should allow to pass config to popper.js with `popperConfig`', () => { + it('should allow to pass config to Popper with `popperConfig`', () => { fixtureEl.innerHTML = [ '' ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') const dropdown = new Dropdown(btnDropdown, { popperConfig: { placement: 'left' @@ -121,1417 +153,2068 @@ describe('Dropdown', () => { expect(popperConfig.placement).toEqual('left') }) - }) - describe('toggle', () => { - it('should toggle a dropdown', done => { + it('should allow to pass config to Popper with `popperConfig` as a function', () => { fixtureEl.innerHTML = [ '' ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const getPopperConfig = jasmine.createSpy('getPopperConfig').and.returnValue({ placement: 'left' }) + const dropdown = new Dropdown(btnDropdown, { + popperConfig: getPopperConfig }) - dropdown.toggle() - }) + const popperConfig = dropdown._getPopperConfig() - it('should destroy old popper references on toggle', done => { - fixtureEl.innerHTML = [ - '', - '' - ].join('') + // Ensure that the function was called with the default config. + expect(getPopperConfig).toHaveBeenCalledWith(jasmine.objectContaining({ + placement: jasmine.any(String) + })) + expect(popperConfig.placement).toEqual('left') + }) + }) - 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) - const dropdown2 = new Dropdown(btnDropdown2) + describe('toggle', () => { + it('should toggle a dropdown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() + }) - firstDropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(firstDropdownEl.classList.contains('show')).toEqual(true) - spyOn(dropdown1._popper, 'destroy') - dropdown2.toggle() + dropdown.toggle() }) + }) - secondDropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdown1._popper.destroy).toHaveBeenCalled() - done() + it('should destroy old popper references on toggle', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].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() }) - - dropdown1.toggle() }) - it('should toggle a dropdown and add/remove event listener on mobile', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const defaultValueOnTouchStart = document.documentElement.ontouchstart - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) - - document.documentElement.ontouchstart = () => {} - spyOn(EventHandler, 'on') - spyOn(EventHandler, 'off') - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - expect(EventHandler.on).toHaveBeenCalled() + it('should toggle a dropdown and add/remove event listener on mobile', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() }) + }) - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(false) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') - expect(EventHandler.off).toHaveBeenCalled() + it('should toggle a dropdown at the right', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() + }) - document.documentElement.ontouchstart = defaultValueOnTouchStart - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should toggle a dropdown at the right', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + it('should toggle a centered dropdown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() + }) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should toggle a dropup', done => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - '
' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropupEl = fixtureEl.querySelector('.dropup') - const dropdown = new Dropdown(btnDropdown) + it('should toggle a dropup', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + '
' + ].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() + }) - dropupEl.addEventListener('shown.bs.dropdown', () => { - expect(dropupEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should toggle a dropup at the right', done => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - '
' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropupEl = fixtureEl.querySelector('.dropup') - const dropdown = new Dropdown(btnDropdown) + it('should toggle a dropup centered', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + '
' + ].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() + }) - dropupEl.addEventListener('shown.bs.dropdown', () => { - expect(dropupEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should toggle a dropright', done => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - '
' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const droprightEl = fixtureEl.querySelector('.dropright') - const dropdown = new Dropdown(btnDropdown) + it('should toggle a dropup at the right', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + '
' + ].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() + }) - droprightEl.addEventListener('shown.bs.dropdown', () => { - expect(droprightEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should toggle a dropleft', done => { - fixtureEl.innerHTML = [ - '
', - ' ', - ' ', - '
' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropleftEl = fixtureEl.querySelector('.dropleft') - const dropdown = new Dropdown(btnDropdown) + it('should toggle a dropend', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + '
' + ].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() + }) - dropleftEl.addEventListener('shown.bs.dropdown', () => { - expect(dropleftEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should toggle a dropdown with parent reference', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should toggle a dropstart', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + ' ', + '
' + ].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() + }) - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown, { - reference: 'parent' + dropdown.toggle() }) + }) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() - }) + it('should toggle a dropdown with parent reference', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() + dropdown.toggle() + }) }) - it('should toggle a dropdown with a dom node reference', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should toggle a dropdown with a dom node reference', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() + }) - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown, { - reference: fixtureEl + dropdown.toggle() }) + }) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() - }) + it('should toggle a dropdown with a jquery object reference', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() + dropdown.toggle() + }) }) - it('should toggle a dropdown with a jquery object reference', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should toggle a dropdown with a valid virtual element reference', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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 + } + } + } - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown, { - reference: { 0: fixtureEl, jquery: 'jQuery' } - }) + 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() - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - done() + dropdown.toggle() }) - - dropdown.toggle() }) - it('should not toggle a dropdown if the element is disabled', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not toggle a dropdown if the element is disabled', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.toggle() + dropdown.toggle() - setTimeout(() => { - expect().nothing() - done() + setTimeout(() => { + expect().nothing() + resolve() + }) }) }) - it('should not toggle a dropdown if the element contains .disabled', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not toggle a dropdown if the element contains .disabled', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.toggle() + dropdown.toggle() - setTimeout(() => { - expect().nothing() - done() + setTimeout(() => { + expect().nothing() + resolve() + }) }) }) - it('should not toggle a dropdown if the menu is shown', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not toggle a dropdown if the menu is shown', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.toggle() + dropdown.toggle() - setTimeout(() => { - expect().nothing() - done() + setTimeout(() => { + expect().nothing() + resolve() + }) }) }) - it('should not toggle a dropdown if show event is prevented', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not toggle a dropdown if show event is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('show.bs.dropdown', e => { - e.preventDefault() - }) + btnDropdown.addEventListener('show.bs.dropdown', event => { + event.preventDefault() + }) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.toggle() + dropdown.toggle() - setTimeout(() => { - expect().nothing() - done() + setTimeout(() => { + expect().nothing() + resolve() + }) }) }) }) describe('show', () => { - it('should show a dropdown', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - done() + it('should show a dropdown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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() }) - - dropdown.show() }) - it('should not show a dropdown if the element is disabled', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not show a dropdown if the element is disabled', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.show() + dropdown.show() - setTimeout(() => { - expect().nothing() - done() - }, 10) + setTimeout(() => { + expect().nothing() + resolve() + }, 10) + }) }) - it('should not show a dropdown if the element contains .disabled', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not show a dropdown if the element contains .disabled', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.show() + dropdown.show() - setTimeout(() => { - expect().nothing() - done() - }, 10) + setTimeout(() => { + expect().nothing() + resolve() + }, 10) + }) }) - it('should not show a dropdown if the menu is shown', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not show a dropdown if the menu is shown', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.show() + dropdown.show() - setTimeout(() => { - expect().nothing() - done() - }, 10) + setTimeout(() => { + expect().nothing() + resolve() + }, 10) + }) }) - it('should not show a dropdown if show event is prevented', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not show a dropdown if show event is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('show.bs.dropdown', e => { - e.preventDefault() - }) + btnDropdown.addEventListener('show.bs.dropdown', event => { + event.preventDefault() + }) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - throw new Error('should not throw shown.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + reject(new Error('should not throw shown.bs.dropdown event')) + }) - dropdown.show() + dropdown.show() - setTimeout(() => { - expect().nothing() - done() - }, 10) + setTimeout(() => { + expect().nothing() + resolve() + }, 10) + }) }) }) describe('hide', () => { - it('should hide a dropdown', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') - const dropdown = new Dropdown(btnDropdown) - - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - expect(dropdownMenu.classList.contains('show')).toEqual(false) - done() - }) + it('should hide a dropdown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].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', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - spyOn(dropdown._popper, 'destroy') dropdown.hide() }) - - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - expect(dropdown._popper.destroy).toHaveBeenCalled() - done() - }) - - dropdown.show() }) - it('should not hide a dropdown if the element is disabled', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should hide a dropdown and destroy popper', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') - const dropdown = new Dropdown(btnDropdown) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - throw new Error('should not throw hidden.bs.dropdown event') - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + spyOn(dropdown._popper, 'destroy') + dropdown.hide() + }) - dropdown.hide() + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(dropdown._popper.destroy).toHaveBeenCalled() + resolve() + }) - setTimeout(() => { - expect(dropdownMenu.classList.contains('show')).toEqual(true) - done() - }, 10) + dropdown.show() + }) }) - it('should not hide a dropdown if the element contains .disabled', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not hide a dropdown if the element is disabled', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].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')) + }) - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') - const dropdown = new Dropdown(btnDropdown) + dropdown.hide() - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - throw new Error('should not throw hidden.bs.dropdown event') + setTimeout(() => { + expect(dropdownMenu).toHaveClass('show') + resolve() + }, 10) }) - - dropdown.hide() - - setTimeout(() => { - expect(dropdownMenu.classList.contains('show')).toEqual(true) - done() - }, 10) }) - it('should not hide a dropdown if the menu is not shown', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should not hide a dropdown if the element contains .disabled', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].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')) + }) - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdown = new Dropdown(btnDropdown) + dropdown.hide() - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - throw new Error('should not throw hidden.bs.dropdown event') + setTimeout(() => { + expect(dropdownMenu).toHaveClass('show') + resolve() + }, 10) }) - - dropdown.hide() - - setTimeout(() => { - expect().nothing() - done() - }, 10) }) - it('should not hide a dropdown if hide event is prevented', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') - const dropdown = new Dropdown(btnDropdown) + it('should not hide a dropdown if the menu is not shown', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].join('') - dropdownEl.addEventListener('hide.bs.dropdown', e => { - e.preventDefault() - }) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - throw new Error('should not throw hidden.bs.dropdown event') - }) + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + reject(new Error('should not throw hidden.bs.dropdown event')) + }) - dropdown.hide() + dropdown.hide() - setTimeout(() => { - expect(dropdownMenu.classList.contains('show')).toEqual(true) - done() + setTimeout(() => { + expect().nothing() + resolve() + }, 10) }) }) - }) - describe('dispose', () => { - it('should dispose dropdown', () => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = new Dropdown(btnDropdown) + it('should not hide a dropdown if hide event is prevented', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '' + ].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')) + }) - expect(dropdown._popper).toBeNull() - expect(dropdown._menu).toBeDefined() - expect(dropdown._element).toBeDefined() - - dropdown.dispose() + dropdown.hide() - expect(dropdown._menu).toBeNull() - expect(dropdown._element).toBeNull() + setTimeout(() => { + expect(dropdownMenu).toHaveClass('show') + resolve() + }) + }) }) - it('should dispose dropdown with popper.js', () => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should remove event listener on touch-enabled device that was added in show method', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = new Dropdown(btnDropdown) + const defaultValueOnTouchStart = document.documentElement.ontouchstart + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdown.toggle() + document.documentElement.ontouchstart = noop + const spy = spyOn(EventHandler, 'off') - expect(dropdown._popper).toBeDefined() - expect(dropdown._menu).toBeDefined() - expect(dropdown._element).toBeDefined() + btnDropdown.addEventListener('shown.bs.dropdown', () => { + dropdown.hide() + }) - spyOn(Popper.prototype, 'destroy') + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(btnDropdown).not.toHaveClass('show') + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') + expect(spy).toHaveBeenCalled() - dropdown.dispose() + document.documentElement.ontouchstart = defaultValueOnTouchStart + resolve() + }) - expect(dropdown._popper).toBeNull() - expect(dropdown._menu).toBeNull() - expect(dropdown._element).toBeNull() - expect(Popper.prototype.destroy).toHaveBeenCalled() + dropdown.show() + }) }) }) - describe('update', () => { - it('should call popper.js and detect navbar on update', () => { + describe('dispose', () => { + it('should dispose dropdown', () => { fixtureEl.innerHTML = [ '' ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = new Dropdown(btnDropdown) - - dropdown.toggle() - - expect(dropdown._popper).toBeDefined() - - spyOn(dropdown._popper, 'scheduleUpdate') - spyOn(dropdown, '_detectNavbar') - - dropdown.update() - - expect(dropdown._popper.scheduleUpdate).toHaveBeenCalled() - expect(dropdown._detectNavbar).toHaveBeenCalled() - }) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - it('should just detect navbar on update', () => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') const dropdown = new Dropdown(btnDropdown) - spyOn(dropdown, '_detectNavbar') - - dropdown.update() - expect(dropdown._popper).toBeNull() - expect(dropdown._detectNavbar).toHaveBeenCalled() - }) - }) + expect(dropdown._menu).not.toBeNull() + expect(dropdown._element).not.toBeNull() + const spy = spyOn(EventHandler, 'off') - describe('data-api', () => { - it('should not add class position-static to dropdown if boundary not set', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('position-static')).toEqual(false) - done() - }) - - btnDropdown.click() - }) - - it('should add class position-static to dropdown if boundary not scrollParent', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('position-static')).toEqual(true) - done() - }) - - btnDropdown.click() - }) - - it('should show and hide a dropdown', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - let showEventTriggered = false - let hideEventTriggered = false - - dropdownEl.addEventListener('show.bs.dropdown', () => { - showEventTriggered = true - }) - - dropdownEl.addEventListener('shown.bs.dropdown', e => { - expect(dropdownEl.classList.contains('show')).toEqual(true) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - expect(showEventTriggered).toEqual(true) - expect(e.relatedTarget).toEqual(btnDropdown) - document.body.click() - }) - - dropdownEl.addEventListener('hide.bs.dropdown', () => { - hideEventTriggered = true - }) - - dropdownEl.addEventListener('hidden.bs.dropdown', e => { - expect(dropdownEl.classList.contains('show')).toEqual(false) - expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') - expect(hideEventTriggered).toEqual(true) - expect(e.relatedTarget).toEqual(btnDropdown) - done() - }) - - btnDropdown.click() - }) - - it('should not use popper.js in navbar', done => { - fixtureEl.innerHTML = [ - '' - ].join('') - - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') - - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownMenu.getAttribute('style')).toEqual(null, 'no inline style applied by popper.js') - done() - }) + dropdown.dispose() - btnDropdown.click() + expect(dropdown._menu).toBeNull() + expect(dropdown._element).toBeNull() + expect(spy).toHaveBeenCalledWith(btnDropdown, Dropdown.EVENT_KEY) }) - it('should not use popper.js if display set to static', done => { + it('should dispose dropdown with Popper', () => { fixtureEl.innerHTML = [ '' ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') - const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - // popper.js add this attribute when we use it - expect(dropdownMenu.getAttribute('x-placement')).toEqual(null) - done() - }) + dropdown.toggle() + + expect(dropdown._popper).not.toBeNull() + expect(dropdown._menu).not.toBeNull() + expect(dropdown._element).not.toBeNull() - btnDropdown.click() + dropdown.dispose() + + expect(dropdown._popper).toBeNull() + expect(dropdown._menu).toBeNull() + expect(dropdown._element).toBeNull() }) + }) - it('should remove "show" class if tabbing outside of menu', done => { + describe('update', () => { + it('should call Popper and detect navbar on update', () => { fixtureEl.innerHTML = [ '' ].join('') - const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdownEl = fixtureEl.querySelector('.dropdown') + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - dropdownEl.addEventListener('shown.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(true) + dropdown.toggle() - const keyUp = createEvent('keyup') + expect(dropdown._popper).not.toBeNull() - keyUp.which = 9 // Tab - document.dispatchEvent(keyUp) - }) + const spyUpdate = spyOn(dropdown._popper, 'update') + const spyDetect = spyOn(dropdown, '_detectNavbar') - dropdownEl.addEventListener('hidden.bs.dropdown', () => { - expect(dropdownEl.classList.contains('show')).toEqual(false) - done() - }) + dropdown.update() - btnDropdown.click() + expect(spyUpdate).toHaveBeenCalled() + expect(spyDetect).toHaveBeenCalled() }) - it('should remove "show" class if body is clicked, with multiple dropdowns', done => { + it('should just detect navbar on update', () => { fixtureEl.innerHTML = [ - '', - '
', - ' ', - ' ', + '' ].join('') - const triggerDropdownList = fixtureEl.querySelectorAll('[data-toggle="dropdown"]') + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) - expect(triggerDropdownList.length).toEqual(2) + const spy = spyOn(dropdown, '_detectNavbar') - const first = triggerDropdownList[0] - const last = triggerDropdownList[1] - const dropdownTestMenu = first.parentNode - const btnGroup = last.parentNode + dropdown.update() - dropdownTestMenu.addEventListener('shown.bs.dropdown', () => { - expect(dropdownTestMenu.classList.contains('show')).toEqual(true) - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1) - document.body.click() - }) + expect(dropdown._popper).toBeNull() + expect(spy).toHaveBeenCalled() + }) + }) - dropdownTestMenu.addEventListener('hidden.bs.dropdown', () => { - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0) - last.click() + describe('data-api', () => { + it('should show and hide a dropdown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + let showEventTriggered = false + let hideEventTriggered = false + + btnDropdown.addEventListener('show.bs.dropdown', () => { + showEventTriggered = true + }) + + btnDropdown.addEventListener('shown.bs.dropdown', event => setTimeout(() => { + expect(btnDropdown).toHaveClass('show') + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') + expect(showEventTriggered).toBeTrue() + expect(event.relatedTarget).toEqual(btnDropdown) + document.body.click() + })) + + btnDropdown.addEventListener('hide.bs.dropdown', () => { + hideEventTriggered = true + }) + + btnDropdown.addEventListener('hidden.bs.dropdown', event => { + expect(btnDropdown).not.toHaveClass('show') + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') + expect(hideEventTriggered).toBeTrue() + expect(event.relatedTarget).toEqual(btnDropdown) + resolve() + }) + + btnDropdown.click() }) + }) - btnGroup.addEventListener('shown.bs.dropdown', () => { - expect(btnGroup.classList.contains('show')).toEqual(true) - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1) - document.body.click() + it('should not use "static" Popper in navbar', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdown._popper).not.toBeNull() + expect(dropdownMenu.getAttribute('data-bs-popper')).toEqual('static') + resolve() + }) + + dropdown.show() }) + }) - btnGroup.addEventListener('hidden.bs.dropdown', () => { - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0) - done() - }) + it('should not collapse the dropdown when clicking a select option nested in the dropdown', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - first.click() - }) + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) - it('should remove "show" class if body if tabbing outside of menu, with multiple dropdowns', done => { - fixtureEl.innerHTML = [ - '', - '
', - ' ', - ' ', - ' ', - '
' - ].join('') + const hideSpy = spyOn(dropdown, '_completeHide') - const triggerDropdownList = fixtureEl.querySelectorAll('[data-toggle="dropdown"]') + btnDropdown.addEventListener('shown.bs.dropdown', () => { + const clickEvent = new MouseEvent('click', { + bubbles: true + }) - expect(triggerDropdownList.length).toEqual(2) + dropdownMenu.querySelector('option').dispatchEvent(clickEvent) + }) - const first = triggerDropdownList[0] - const last = triggerDropdownList[1] - const dropdownTestMenu = first.parentNode - const btnGroup = last.parentNode + dropdownMenu.addEventListener('click', event => { + expect(event.target.tagName).toMatch(/select|option/i) - dropdownTestMenu.addEventListener('shown.bs.dropdown', () => { - expect(dropdownTestMenu.classList.contains('show')).toEqual(true, '"show" class added on click') - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1, 'only one dropdown is shown') + Dropdown.clearMenus(event) - const keyUp = createEvent('keyup') - keyUp.which = 9 // Tab + setTimeout(() => { + expect(hideSpy).not.toHaveBeenCalled() + resolve() + }, 10) + }) - document.dispatchEvent(keyUp) + dropdown.show() }) + }) - dropdownTestMenu.addEventListener('hidden.bs.dropdown', () => { - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0, '"show" class removed') - last.click() + it('should manage bs attribute `data-bs-popper`="static" when dropdown is in navbar', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.getAttribute('data-bs-popper')).toEqual('static') + dropdown.hide() + }) + + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(dropdownMenu.getAttribute('data-bs-popper')).toBeNull() + resolve() + }) + + dropdown.show() }) + }) - btnGroup.addEventListener('shown.bs.dropdown', () => { - expect(btnGroup.classList.contains('show')).toEqual(true, '"show" class added on click') - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1, 'only one dropdown is shown') + it('should not use Popper if display set to static', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const keyUp = createEvent('keyup') - keyUp.which = 9 // Tab + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') - document.dispatchEvent(keyUp) - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + // Popper adds this attribute when we use it + expect(dropdownMenu.getAttribute('data-popper-placement')).toBeNull() + resolve() + }) - btnGroup.addEventListener('hidden.bs.dropdown', () => { - expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0, '"show" class removed') - done() + btnDropdown.click() }) + }) - first.click() + it('should manage bs attribute `data-bs-popper`="static" when display set to static', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.getAttribute('data-bs-popper')).toEqual('static') + dropdown.hide() + }) + + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(dropdownMenu.getAttribute('data-bs-popper')).toBeNull() + resolve() + }) + + dropdown.show() + }) }) - it('should fire hide and hidden event without a clickEvent if event type is not click', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should remove "show" class if tabbing outside of menu', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = fixtureEl.querySelector('.dropdown') + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - dropdown.addEventListener('hide.bs.dropdown', e => { - expect(e.clickEvent).toBeUndefined() - }) + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(btnDropdown).toHaveClass('show') - dropdown.addEventListener('hidden.bs.dropdown', e => { - expect(e.clickEvent).toBeUndefined() - done() - }) + const keyup = createEvent('keyup') + + keyup.key = 'Tab' + document.dispatchEvent(keyup) + }) - dropdown.addEventListener('shown.bs.dropdown', () => { - const keyDown = createEvent('keydown') + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(btnDropdown).not.toHaveClass('show') + resolve() + }) - keyDown.which = 27 - triggerDropdown.dispatchEvent(keyDown) + btnDropdown.click() }) + }) - triggerDropdown.click() + it('should remove "show" class if body is clicked, with multiple dropdowns', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
', + ' ', + ' ', + ' ', + '
' + ].join('') + + const triggerDropdownList = fixtureEl.querySelectorAll('[data-bs-toggle="dropdown"]') + + expect(triggerDropdownList).toHaveSize(2) + + const [triggerDropdownFirst, triggerDropdownLast] = triggerDropdownList + + triggerDropdownFirst.addEventListener('shown.bs.dropdown', () => { + expect(triggerDropdownFirst).toHaveClass('show') + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1) + document.body.click() + }) + + triggerDropdownFirst.addEventListener('hidden.bs.dropdown', () => { + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0) + triggerDropdownLast.click() + }) + + triggerDropdownLast.addEventListener('shown.bs.dropdown', () => { + expect(triggerDropdownLast).toHaveClass('show') + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1) + document.body.click() + }) + + triggerDropdownLast.addEventListener('hidden.bs.dropdown', () => { + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0) + resolve() + }) + + triggerDropdownFirst.click() + }) }) - it('should ignore keyboard events within s and ', - '
', - '
' - ].join('') + it('should remove "show" class if body if tabbing outside of menu, with multiple dropdowns', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '
', + ' ', + ' ', + ' ', + '
' + ].join('') - const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = fixtureEl.querySelector('.dropdown') - const input = fixtureEl.querySelector('input') - const textarea = fixtureEl.querySelector('textarea') + const triggerDropdownList = fixtureEl.querySelectorAll('[data-bs-toggle="dropdown"]') - dropdown.addEventListener('shown.bs.dropdown', () => { - input.focus() - const keyDown = createEvent('keydown') + expect(triggerDropdownList).toHaveSize(2) - keyDown.which = 38 - input.dispatchEvent(keyDown) + const [triggerDropdownFirst, triggerDropdownLast] = triggerDropdownList - expect(document.activeElement).toEqual(input, 'input still focused') + triggerDropdownFirst.addEventListener('shown.bs.dropdown', () => { + expect(triggerDropdownFirst).toHaveClass('show') + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1) - textarea.focus() - textarea.dispatchEvent(keyDown) + const keyup = createEvent('keyup') + keyup.key = 'Tab' - expect(document.activeElement).toEqual(textarea, 'textarea still focused') - done() - }) + document.dispatchEvent(keyup) + }) + + triggerDropdownFirst.addEventListener('hidden.bs.dropdown', () => { + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0) + triggerDropdownLast.click() + }) + + triggerDropdownLast.addEventListener('shown.bs.dropdown', () => { + expect(triggerDropdownLast).toHaveClass('show') + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(1) - triggerDropdown.click() + const keyup = createEvent('keyup') + keyup.key = 'Tab' + + document.dispatchEvent(keyup) + }) + + triggerDropdownLast.addEventListener('hidden.bs.dropdown', () => { + expect(fixtureEl.querySelectorAll('.dropdown-menu.show')).toHaveSize(0) + resolve() + }) + + triggerDropdownFirst.click() + }) }) - it('should skip disabled element when using keyboard navigation', done => { + it('should be able to identify clicked dropdown, even with multiple dropdowns in the same tag', () => { fixtureEl.innerHTML = [ '' + ].join('') - const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = fixtureEl.querySelector('.dropdown') - const item1 = fixtureEl.querySelector('#item1') - const item2 = fixtureEl.querySelector('#item2') + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const input = fixtureEl.querySelector('input') + const textarea = fixtureEl.querySelector('textarea') - dropdown.addEventListener('shown.bs.dropdown', () => { - const keyDown40 = createEvent('keydown') - keyDown40.which = 40 + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + input.focus() + const keydown = createEvent('keydown') - triggerDropdown.dispatchEvent(keyDown40) - expect(document.activeElement).toEqual(item1, 'item1 is focused') + keydown.key = 'ArrowUp' + input.dispatchEvent(keydown) - document.activeElement.dispatchEvent(keyDown40) - expect(document.activeElement).toEqual(item2, 'item2 is focused') + expect(document.activeElement).toEqual(input, 'input still focused') - const keyDown38 = createEvent('keydown') - keyDown38.which = 38 + textarea.focus() + textarea.dispatchEvent(keydown) - document.activeElement.dispatchEvent(keyDown38) - expect(document.activeElement).toEqual(item1, 'item1 is focused') + expect(document.activeElement).toEqual(textarea, 'textarea still focused') + resolve() + }) - done() + triggerDropdown.click() }) - - triggerDropdown.click() }) - it('should not close the dropdown if the user clicks on a text field', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should skip disabled element when using keyboard navigation', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = fixtureEl.querySelector('.dropdown') - const input = fixtureEl.querySelector('input') + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - input.addEventListener('click', () => { - expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown') - done() - }) + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + const keydown = createEvent('keydown') + keydown.key = 'ArrowDown' + + triggerDropdown.dispatchEvent(keydown) + triggerDropdown.dispatchEvent(keydown) - dropdown.addEventListener('shown.bs.dropdown', () => { - expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown') - input.dispatchEvent(createEvent('click')) + expect(document.activeElement).not.toHaveClass('disabled') + expect(document.activeElement.hasAttribute('disabled')).toBeFalse() + resolve() + }) + + triggerDropdown.click() }) + }) - triggerDropdown.click() + it('should skip hidden element when using keyboard navigation', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + const keydown = createEvent('keydown') + keydown.key = 'ArrowDown' + + triggerDropdown.dispatchEvent(keydown) + + expect(document.activeElement).not.toHaveClass('d-none') + expect(document.activeElement.style.display).not.toEqual('none') + expect(document.activeElement.style.visibility).not.toEqual('hidden') + + resolve() + }) + + triggerDropdown.click() + }) }) - it('should not close the dropdown if the user clicks on a textarea', done => { - fixtureEl.innerHTML = [ - '' - ].join('') + it('should focus next/previous element when using keyboard navigation', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = fixtureEl.querySelector('.dropdown') - const textarea = fixtureEl.querySelector('textarea') + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const item1 = fixtureEl.querySelector('#item1') + const item2 = fixtureEl.querySelector('#item2') + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + const keydownArrowDown = createEvent('keydown') + keydownArrowDown.key = 'ArrowDown' + + triggerDropdown.dispatchEvent(keydownArrowDown) + expect(document.activeElement).toEqual(item1, 'item1 is focused') + + document.activeElement.dispatchEvent(keydownArrowDown) + expect(document.activeElement).toEqual(item2, 'item2 is focused') + + const keydownArrowUp = createEvent('keydown') + keydownArrowUp.key = 'ArrowUp' + + document.activeElement.dispatchEvent(keydownArrowUp) + expect(document.activeElement).toEqual(item1, 'item1 is focused') + + resolve() + }) - textarea.addEventListener('click', () => { - expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown') - done() + triggerDropdown.click() }) + }) - dropdown.addEventListener('shown.bs.dropdown', () => { - expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown') - textarea.dispatchEvent(createEvent('click')) + it('should open the dropdown and focus on the last item when using ArrowUp for the first time', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const lastItem = fixtureEl.querySelector('#item2') + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + setTimeout(() => { + expect(document.activeElement).toEqual(lastItem, 'item2 is focused') + resolve() + }) + }) + + const keydown = createEvent('keydown') + keydown.key = 'ArrowUp' + triggerDropdown.dispatchEvent(keydown) }) + }) - triggerDropdown.click() + it('should open the dropdown and focus on the first item when using ArrowDown for the first time', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const firstItem = fixtureEl.querySelector('#item1') + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + setTimeout(() => { + expect(document.activeElement).toEqual(firstItem, 'item1 is focused') + resolve() + }) + }) + + const keydown = createEvent('keydown') + keydown.key = 'ArrowDown' + triggerDropdown.dispatchEvent(keydown) + }) }) - it('should ignore keyboard events for s and ', - '
', - '
' - ].join('') + it('should not close the dropdown if the user clicks on a text field within dropdown-menu', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]') - const dropdown = fixtureEl.querySelector('.dropdown') - const input = fixtureEl.querySelector('input') - const textarea = fixtureEl.querySelector('textarea') + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const input = fixtureEl.querySelector('input') + + input.addEventListener('click', () => { + expect(triggerDropdown).toHaveClass('show') + resolve() + }) + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + expect(triggerDropdown).toHaveClass('show') + input.dispatchEvent(createEvent('click')) + }) + + triggerDropdown.click() + }) + }) + + it('should not close the dropdown if the user clicks on a textarea within dropdown-menu', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') - // Space key - const keyDownSpace = createEvent('keydown') - keyDownSpace.which = 32 + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const textarea = fixtureEl.querySelector('textarea') - // Key up - const keyDownUp = createEvent('keydown') - keyDownSpace.which = 38 + textarea.addEventListener('click', () => { + expect(triggerDropdown).toHaveClass('show') + resolve() + }) - // Key down - const keyDown = createEvent('keydown') - keyDownSpace.which = 40 + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + expect(triggerDropdown).toHaveClass('show') + textarea.dispatchEvent(createEvent('click')) + }) - // Key escape - const keyDownEscape = createEvent('keydown') - keyDownEscape.which = 27 + triggerDropdown.click() + }) + }) + + it('should close the dropdown if the user clicks on a text field that is not contained within dropdown-menu', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '', + '' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const input = fixtureEl.querySelector('input') + + triggerDropdown.addEventListener('hidden.bs.dropdown', () => { + expect().nothing() + resolve() + }) + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + input.dispatchEvent(createEvent('click', { + bubbles: true + })) + }) + + triggerDropdown.click() + }) + }) - dropdown.addEventListener('shown.bs.dropdown', () => { - // Space key - input.focus() - input.dispatchEvent(keyDownSpace) + it('should ignore keyboard events for s and ', + '
', + '
' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const input = fixtureEl.querySelector('input') + const textarea = fixtureEl.querySelector('textarea') + + const test = (eventKey, elementToDispatch) => { + const event = createEvent('keydown') + event.key = eventKey + elementToDispatch.focus() + elementToDispatch.dispatchEvent(event) + expect(document.activeElement).toEqual(elementToDispatch, `${elementToDispatch.tagName} still focused`) + } - expect(document.activeElement).toEqual(input, 'input still focused') + const keydownEscape = createEvent('keydown') + keydownEscape.key = 'Escape' - textarea.focus() - textarea.dispatchEvent(keyDownSpace) + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + // Key Space + test('Space', input) - expect(document.activeElement).toEqual(textarea, 'textarea still focused') + test('Space', textarea) - // Key up - input.focus() - input.dispatchEvent(keyDownUp) + // Key ArrowUp + test('ArrowUp', input) - expect(document.activeElement).toEqual(input, 'input still focused') + test('ArrowUp', textarea) - textarea.focus() - textarea.dispatchEvent(keyDownUp) + // Key ArrowDown + test('ArrowDown', input) - expect(document.activeElement).toEqual(textarea, 'textarea still focused') + test('ArrowDown', textarea) - // Key down - input.focus() - input.dispatchEvent(keyDown) + // Key Escape + input.focus() + input.dispatchEvent(keydownEscape) - expect(document.activeElement).toEqual(input, 'input still focused') + expect(triggerDropdown).not.toHaveClass('show') + resolve() + }) - textarea.focus() - textarea.dispatchEvent(keyDown) + triggerDropdown.click() + }) + }) - expect(document.activeElement).toEqual(textarea, 'textarea still focused') + it('should not open dropdown if escape key was pressed on the toggle', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(triggerDropdown) + const button = fixtureEl.querySelector('button[data-bs-toggle="dropdown"]') + + const spy = spyOn(dropdown, 'toggle') // Key escape - input.focus() - input.dispatchEvent(keyDownEscape) + button.focus() + // Key escape + const keydownEscape = createEvent('keydown') + keydownEscape.key = 'Escape' + button.dispatchEvent(keydownEscape) + + setTimeout(() => { + expect(spy).not.toHaveBeenCalled() + expect(triggerDropdown).not.toHaveClass('show') + resolve() + }, 20) + }) + }) + + it('should propagate escape key events if dropdown is closed', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const parent = fixtureEl.querySelector('.parent') + const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + + const parentKeyHandler = jasmine.createSpy('parentKeyHandler') + + parent.addEventListener('keydown', parentKeyHandler) + parent.addEventListener('keyup', () => { + expect(parentKeyHandler).toHaveBeenCalled() + resolve() + }) + + const keydownEscape = createEvent('keydown', { bubbles: true }) + keydownEscape.key = 'Escape' + const keyupEscape = createEvent('keyup', { bubbles: true }) + keyupEscape.key = 'Escape' + + toggle.focus() + toggle.dispatchEvent(keydownEscape) + toggle.dispatchEvent(keyupEscape) + }) + }) + + it('should not propagate escape key events if dropdown is open', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + ' ', + '
' + ].join('') + + const parent = fixtureEl.querySelector('.parent') + const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + + const parentKeyHandler = jasmine.createSpy('parentKeyHandler') + + parent.addEventListener('keydown', parentKeyHandler) + parent.addEventListener('keyup', () => { + expect(parentKeyHandler).not.toHaveBeenCalled() + resolve() + }) + + const keydownEscape = createEvent('keydown', { bubbles: true }) + keydownEscape.key = 'Escape' + const keyupEscape = createEvent('keyup', { bubbles: true }) + keyupEscape.key = 'Escape' + + toggle.click() + toggle.dispatchEvent(keydownEscape) + toggle.dispatchEvent(keyupEscape) + }) + }) + + it('should close dropdown using `escape` button, and return focus to its trigger', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + + toggle.addEventListener('shown.bs.dropdown', () => { + const keydownEvent = createEvent('keydown', { bubbles: true }) + keydownEvent.key = 'ArrowDown' + toggle.dispatchEvent(keydownEvent) + keydownEvent.key = 'Escape' + toggle.dispatchEvent(keydownEvent) + }) + + toggle.addEventListener('hidden.bs.dropdown', () => setTimeout(() => { + expect(document.activeElement).toEqual(toggle) + resolve() + })) + + toggle.click() + }) + }) + + it('should close dropdown (only) by clicking inside the dropdown menu when it has data-attribute `data-bs-auto-close="inside"`', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = () => setTimeout(() => { + expect(dropdownToggle).toHaveClass('show') + dropdownMenu.click() + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + document.documentElement.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.addEventListener('hidden.bs.dropdown', () => setTimeout(() => { + expect(dropdownToggle).not.toHaveClass('show') + resolve() + })) + + dropdownToggle.click() + }) + }) - expect(dropdown.classList.contains('show')).toEqual(false, 'dropdown menu is not shown') - done() + it('should close dropdown (only) by clicking outside the dropdown menu when it has data-attribute `data-bs-auto-close="outside"`', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = () => setTimeout(() => { + expect(dropdownToggle).toHaveClass('show') + document.documentElement.click() + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + dropdownMenu.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.addEventListener('hidden.bs.dropdown', () => { + expect(dropdownToggle).not.toHaveClass('show') + resolve() + }) + + dropdownToggle.click() }) + }) - triggerDropdown.click() + it('should not close dropdown by clicking inside or outside the dropdown menu when it has data-attribute `data-bs-auto-close="false"`', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = (shouldTriggerClick = true) => setTimeout(() => { + expect(dropdownToggle).toHaveClass('show') + if (shouldTriggerClick) { + document.documentElement.click() + } else { + resolve() + } + + expectDropdownToBeOpened(false) + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + dropdownMenu.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.click() + }) }) - it('should not open dropdown if escape key was pressed on the toggle', done => { + it('should be able to identify clicked dropdown, no matter the markup order', () => { fixtureEl.innerHTML = [ - '
', - ' `} /> + +## Mixed styles + + + + + +
`} /> + +## Outlined styles + + + + + +
`} /> + +## Checkbox and radio button groups + +Combine button-like checkbox and radio [toggle buttons]([[docsref:/forms/checks-radios]]) into a seamless looking button group. + + + + + + + + + + +
`} /> + + + + + + + + + + +
`} /> + +## Button toolbar + +Combine sets of button groups into button toolbars for more complex components. Use utility classes as needed to space out groups, buttons, and more. + + +
+ + + + +
+
+ + + +
+
+ +
+ `} /> + +Feel free to mix input groups with button groups in your toolbars. Similar to the example above, you’ll likely need some utilities though to space things properly. + + +
+ + + + +
+
+
@
+ +
+ + + `} /> + +## Sizing + +Instead of applying button sizing classes to every button in a group, just add `.btn-group-*` to each `.btn-group`, including each one when nesting multiple groups. + + + + + + +
+
+ + + +
+
+
+ + + +
`} /> + +## Nesting + +Place a `.btn-group` within another `.btn-group` when you want dropdown menus mixed with a series of buttons. + + + + + +
+ + +
+ `} /> + +## Vertical variation + +Make a set of buttons appear vertically stacked rather than horizontally. **Split button dropdowns are not supported here.** + + + + + + + `} /> + + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+ `} /> + + + + + + + + + `} /> diff --git a/site/src/content/docs/components/buttons.mdx b/site/src/content/docs/components/buttons.mdx new file mode 100644 index 000000000000..f54ce8f95b5b --- /dev/null +++ b/site/src/content/docs/components/buttons.mdx @@ -0,0 +1,227 @@ +--- +title: Buttons +description: Use Bootstrap’s custom button styles for actions in forms, dialogs, and more with support for multiple sizes, states, and more. +toc: true +--- + +import { getData } from '@libs/data' + +## Base class + +Bootstrap has a base `.btn` class that sets up basic styles such as padding and content alignment. By default, `.btn` controls have a transparent border and background color, and lack any explicit focus and hover styles. + +Base class`} /> + +The `.btn` class is intended to be used in conjunction with our button variants, or to serve as a basis for your own custom styles. + + +If you are using the `.btn` class on its own, remember to at least define some explicit `:focus` and/or `:focus-visible` styles. + + +## Variants + +Bootstrap includes several button variants, each serving its own semantic purpose, with a few extras thrown in for more control. + + ``), ` +`]} /> + + + +## Disable text wrapping + +If you don’t want the button text to wrap, you can add the `.text-nowrap` class to the button. In Sass, you can set `$btn-white-space: nowrap` to disable text wrapping for each button. + +## Button tags + +The `.btn` classes are designed to be used with the ` + + +`} /> + +## Outline buttons + +In need of a button, but not the hefty background colors they bring? Replace the default modifier classes with the `.btn-outline-*` ones to remove all background images and colors on any button. + + ``)} /> + + +Some of the button styles use a relatively light foreground color, and should only be used on a dark background in order to have sufficient contrast. + + +## Sizes + +Fancy larger or smaller buttons? Add `.btn-lg` or `.btn-sm` for additional sizes. + +Large button +`} /> + +Small button +`} /> + +You can even roll your own custom sizing with CSS variables: + + + Custom button + `} /> + +## Disabled state + +Make buttons look inactive by adding the `disabled` boolean attribute to any ` + + +`} /> + +Disabled buttons using the `` element behave a bit different: + +- ``s don’t support the `disabled` attribute, so you must add the `.disabled` class to make it visually appear disabled. +- Some future-friendly styles are included to disable all `pointer-events` on anchor buttons. +- Disabled buttons using `` should include the `aria-disabled="true"` attribute to indicate the state of the element to assistive technologies. +- Disabled buttons using `` *should not* include the `href` attribute. + +Primary link +Link`} /> + +### Link functionality caveat + +To cover cases where you have to keep the `href` attribute on a disabled link, the `.disabled` class uses `pointer-events: none` to try to disable the link functionality of ``s. Note that this CSS property is not yet standardized for HTML, but all modern browsers support it. In addition, even in browsers that do support `pointer-events: none`, keyboard navigation remains unaffected, meaning that sighted keyboard users and users of assistive technologies will still be able to activate these links. So to be safe, in addition to `aria-disabled="true"`, also include a `tabindex="-1"` attribute on these links to prevent them from receiving keyboard focus, and use custom JavaScript to disable their functionality altogether. + +Primary link +Link`} /> + +## Block buttons + +Create responsive stacks of full-width, “block buttons” like those in Bootstrap 4 with a mix of our display and gap utilities. By using utilities instead of button-specific classes, we have much greater control over spacing, alignment, and responsive behaviors. + + + + + `} /> + +Here we create a responsive variation, starting with vertically stacked buttons until the `md` breakpoint, where `.d-md-block` replaces the `.d-grid` class, thus nullifying the `gap-2` utility. Resize your browser to see them change. + + + + + `} /> + +You can adjust the width of your block buttons with grid column width classes. For example, for a half-width “block button”, use `.col-6`. Center it horizontally with `.mx-auto`, too. + + + + + `} /> + +Additional utilities can be used to adjust the alignment of buttons when horizontal. Here we’ve taken our previous responsive example and added some flex utilities and a margin utility on the button to right-align the buttons when they’re no longer stacked. + + + + + `} /> + +## Button plugin + +The button plugin allows you to create simple on/off toggle buttons. + + +Visually, these toggle buttons are identical to the [checkbox toggle buttons]([[docsref:/forms/checks-radios#checkbox-toggle-buttons]]). However, they are conveyed differently by assistive technologies: the checkbox toggles will be announced by screen readers as “checked”/“not checked” (since, despite their appearance, they are fundamentally still checkboxes), whereas these toggle buttons will be announced as “button”/“button pressed”. The choice between these two approaches will depend on the type of toggle you are creating, and whether or not the toggle will make sense to users when announced as a checkbox or as an actual button. + + +### Toggle states + +Add `data-bs-toggle="button"` to toggle a button’s `active` state. If you’re pre-toggling a button, you must manually add the `.active` class **and** `aria-pressed="true"` to ensure that it is conveyed appropriately to assistive technologies. + + + + + +

+

+ + + +

`} /> + + + Toggle link + Active toggle link + Disabled toggle link +

+

+ Toggle link + Active toggle link + Disabled toggle link +

`} /> + +### Methods + +You can create a button instance with the button constructor, for example: + +```js +const bsButton = new bootstrap.Button('#myButton') +``` + + +| Method | Description | +| --- | --- | +| `dispose` | Destroys an element’s button. (Removes stored data on the DOM element) | +| `getInstance` | Static method which allows you to get the button instance associated with a DOM element, you can use it like this: `bootstrap.Button.getInstance(element)`. | +| `getOrCreateInstance` | Static method which returns a button instance associated with a DOM element or creates a new one in case it wasn’t initialized. You can use it like this: `bootstrap.Button.getOrCreateInstance(element)`. | +| `toggle` | Toggles push state. Gives the button the appearance that it has been activated. | + + +For example, to toggle all buttons + +```js +document.querySelectorAll('.btn').forEach(buttonElement => { + const button = bootstrap.Button.getOrCreateInstance(buttonElement) + button.toggle() +}) +``` + +## CSS + +### Variables + + + +As part of Bootstrap’s evolving CSS variables approach, buttons now use local CSS variables on `.btn` for enhanced real-time customization. Values for the CSS variables are set via Sass, so Sass customization is still supported, too. + + + +Each `.btn-*` modifier class updates the appropriate CSS variables to minimize additional CSS rules with our `button-variant()`, `button-outline-variant()`, and `button-size()` mixins. + +Here’s an example of building a custom `.btn-*` modifier class as we do for the buttons unique to our docs by reassigning Bootstrap’s CSS variables with a mixture of our own CSS and Sass variables. + +
+ +
+ + + +### Sass variables + + + +### Sass mixins + +There are three mixins for buttons: button and button outline variant mixins (both based on `$theme-colors`), plus a button size mixin. + + + + + + + +### Sass loops + +Button variants (for regular and outline buttons) use their respective mixins with our `$theme-colors` map to generate the modifier classes in `scss/_buttons.scss`. + + diff --git a/site/src/content/docs/components/card.mdx b/site/src/content/docs/components/card.mdx new file mode 100644 index 000000000000..1cf25220cfc9 --- /dev/null +++ b/site/src/content/docs/components/card.mdx @@ -0,0 +1,673 @@ +--- +title: Cards +description: Bootstrap’s cards provide a flexible and extensible content container with multiple variants and options. +toc: true +--- + +import { getData } from '@libs/data' + +## About + +A **card** is a flexible and extensible content container. It includes options for headers and footers, a wide variety of content, contextual background colors, and powerful display options. If you’re familiar with Bootstrap 3, cards replace our old panels, wells, and thumbnails. Similar functionality to those components is available as modifier classes for cards. + +## Example + +Cards are built with as little markup and styles as possible, but still manage to deliver a ton of control and customization. Built with flexbox, they offer easy alignment and mix well with other Bootstrap components. They have no `margin` by default, so use [spacing utilities]([[docsref:/utilities/spacing]]) as needed. + +Below is an example of a basic card with mixed content and a fixed width. Cards have no fixed width to start, so they’ll naturally fill the full width of its parent element. This is easily customized with our various [sizing options](#sizing). + + + +
+
Card title
+

Some quick example text to build on the card title and make up the bulk of the card’s content.

+ Go somewhere +
+ `} /> + +## Content types + +Cards support a wide variety of content, including images, text, list groups, links, and more. Below are examples of what’s supported. + +### Body + +The building block of a card is the `.card-body`. Use it whenever you need a padded section within a card. + + +
+ This is some text within a card body. +
+ `} /> + +### Titles, text, and links + +Card titles are used by adding `.card-title` to a `` tag. In the same way, links are added and placed next to each other by adding `.card-link` to an `` tag. + +Subtitles are used by adding a `.card-subtitle` to a `` tag. If the `.card-title` and the `.card-subtitle` items are placed in a `.card-body` item, the card title and subtitle are aligned nicely. + + + + `} /> + +### Images + +`.card-img-top` and `.card-img-bottom` respectively set the top and bottom corners rounded to match the card’s borders. With `.card-text`, text can be added to the card. Text within `.card-text` can also be styled with the standard HTML tags. + + + +
+

Some quick example text to build on the card title and make up the bulk of the card’s content.

+
+ `} /> + +### List groups + +Create lists of content in a card with a flush list group. + + +
    +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
+ `} /> + + +
+ Featured +
+
    +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
+ `} /> + + +
    +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
+ + `} /> + +### Kitchen sink + +Mix and match multiple content types to create the card you need, or throw everything in there. Shown below are image styles, blocks, text styles, and a list group—all wrapped in a fixed-width card. + + + +
+
Card title
+

Some quick example text to build on the card title and make up the bulk of the card’s content.

+
+
    +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
+ + `} /> + +### Header and footer + +Add an optional header and/or footer within a card. + + +
+ Featured +
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ `} /> + +Card headers can be styled by adding `.card-header` to `` elements. + + +
Featured
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ `} /> + + +
+ Quote +
+
+
+
+

A well-known quote, contained in a blockquote element.

+
+ +
+
+ `} /> + + +
+ Featured +
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ + `} /> + +## Sizing + +Cards assume no specific `width` to start, so they’ll be 100% wide unless otherwise stated. You can change this as needed with custom CSS, grid classes, grid Sass mixins, or utilities. + +### Using grid markup + +Using the grid, wrap cards in columns and rows as needed. + + +
+
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+
+
+
+
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+
+
+ `} /> + +### Using utilities + +Use our handful of [available sizing utilities]([[docsref:/utilities/sizing]]) to quickly set a card’s width. + + +
+
Card title
+

With supporting text below as a natural lead-in to additional content.

+ Button +
+ + +
+
+
Card title
+

With supporting text below as a natural lead-in to additional content.

+ Button +
+
`} /> + +### Using custom CSS + +Use custom CSS in your stylesheets or as inline styles to set a width. + + +
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ `} /> + +## Text alignment + +You can quickly change the text alignment of any card—in its entirety or specific parts—with our [text align classes]([[docsref:/utilities/text#text-alignment]]). + + +
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ + +
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+
+ +
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+
`} /> + +## Navigation + +Add some navigation to a card’s header (or block) with Bootstrap’s [nav components]([[docsref:/components/navs-tabs]]). + + +
+ +
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ `} /> + + +
+ +
+
+
Special title treatment
+

With supporting text below as a natural lead-in to additional content.

+ Go somewhere +
+ `} /> + +## Images + +Cards include a few options for working with images. Choose from appending “image caps” at either end of a card, overlaying images with card content, or simply embedding the image in a card. + +### Image caps + +Similar to headers and footers, cards can include top and bottom “image caps”—images at the top or bottom of a card. + + + +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+

Last updated 3 mins ago

+
+ +
+
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+

Last updated 3 mins ago

+
+ +
`} /> + +### Image overlays + +Turn an image into a card background and overlay your card’s text. Depending on the image, you may or may not need additional styles or utilities. + + + +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+

Last updated 3 mins ago

+
+ `} /> + + +Note that content should not be larger than the height of the image. If content is larger than the image the content will be displayed outside the image. + + +## Horizontal + +Using a combination of grid and utility classes, cards can be made horizontal in a mobile-friendly and responsive way. In the example below, we remove the grid gutters with `.g-0` and use `.col-md-*` classes to make the card horizontal at the `md` breakpoint. Further adjustments may be needed depending on your card content. + + +
+
+ +
+
+
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+

Last updated 3 mins ago

+
+
+
+ `} /> + +## Card styles + +Cards include various options for customizing their backgrounds, borders, and color. + +### Background and color + + + +Set a `background-color` with contrasting foreground `color` with [our `.text-bg-{color}` helpers]([[docsref:helpers/color-background]]). Previously it was required to manually pair your choice of [`.text-{color}`]([[docsref:/utilities/colors]]) and [`.bg-{color}`]([[docsref:/utilities/background]]) utilities for styling, which you still may use if you prefer. + + `
+
Header
+
+
${themeColor.title} card title
+

Some quick example text to build on the card title and make up the bulk of the card’s content.

+
+
`)} /> + + + +### Border + +Use [border utilities]([[docsref:/utilities/borders]]) to change just the `border-color` of a card. Note that you can put `.text-{color}` classes on the parent `.card` or a subset of the card’s contents as shown below. + + `
+
Header
+
+
${themeColor.title} card title
+

Some quick example text to build on the card title and make up the bulk of the card’s content.

+
+
`)} /> + +### Mixins utilities + +You can also change the borders on the card header and footer as needed, and even remove their `background-color` with `.bg-transparent`. + + +
Header
+
+
Success card title
+

Some quick example text to build on the card title and make up the bulk of the card’s content.

+
+ + `} /> + +## Card layout + +In addition to styling the content within cards, Bootstrap includes a few options for laying out series of cards. For the time being, **these layout options are not yet responsive**. + +### Card groups + +Use card groups to render cards as a single, attached element with equal width and height columns. Card groups start off stacked and use `display: flex;` to become attached with uniform dimensions starting at the `sm` breakpoint. + + +
+ +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+

Last updated 3 mins ago

+
+
+
+ +
+
Card title
+

This card has supporting text below as a natural lead-in to additional content.

+

Last updated 3 mins ago

+
+
+
+ +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This card has even longer content than the first to show that equal height action.

+

Last updated 3 mins ago

+
+
+ `} /> + +When using card groups with footers, their content will automatically line up. + + +
+ +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+ +
+
+ +
+
Card title
+

This card has supporting text below as a natural lead-in to additional content.

+
+ +
+
+ +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This card has even longer content than the first to show that equal height action.

+
+ +
+ `} /> + +### Grid cards + +Use the Bootstrap grid system and its [`.row-cols` classes]([[docsref:/layout/grid#row-columns]]) to control how many grid columns (wrapped around your cards) you show per row. For example, here’s `.row-cols-1` laying out the cards on one column, and `.row-cols-md-2` splitting four cards to equal width across multiple rows, from the medium breakpoint up. + + +
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+ `} /> + +Change it to `.row-cols-3` and you’ll see the fourth card wrap. + + +
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+ `} /> + +When you need equal height, add `.h-100` to the cards. If you want equal heights by default, you can set `$card-height: 100%` in Sass. + + +
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+
+
+ +
+
Card title
+

This is a short card.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content.

+
+
+
+
+
+ +
+
Card title
+

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+
+
+ `} /> + +Just like with card groups, card footers will automatically line up. + + +
+
+ +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

+
+ +
+
+
+
+ +
+
Card title
+

This card has supporting text below as a natural lead-in to additional content.

+
+ +
+
+
+
+ +
+
Card title
+

This is a wider card with supporting text below as a natural lead-in to additional content. This card has even longer content than the first to show that equal height action.

+
+ +
+
+ `} /> + +### Masonry + +In `v4` we used a CSS-only technique to mimic the behavior of [Masonry](https://masonry.desandro.com/)-like columns, but this technique came with lots of unpleasant [side effects](https://github.com/twbs/bootstrap/pull/28922). If you want to have this type of layout in `v5`, you can just make use of Masonry plugin. **Masonry is not included in Bootstrap**, but we’ve made a [demo example]([[docsref:/examples/masonry]]) to help you get started. + +## CSS + +### Variables + + + +As part of Bootstrap’s evolving CSS variables approach, cards now use local CSS variables on `.card` for enhanced real-time customization. Values for the CSS variables are set via Sass, so Sass customization is still supported, too. + + + +### Sass variables + + diff --git a/site/src/content/docs/components/carousel.mdx b/site/src/content/docs/components/carousel.mdx new file mode 100644 index 000000000000..397f7da876c9 --- /dev/null +++ b/site/src/content/docs/components/carousel.mdx @@ -0,0 +1,420 @@ +--- +title: Carousel +description: A slideshow component for cycling through elements—images or slides of text—like a carousel. +toc: true +--- + +## How it works + +- The carousel is a slideshow for cycling through a series of content, built with CSS 3D transforms and a bit of JavaScript. It works with a series of images, text, or custom markup. It also includes support for previous/next controls and indicators. + +- For performance reasons, **carousels must be manually initialized** using the [carousel constructor method](#methods). Without initialization, some of the event listeners (specifically, the events needed touch/swipe support) will not be registered until a user has explicitly activated a control or indicator. + + The only exception are [autoplaying carousels](#autoplaying-carousels) with the `data-bs-ride="carousel"` attribute as these are initialized automatically on page load. If you’re using autoplaying carousels with the data attribute, **don’t explicitly initialize the same carousels with the constructor method.** + +- Nested carousels are not supported. You should also be aware that carousels in general can often cause usability and accessibility challenges. + + + +## Basic examples + +Here is a basic example of a carousel with three slides. Note the previous/next controls. We recommend using ` + + `} /> + +Carousels don’t automatically normalize slide dimensions. As such, you may need to use additional utilities or custom styles to appropriately size content. While carousels support previous/next controls and indicators, they’re not explicitly required. Add and customize as you see fit. + +**You must add the `.active` class to one of the slides**, otherwise the carousel will not be visible. Also be sure to set a unique `id` on the `.carousel` for optional controls, especially if you’re using multiple carousels on a single page. Control and indicator elements must have a `data-bs-target` attribute (or `href` for links) that matches the `id` of the `.carousel` element. + +### Indicators + +You can add indicators to the carousel, alongside the previous/next controls. The indicators let users jump directly to a particular slide. + + + + + + + `} /> + +### Captions + +You can add captions to your slides with the `.carousel-caption` element within any `.carousel-item`. They can be easily hidden on smaller viewports, as shown below, with optional [display utilities]([[docsref:/utilities/display]]). We hide them initially with `.d-none` and bring them back on medium-sized devices with `.d-md-block`. + + + + + + + `} /> + +### Crossfade + +Add `.carousel-fade` to your carousel to animate slides with a fade transition instead of a slide. Depending on your carousel content (e.g., text only slides), you may want to add `.bg-body` or some custom CSS to the `.carousel-item`s for proper crossfading. + + + + + + `} /> + +## Autoplaying carousels + +You can make your carousels autoplay on page load by setting the `ride` option to `carousel`. Autoplaying carousels automatically pause while hovered with the mouse. This behavior can be controlled with the `pause` option. In browsers that support the [Page Visibility API](https://www.w3.org/TR/page-visibility/), the carousel will stop cycling when the webpage is not visible to the user (such as when the browser tab is inactive, or when the browser window is minimized). + + +For accessibility reasons, we recommend avoiding the use of autoplaying carousels. If your page does include an autoplaying carousel, we recommend providing an additional button or control to explicitly pause/stop the carousel. + +See [WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide](https://www.w3.org/TR/WCAG/#pause-stop-hide). + + + + + + + `} /> + +When the `ride` option is set to `true`, rather than `carousel`, the carousel won’t automatically start to cycle on page load. Instead, it will only start after the first user interaction. + + + + + + `} /> + +### Individual `.carousel-item` interval + +Add `data-bs-interval=""` to a `.carousel-item` to change the amount of time to delay between automatically cycling to the next item. + + + + + + `} /> + +### Autoplaying carousels without controls + +Here’s a carousel with slides only. Note the presence of the `.d-block` and `.w-100` on carousel images to prevent browser default image alignment. + + + + `} /> + +## Disable touch swiping + +Carousels support swiping left/right on touchscreen devices to move between slides. This can be disabled by setting the `touch` option to `false`. + + + + + + `} /> + +## Dark variant + + + +Add `.carousel-dark` to the `.carousel` for darker controls, indicators, and captions. Controls are inverted compared to their default white fill with the `filter` CSS property. Captions and controls have additional Sass variables that customize the `color` and `background-color`. + + + + + + + + + `} /> + +## Custom transition + +The transition duration of `.carousel-item` can be changed with the `$carousel-transition-duration` Sass variable before compiling or custom styles if you’re using the compiled CSS. If multiple transitions are applied, make sure the transform transition is defined first (e.g. `transition: transform 2s ease, opacity .5s ease-out`). + +## CSS + +### Sass variables + +Variables for all carousels: + + + +Variables for the [dark carousel](#dark-variant): + + + +## Usage + +### Via data attributes + +Use data attributes to easily control the position of the carousel. `data-bs-slide` accepts the keywords `prev` or `next`, which alters the slide position relative to its current position. Alternatively, use `data-bs-slide-to` to pass a raw slide index to the carousel `data-bs-slide-to="2"`, which shifts the slide position to a particular index beginning with `0`. + +### Via JavaScript + +Call carousel manually with: + +```js +const carousel = new bootstrap.Carousel('#myCarousel') +``` + +### Options + + + + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `interval` | number | `5000` | The amount of time to delay between automatically cycling an item. | +| `keyboard` | boolean | `true` | Whether the carousel should react to keyboard events. | +| `pause` | string, boolean | `"hover"` | If set to `"hover"`, pauses the cycling of the carousel on `mouseenter` and resumes the cycling of the carousel on `mouseleave`. If set to `false`, hovering over the carousel won’t pause it. On touch-enabled devices, when set to `"hover"`, cycling will pause on `touchend` (once the user finished interacting with the carousel) for two intervals, before automatically resuming. This is in addition to the mouse behavior. | +| `ride` | string, boolean | `false` | If set to `true`, autoplays the carousel after the user manually cycles the first item. If set to `"carousel"`, autoplays the carousel on load. | +| `touch` | boolean | `true` | Whether the carousel should support left/right swipe interactions on touchscreen devices. | +| `wrap` | boolean | `true` | Whether the carousel should cycle continuously or have hard stops. | + + +### Methods + + + +You can create a carousel instance with the carousel constructor, and pass on any additional options. For example, to manually initialize an autoplaying carousel (assuming you’re not using the `data-bs-ride="carousel"` attribute in the markup itself) with a specific interval and with touch support disabled, you can use: + +```js +const myCarouselElement = document.querySelector('#myCarousel') + +const carousel = new bootstrap.Carousel(myCarouselElement, { + interval: 2000, + touch: false +}) +``` + + +| Method | Description | +| --- | --- | +| `cycle` | Starts cycling through the carousel items from left to right. | +| `dispose` | Destroys an element’s carousel. (Removes stored data on the DOM element) | +| `getInstance` | Static method which allows you to get the carousel instance associated to a DOM element. You can use it like this: `bootstrap.Carousel.getInstance(element)`. | +| `getOrCreateInstance` | Static method which returns a carousel instance associated to a DOM element, or creates a new one in case it wasn’t initialized. You can use it like this: `bootstrap.Carousel.getOrCreateInstance(element)`. | +| `next` | Cycles to the next item. **Returns to the caller before the next item has been shown** (e.g., before the `slid.bs.carousel` event occurs). | +| `nextWhenVisible` | Don’t cycle carousel to next when the page, the carousel, or the carousel’s parent aren’t visible. **Returns to the caller before the target item has been shown**. | +| `pause` | Stops the carousel from cycling through items. | +| `prev` | Cycles to the previous item. **Returns to the caller before the previous item has been shown** (e.g., before the `slid.bs.carousel` event occurs). | +| `to` | Cycles the carousel to a particular frame (0 based, similar to an array). **Returns to the caller before the target item has been shown** (e.g., before the `slid.bs.carousel` event occurs). | + + +### Events + +Bootstrap’s carousel class exposes two events for hooking into carousel functionality. Both events have the following additional properties: + +- `direction`: The direction in which the carousel is sliding (either `"left"` or `"right"`). +- `relatedTarget`: The DOM element that is being slid into place as the active item. +- `from`: The index of the current item +- `to`: The index of the next item + +All carousel events are fired at the carousel itself (i.e. at the ``} /> + +## CSS + +### Variables + + + +As part of Bootstrap’s evolving CSS variables approach, close button now uses local CSS variables on `.btn-close` for enhanced real-time customization. Values for the CSS variables are set via Sass, so Sass customization is still supported, too. + + + +### Sass variables + + diff --git a/site/src/content/docs/components/collapse.mdx b/site/src/content/docs/components/collapse.mdx new file mode 100644 index 000000000000..8124c492f380 --- /dev/null +++ b/site/src/content/docs/components/collapse.mdx @@ -0,0 +1,184 @@ +--- +title: Collapse +description: Toggle the visibility of content across your project with a few classes and our JavaScript plugins. +toc: true +--- + +## How it works + +The collapse JavaScript plugin is used to show and hide content. Buttons or anchors are used as triggers that are mapped to specific elements you toggle. Collapsing an element will animate the `height` from its current value to `0`. Given how CSS handles animations, you cannot use `padding` on a `.collapse` element. Instead, use the class as an independent wrapping element. + + + +## Example + +Click the buttons below to show and hide another element via class changes: + +- `.collapse` hides content +- `.collapsing` is applied during transitions +- `.collapse.show` shows content + +Generally, we recommend using a ` +

+
+
+ Some placeholder content for the collapse component. This panel is hidden by default but revealed when the user activates the relevant trigger. +
+
`} /> + +## Horizontal + +The collapse plugin supports horizontal collapsing. Add the `.collapse-horizontal` modifier class to transition the `width` instead of `height` and set a `width` on the immediate child element. Feel free to write your own custom Sass, use inline styles, or use our [width utilities]([[docsref:/utilities/sizing]]). + + +Please note that while the example below has a `min-height` set to avoid excessive repaints in our docs, this is not explicitly required. **Only the `width` on the child element is required.** + + + + +

+
+
+
+ This is some placeholder content for a horizontal collapse. It’s hidden by default and shown when triggered. +
+
+
`} /> + +## Multiple toggles and targets + +A ` + +

+
+
+
+
+ Some placeholder content for the first collapse component of this multi-collapse example. This panel is hidden by default but revealed when the user activates the relevant trigger. +
+
+
+
+
+
+ Some placeholder content for the second collapse component of this multi-collapse example. This panel is hidden by default but revealed when the user activates the relevant trigger. +
+
+
+
`} /> + +## Accessibility + +Be sure to add `aria-expanded` to the control element. This attribute explicitly conveys the current state of the collapsible element tied to the control to screen readers and similar assistive technologies. If the collapsible element is closed by default, the attribute on the control element should have a value of `aria-expanded="false"`. If you’ve set the collapsible element to be open by default using the `show` class, set `aria-expanded="true"` on the control instead. The plugin will automatically toggle this attribute on the control based on whether or not the collapsible element has been opened or closed (via JavaScript, or because the user triggered another control element also tied to the same collapsible element). If the control element’s HTML element is not a button (e.g., an `` or `
`), the attribute `role="button"` should be added to the element. + +If your control element is targeting a single collapsible element – i.e. the `data-bs-target` attribute is pointing to an `id` selector – you should add the `aria-controls` attribute to the control element, containing the `id` of the collapsible element. Modern screen readers and similar assistive technologies make use of this attribute to provide users with additional shortcuts to navigate directly to the collapsible element itself. + +Note that Bootstrap’s current implementation does not cover the various *optional* keyboard interactions described in the [ARIA Authoring Practices Guide accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) - you will need to include these yourself with custom JavaScript. + +## CSS + +### Sass variables + + + +### Classes + +Collapse transition classes can be found in `scss/_transitions.scss` as these are shared across multiple components (collapse and accordion). + + + +## Usage + +The collapse plugin utilizes a few classes to handle the heavy lifting: + +- `.collapse` hides the content +- `.collapse.show` shows the content +- `.collapsing` is added when the transition starts, and removed when it finishes + +These classes can be found in `_transitions.scss`. + +### Via data attributes + +Just add `data-bs-toggle="collapse"` and a `data-bs-target` to the element to automatically assign control of one or more collapsible elements. The `data-bs-target` attribute accepts a CSS selector to apply the collapse to. Be sure to add the class `collapse` to the collapsible element. If you’d like it to default open, add the additional class `show`. + +To add accordion-like group management to a collapsible area, add the data attribute `data-bs-parent="#selector"`. Refer to the [accordion page]([[docsref:/components/accordion]]) for more information. + +### Via JavaScript + +Enable manually with: + +```js +const collapseElementList = document.querySelectorAll('.collapse') +const collapseList = [...collapseElementList].map(collapseEl => new bootstrap.Collapse(collapseEl)) +``` + +### Options + + + + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +`parent` | selector, DOM element | `null` | If parent is provided, then all collapsible elements under the specified parent will be closed when this collapsible item is shown. (similar to traditional accordion behavior - this is dependent on the `card` class). The attribute has to be set on the target collapsible area. | +`toggle` | boolean | `true` | Toggles the collapsible element on invocation. | + + +### Methods + + + +Activates your content as a collapsible element. Accepts an optional options `object`. + +You can create a collapse instance with the constructor, for example: + +```js +const bsCollapse = new bootstrap.Collapse('#myCollapse', { + toggle: false +}) +``` + + +| Method | Description | +| --- | --- | +| `dispose` | Destroys an element’s collapse. (Removes stored data on the DOM element) | +| `getInstance` | Static method which allows you to get the collapse instance associated to a DOM element, you can use it like this: `bootstrap.Collapse.getInstance(element)`. | +| `getOrCreateInstance` | Static method which returns a collapse instance associated to a DOM element or create a new one in case it wasn’t initialized. You can use it like this: `bootstrap.Collapse.getOrCreateInstance(element)`. | +| `hide` | Hides a collapsible element. **Returns to the caller before the collapsible element has actually been hidden** (e.g., before the `hidden.bs.collapse` event occurs). | +| `show` | Shows a collapsible element. **Returns to the caller before the collapsible element has actually been shown** (e.g., before the `shown.bs.collapse` event occurs). | +| `toggle` | Toggles a collapsible element to shown or hidden. **Returns to the caller before the collapsible element has actually been shown or hidden** (i.e. before the `shown.bs.collapse` or `hidden.bs.collapse` event occurs). | + + +### Events + +Bootstrap’s collapse class exposes a few events for hooking into collapse functionality. + + +| Event type | Description | +| --- | --- | +| `hide.bs.collapse` | This event is fired immediately when the `hide` method has been called. | +| `hidden.bs.collapse` | This event is fired when a collapse element has been hidden from the user (will wait for CSS transitions to complete). | +| `show.bs.collapse` | This event fires immediately when the `show` instance method is called. | +| `shown.bs.collapse` | This event is fired when a collapse element has been made visible to the user (will wait for CSS transitions to complete). | + + +```js +const myCollapsible = document.getElementById('myCollapsible') +myCollapsible.addEventListener('hidden.bs.collapse', event => { + // do something... +}) +``` diff --git a/site/src/content/docs/components/dropdowns.mdx b/site/src/content/docs/components/dropdowns.mdx new file mode 100644 index 000000000000..32431c2db8fe --- /dev/null +++ b/site/src/content/docs/components/dropdowns.mdx @@ -0,0 +1,1065 @@ +--- +title: Dropdowns +description: Toggle contextual overlays for displaying lists of links and more with the Bootstrap dropdown plugin. +toc: true +--- + +## Overview + +Dropdowns are toggleable, contextual overlays for displaying lists of links and more. They’re made interactive with the included Bootstrap dropdown JavaScript plugin. They’re toggled by clicking, not by hovering; this is [an intentional design decision](https://markdotto.com/blog/bootstrap-explained-dropdowns/). + +Dropdowns are built on a third party library, [Popper](https://popper.js.org/docs/v2/), which provides dynamic positioning and viewport detection. Be sure to include [popper.min.js]([[config:cdn.popper]]) before Bootstrap’s JavaScript or use `bootstrap.bundle.min.js` / `bootstrap.bundle.js` which contains Popper. Popper isn’t used to position dropdowns in navbars though as dynamic positioning isn’t required. + +## Accessibility + +The [WAI ARIA](https://www.w3.org/TR/wai-aria/) standard defines an actual [`role="menu"` widget](https://www.w3.org/TR/wai-aria/#menu), but this is specific to application-like menus which trigger actions or functions. ARIA menus can only contain menu items, checkbox menu items, radio button menu items, radio button groups, and sub-menus. + +Bootstrap’s dropdowns, on the other hand, are designed to be generic and applicable to a variety of situations and markup structures. For instance, it is possible to create dropdowns that contain additional inputs and form controls, such as search fields or login forms. For this reason, Bootstrap does not expect (nor automatically add) any of the `role` and `aria-` attributes required for true ARIA menus. Authors will have to include these more specific attributes themselves. + +However, Bootstrap does add built-in support for most standard keyboard menu interactions, such as the ability to move through individual `.dropdown-item` elements using the cursor keys and close the menu with the Esc key. + +## Examples + +Wrap the dropdown’s toggle (your button or link) and the dropdown menu within `.dropdown`, or another element that declares `position: relative;`. Ideally, you should use a ` + +
`} /> + +While ` + + + + + + + `} /> + +```html + + +``` + +### Split button + +Similarly, create split button dropdowns with virtually the same markup as single button dropdowns, but with the addition of `.dropdown-toggle-split` for proper spacing around the dropdown caret. + +We use this extra class to reduce the horizontal `padding` on either side of the caret by 25% and remove the `margin-left` that’s added for regular button dropdowns. Those extra changes keep the caret centered in the split button and provide a more appropriately sized hit area next to the main button. + + + + + + +
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
`} /> + +```html + +
+ + + +
+``` + +## Sizing + +Button dropdowns work with buttons of all sizes, including default and split dropdown buttons. + + + + + +
+ + + +
`} /> + +```html + +
+ + +
+
+ + + +
+``` + + + + + +
+ + + +
`} /> + +```html +
+ + +
+
+ + + +
+``` + +## Dark dropdowns + + + +Opt into darker dropdowns to match a dark navbar or custom style by adding `.dropdown-menu-dark` onto an existing `.dropdown-menu`. No changes are required to the dropdown items. + + + + + + + `} /> + +And putting it to use in a navbar: + + +
+ Navbar + + +
+ `} /> + +## Directions + + +**Directions are flipped in RTL mode.** As such, `.dropstart` will appear on the right side. + + +### Centered + +Make the dropdown menu centered below the toggle with `.dropdown-center` on the parent element. + + + + + `} /> + +### Dropup + +Trigger dropdown menus above elements by adding `.dropup` to the parent element. + + + + + +
+ + + +
`} /> + +```html + +
+ + +
+ + +
+ + + +
+``` + +### Dropup centered + +Make the dropup menu centered above the toggle with `.dropup-center` on the parent element. + + + + + `} /> + +### Dropend + +Trigger dropdown menus at the right of the elements by adding `.dropend` to the parent element. + + + + + +
+ + + +
`} /> + +```html + +
+ + +
+ + +
+ + + +
+``` + +### Dropstart + +Trigger dropdown menus at the left of the elements by adding `.dropstart` to the parent element. + + + + + +
+ + + +
`} /> + +```html + +
+ + +
+ + +
+ + + +
+``` + +## Menu items + +You can use `` or ` + + `} /> + +You can also create non-interactive dropdown items with `.dropdown-item-text`. Feel free to style further with custom CSS or text utilities. + + +
  • Dropdown item text
  • +
  • Action
  • +
  • Another action
  • +
  • Something else here
  • + `} /> + +### Active + +Add `.active` to items in the dropdown to **style them as active**. To convey the active state to assistive technologies, use the `aria-current` attribute — using the `page` value for the current page, or `true` for the current item in a set. + + +
  • Regular link
  • +
  • Active link
  • +
  • Another link
  • + `} /> + +### Disabled + +Add `.disabled` to items in the dropdown to **style them as disabled**. + + +
  • Regular link
  • +
  • Disabled link
  • +
  • Another link
  • + `} /> + +## Menu alignment + +By default, a dropdown menu is automatically positioned 100% from the top and along the left side of its parent. You can change this with the directional `.drop*` classes, but you can also control them with additional modifier classes. + +Add `.dropdown-menu-end` to a `.dropdown-menu` to right align the dropdown menu. Directions are mirrored when using Bootstrap in RTL, meaning `.dropdown-menu-end` will appear on the left side. + + +**Heads up!** Dropdowns are positioned thanks to Popper except when they are contained in a navbar. + + + + + + `} /> + +### Responsive alignment + +If you want to use responsive alignment, disable dynamic positioning by adding the `data-bs-display="static"` attribute and use the responsive variation classes. + +To align **right** the dropdown menu with the given breakpoint or larger, add `.dropdown-menu{-sm|-md|-lg|-xl|-xxl}-end`. + + + + + `} /> + +To align **left** the dropdown menu with the given breakpoint or larger, add `.dropdown-menu-end` and `.dropdown-menu{-sm|-md|-lg|-xl|-xxl}-start`. + + + + + `} /> + +Note that you don’t need to add a `data-bs-display="static"` attribute to dropdown buttons in navbars, since Popper isn’t used in navbars. + +### Alignment options + +Taking most of the options shown above, here’s a small kitchen sink demo of various dropdown alignment options in one place. + + + + + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    `} /> + +## Menu content + +### Headers + +Add a header to label sections of actions in any dropdown menu. + + +
  • +
  • Action
  • +
  • Another action
  • + `} /> + +### Dividers + +Separate groups of related menu items with a divider. + + +
  • Action
  • +
  • Another action
  • +
  • Something else here
  • +
  • +
  • Separated link
  • + `} /> + +### Text + +Place any freeform text within a dropdown menu with text and use [spacing utilities]([[docsref:/utilities/spacing]]). Note that you’ll likely need additional sizing styles to constrain the menu width. + + +

    + Some example text that’s free-flowing within the dropdown menu. +

    +

    + And this is more example text. +

    + `} /> + +### Forms + +Put a form within a dropdown menu, or make it into a dropdown menu, and use [margin or padding utilities]([[docsref:/utilities/spacing]]) to give it the negative space you require. + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + +
    + + New around here? Sign up + Forgot password? + `} /> + + + + + `} /> + +## Dropdown options + +Use `data-bs-offset` or `data-bs-reference` to change the location of the dropdown. + + + +
    + + + +
    + `} /> + +### Auto close behavior + +By default, the dropdown menu is closed when clicking inside or outside the dropdown menu. You can use the `autoClose` option to change this behavior of the dropdown. + + + + + + +
    + + +
    + +
    + + +
    + +
    + + +
    `} /> + +## CSS + +### Variables + + + +As part of Bootstrap’s evolving CSS variables approach, dropdowns now use local CSS variables on `.dropdown-menu` for enhanced real-time customization. Values for the CSS variables are set via Sass, so Sass customization is still supported, too. + + + + +Dropdown items include at least one variable that is not set on `.dropdown`. This allows you to provide a new value while Bootstrap defaults to a fallback value. + +- `--bs-dropdown-item-border-radius` + + +Customization through CSS variables can be seen on the `.dropdown-menu-dark` class where we override specific values without adding duplicate CSS selectors. + + + +### Sass variables + +Variables for all dropdowns: + + + +Variables for the [dark dropdown](#dark-dropdowns): + + + +Variables for the CSS-based carets that indicate a dropdown’s interactivity: + + + +### Sass mixins + +Mixins are used to generate the CSS-based carets and can be found in `scss/mixins/_caret.scss`. + + + +## Usage + +Via data attributes or JavaScript, the dropdown plugin toggles hidden content (dropdown menus) by toggling the `.show` class on the parent `.dropdown-menu`. The `data-bs-toggle="dropdown"` attribute is relied on for closing dropdown menus at an application level, so it’s a good idea to always use it. + + +On touch-enabled devices, opening a dropdown adds empty `mouseover` handlers to the immediate children of the `` element. This admittedly ugly hack is necessary to work around a [quirk in iOs’ event delegation](https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html), which would otherwise prevent a tap anywhere outside of the dropdown from triggering the code that closes the dropdown. Once the dropdown is closed, these additional empty `mouseover` handlers are removed. + + +### Via data attributes + +Add `data-bs-toggle="dropdown"` to a link or button to toggle a dropdown. + +```html + +``` + +### Via JavaScript + + +Dropdowns must have `data-bs-toggle="dropdown"` on their trigger element, regardless of whether you call your dropdown via JavaScript or use the data-api. + + +Call the dropdowns via JavaScript: + +```js +const dropdownElementList = document.querySelectorAll('.dropdown-toggle') +const dropdownList = [...dropdownElementList].map(dropdownToggleEl => new bootstrap.Dropdown(dropdownToggleEl)) +``` + +### Options + + + + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `autoClose` | boolean, string | `true` | Configure the auto close behavior of the dropdown:
    • `true` - the dropdown will be closed by clicking outside or inside the dropdown menu.
    • `false` - the dropdown will be closed by clicking the toggle button and manually calling `hide` or `toggle` method. (Also will not be closed by pressing Esc key)
    • `'inside'` - the dropdown will be closed (only) by clicking inside the dropdown menu.
    • `'outside'` - the dropdown will be closed (only) by clicking outside the dropdown menu.
    Note: the dropdown can always be closed with the Esc key. | +| `boundary` | string, element | `'clippingParents'` | Overflow constraint boundary of the dropdown menu (applies only to Popper’s preventOverflow modifier). By default it’s `clippingParents` and can accept an HTMLElement reference (via JavaScript only). For more information refer to Popper’s [detectOverflow docs](https://popper.js.org/docs/v2/utils/detect-overflow/#boundary). | +| `display` | string | `'dynamic'` | By default, we use Popper for dynamic positioning. Disable this with `static`. | +| `offset` | array, string, function | `[0, 2]` | Offset of the dropdown relative to its target. You can pass a string in data attributes with comma separated values like: `data-bs-offset="10,20"`. When a function is used to determine the offset, it is called with an object containing the popper placement, the reference, and popper rects as its first argument. The triggering element DOM node is passed as the second argument. The function must return an array with two numbers: [skidding](https://popper.js.org/docs/v2/modifiers/offset/#skidding-1), [distance](https://popper.js.org/docs/v2/modifiers/offset/#distance-1). For more information refer to Popper’s [offset docs](https://popper.js.org/docs/v2/modifiers/offset/#options). | +| `popperConfig` | null, object, function | `null` | To change Bootstrap’s default Popper config, see [Popper’s configuration](https://popper.js.org/docs/v2/constructors/#options). When a function is used to create the Popper configuration, it’s called with an object that contains the Bootstrap’s default Popper configuration. It helps you use and merge the default with your own configuration. The function must return a configuration object for Popper. | +| `reference` | string, element, object | `'toggle'` | Reference element of the dropdown menu. Accepts the values of `'toggle'`, `'parent'`, an HTMLElement reference or an object providing `getBoundingClientRect`. For more information refer to Popper’s [constructor docs](https://popper.js.org/docs/v2/constructors/#createpopper) and [virtual element docs](https://popper.js.org/docs/v2/virtual-elements/). | +
    + +#### Using function with `popperConfig` + +```js +const dropdown = new bootstrap.Dropdown(element, { + popperConfig(defaultBsPopperConfig) { + // const newPopperConfig = {...} + // use defaultBsPopperConfig if needed... + // return newPopperConfig + } +}) +``` + +### Methods + + +| Method | Description | +| --- | --- | +| `dispose` | Destroys an element’s dropdown. (Removes stored data on the DOM element) | +| `getInstance` | Static method which allows you to get the dropdown instance associated to a DOM element, you can use it like this: `bootstrap.Dropdown.getInstance(element)`. | +| `getOrCreateInstance` | Static method which returns a dropdown instance associated to a DOM element or create a new one in case it wasn’t initialized. You can use it like this: `bootstrap.Dropdown.getOrCreateInstance(element)`. | +| `hide` | Hides the dropdown menu of a given navbar or tabbed navigation. | +| `show` | Shows the dropdown menu of a given navbar or tabbed navigation. | +| `toggle` | Toggles the dropdown menu of a given navbar or tabbed navigation. | +| `update` | Updates the position of an element’s dropdown. | + + +### Events + +All dropdown events are fired at the toggling element and then bubbled up. So you can also add event listeners on the `.dropdown-menu`’s parent element. `hide.bs.dropdown` and `hidden.bs.dropdown` events have a `clickEvent` property (only when the original Event type is `click`) that contains an Event Object for the click event. + + +| Event type | Description | +| --- | --- | +| `hide.bs.dropdown` | Fires immediately when the `hide` instance method has been called. | +| `hidden.bs.dropdown` | Fired when the dropdown has finished being hidden from the user and CSS transitions have completed. | +| `show.bs.dropdown` | Fires immediately when the `show` instance method is called. | +| `shown.bs.dropdown` | Fired when the dropdown has been made visible to the user and CSS transitions have completed. | + + +```js +const myDropdown = document.getElementById('myDropdown') +myDropdown.addEventListener('show.bs.dropdown', event => { + // do something... +}) +``` diff --git a/site/src/content/docs/components/list-group.mdx b/site/src/content/docs/components/list-group.mdx new file mode 100644 index 000000000000..59827ddd41bb --- /dev/null +++ b/site/src/content/docs/components/list-group.mdx @@ -0,0 +1,448 @@ +--- +title: List group +description: List groups are a flexible and powerful component for displaying a series of content. Modify and extend them to support just about any content within. +toc: true +--- + +import { getData } from '@libs/data' + +## Basic example + +The most basic list group is an unordered list with list items and the proper classes. Build upon it with the options that follow, or with your own CSS as needed. + + +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
  • A fourth item
  • +
  • And a fifth one
  • + `} /> + +## Active items + +Add `.active` to a `.list-group-item` to indicate the current active selection. + + +
  • An active item
  • +
  • A second item
  • +
  • A third item
  • +
  • A fourth item
  • +
  • And a fifth one
  • + `} /> + +## Links and buttons + +Use ``s or ` + + + + + `} /> + +## Flush + +Add `.list-group-flush` to remove some borders and rounded corners to render list group items edge-to-edge in a parent container (e.g., cards). + + +
  • An item
  • +
  • A second item
  • +
  • A third item
  • +
  • A fourth item
  • +
  • And a fifth one
  • + `} /> + +## Numbered + +Add the `.list-group-numbered` modifier class (and optionally use an `
      ` element) to opt into numbered list group items. Numbers are generated via CSS (as opposed to a `
        `s default browser styling) for better placement inside list group items and to allow for better customization. + +Numbers are generated by `counter-reset` on the `
          `, and then styled and placed with a `::before` pseudo-element on the `
        1. ` with `counter-increment` and `content`. + + +
        2. A list item
        3. +
        4. A list item
        5. +
        6. A list item
        7. +
        `} /> + +These work great with custom content as well. + + +
      1. +
        +
        Subheading
        + Content for list item +
        + 14 +
      2. +
      3. +
        +
        Subheading
        + Content for list item +
        + 14 +
      4. +
      5. +
        +
        Subheading
        + Content for list item +
        + 14 +
      6. +
      `} /> + +## Horizontal + +Add `.list-group-horizontal` to change the layout of list group items from vertical to horizontal across all breakpoints. Alternatively, choose a responsive variant `.list-group-horizontal-{sm|md|lg|xl|xxl}` to make a list group horizontal starting at that breakpoint’s `min-width`. Currently **horizontal list groups cannot be combined with flush list groups.** + +**ProTip:** Want equal-width list group items when horizontal? Add `.flex-fill` to each list group item. + + `
        +
      • An item
      • +
      • A second item
      • +
      • A third item
      • +
      `)} /> + +## Variants + + +**Heads up!** As of v5.3.0, the `list-group-item-variant()` Sass mixin is deprecated. List group item variants now have their CSS variables overridden in [a Sass loop](#sass-loops). + + +Use contextual classes to style list items with a stateful background and color. + + +
    1. A simple default list group item
    2. + `, + ...getData('theme-colors').map((themeColor) => `
    3. A simple ${themeColor.name} list group item
    4. `), + `` + ]} /> + +### For links and buttons + +Contextual classes also work with `.list-group-item-action` for `
      ` and ` + + + + + + + + +```html + +``` + + +In the above static example, we use `
      `, to avoid issues with the heading hierarchy in the documentation page. Structurally, however, a modal dialog represents its own separate document/context, so the `.modal-title` should ideally be an `

      `. If necessary, you can use the [font size utilities]([[docsref:/utilities/text#font-size]]) to control the heading’s appearance. All the following live examples use this approach. + + +### Live demo + +Toggle a working modal demo by clicking the button below. It will slide down and fade in from the top of the page. + + + +
      + +
      + +```html + + + + + +``` + +### Static backdrop + +When backdrop is set to static, the modal will not close when clicking outside of it. Click the button below to try it. + + + +
      + +
      + +```html + + + + + +``` + +### Scrolling long content + +When modals become too long for the user’s viewport or device, they scroll independent of the page itself. Try the demo below to see what we mean. + + + +
      + +
      + +You can also create a scrollable modal that allows scrolling the modal body by adding `.modal-dialog-scrollable` to `.modal-dialog`. + + + +
      + +
      + +```html + + +``` + +### Vertically centered + +Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal. + +