diff --git a/.github/workflows/broken-links-check.yml b/.github/workflows/broken-links-check.yml index 1bced87a9..f43277e2c 100644 --- a/.github/workflows/broken-links-check.yml +++ b/.github/workflows/broken-links-check.yml @@ -6,7 +6,7 @@ on: jobs: broken_link_check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 name: Check react-ui.io for broken links steps: - name: Check for broken links diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b375c1090..02a7aa0e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: build: name: Build distribution CSS and JS - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: matrix: node: [ 20, 22 ] diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f41c6e6a0..b72003019 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -23,7 +23,7 @@ on: jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: # required for all workflows diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4ad5b324e..d2216abb8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,7 +8,7 @@ permissions: jobs: build: name: Build Docs - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/.github/workflows/external-links-check.yml b/.github/workflows/external-links-check.yml index 92344590e..c7378df9a 100644 --- a/.github/workflows/external-links-check.yml +++ b/.github/workflows/external-links-check.yml @@ -6,7 +6,7 @@ on: jobs: broken_link_check: name: Markdown link check - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: gaurav-nelson/github-action-markdown-link-check@v1 diff --git a/.github/workflows/git.yml b/.github/workflows/git.yml index d1d25a465..855d0a7ea 100644 --- a/.github/workflows/git.yml +++ b/.github/workflows/git.yml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: block-merge-with-autosquash-commits: name: Block merge with autosquash commits - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Block merge with autosquash commits diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 672a5af7e..06db22543 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,7 @@ on: [ pull_request ] jobs: lint: name: Lint - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/.github/workflows/pull-request-meta.yml b/.github/workflows/pull-request-meta.yml index 98edccca6..98175f98f 100644 --- a/.github/workflows/pull-request-meta.yml +++ b/.github/workflows/pull-request-meta.yml @@ -7,7 +7,7 @@ on: jobs: process_pr_meta: name: Process PR meta - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Assign to author uses: kentaro-m/auto-assign-action@v2.0.0 # Specify also the minor version because v2 does not exist diff --git a/.github/workflows/release-management.yml b/.github/workflows/release-management.yml index 83e304819..d1895b1e6 100644 --- a/.github/workflows/release-management.yml +++ b/.github/workflows/release-management.yml @@ -7,7 +7,7 @@ on: jobs: test_and_build: name: Test and build - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 outputs: version: ${{ steps.check_package_version.outputs.version }} version_changed: ${{ steps.check_package_version.outputs.changed }} @@ -51,7 +51,7 @@ jobs: contents: write needs: [test_and_build] if: needs.test_and_build.outputs.version_changed == 'false' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Draft release on GitHub uses: release-drafter/release-drafter@v6 @@ -62,7 +62,7 @@ jobs: name: Publish release draft needs: [test_and_build] if: needs.test_and_build.outputs.version_changed == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 @@ -86,7 +86,7 @@ jobs: name: Publish to npm needs: [test_and_build, publish_release_draft_on_version_bump] if: needs.test_and_build.outputs.version_changed == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 @@ -112,7 +112,7 @@ jobs: contents: write needs: [test_and_build, publish_release_draft_on_version_bump] if: needs.test_and_build.outputs.version_changed == 'true' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 082adc4c6..5a94a3745 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: name: Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Clone repository uses: actions/checkout@v4 diff --git a/README.md b/README.md index fc5c24999..59555147b 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,7 @@ To install React UI in your app: npm install --save @react-ui-org/react-ui ``` -2. Load the Titillium Web font: - - ```html - - ``` - -3. Load React UI CSS in your app: +2. Load React UI CSS in your app: @@ -73,7 +64,7 @@ To install React UI in your app: -4. Import and use any of React UI components in your app: +3. Import and use any of React UI components in your app: ```jsx import { Button } from '@react-ui-org/react-ui'; diff --git a/RELEASING.md b/RELEASING.md index baf2eb577..3d4bb3496 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,9 +12,9 @@ In order for the automation to work properly, contributors must follow the 1. Pull requests are automatically labelled by branch name. Labels are then used for: - 1. resolving next [semantic version number][semver] - (BREAKING.FEATURE.PATCH), - 2. grouping changes in changelog. + 1. resolving next [semantic version number][semver] + (BREAKING.FEATURE.PATCH), + 2. grouping changes in changelog. 2. Release draft with changelog is generated as pull requests are merged into the `master` branch. Invididual PR names are listed and grouped by type based @@ -30,35 +30,36 @@ See the source of `.github/workflows` for details. [GitHub releases page][gh-releases] to see what the changelog looks like and what will be the next version number. - **Don't edit manually until you are ready to publish the release.** Release - draft is automatically overwritten everytime a change is merged to `master`. + **Don't edit manually until you are ready to publish the release.** Release + draft is automatically overwritten everytime a change is merged to `master`. 2. **Manual:** once you are ready to publish a release: - 1. **Bump the version number** in `package.json` and `package-lock.json`. - Make sure it matches the intended version number in the release draft. - **Don't combine this step with any other changes,** they wouldn't be - reflected in the changelog. + 1. **Bump the version number** in `package.json` and `package-lock.json`. + Make sure it matches the intended version number in the release draft. + **Don't combine this step with any other changes,** they wouldn't be + reflected in the changelog. - 2. Now is also your **chance to review and adjust (if necessary) the intended - version and actual changelog before the release is published.** - Automatic release drafting is skipped when a version change in - `package.json` is detected so this time your changes will not be - overwritten. Save your changes in release draft with the _Save draft_ - button, **do not publish** yet! + 2. Now is also your **chance to review and adjust (if necessary) the + intended + version and actual changelog before the release is published.** + Automatic release drafting is skipped when a version change in + `package.json` is detected so this time your changes will not be + overwritten. Save your changes in release draft with the _Save draft_ + button, **do not publish** yet! - 3. Get back to the repository, commit both files as - `Bump version to ` in `release/` branch, - create a pull request, hold your breath, and—merge it. + 3. Get back to the repository, commit both files as + `Bump version to ` in `release/` branch, + create a pull request, hold your breath, and—merge it. 3. **Automatic:** once the release pull request from step 2.3 is merged, the following actions are triggered automatically: - 1. GitHub release draft with name corresponding to the version number from - step 2 is published. - 2. Git tag with the version number from step 2 is added to `master` branch. - 3. Package is built and published to npm package registry. - 4. Documentation is built and deployed to production. + 1. GitHub release draft with name corresponding to the version number from + step 2 is published. + 2. Git tag with the version number from step 2 is added to `master` branch. + 3. Package is built and published to npm package registry. + 4. Documentation is built and deployed to production. **Note:** prefix version number with `v` everywhere except in `package.json` and `package-lock.json`. diff --git a/docker/node/Dockerfile b/docker/node/Dockerfile index 0cc062167..ff1346e36 100644 --- a/docker/node/Dockerfile +++ b/docker/node/Dockerfile @@ -1,3 +1,3 @@ -FROM node:20 +FROM node:22 RUN mkdir /workspace WORKDIR /workspace diff --git a/jest.config.js b/jest.config.js index c07a0aba6..548c153ec 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,7 +13,7 @@ module.exports = { setupFilesAfterEnv: [ '/tests/setupTestingLibrary.js', ], - testEnvironment: 'jsdom', + testEnvironment: '@happy-dom/jest-environment', transformIgnorePatterns: [ 'node_modules/(?!(@react-ui-org))', ], diff --git a/mkdocs.yml b/mkdocs.yml index e07315fd2..6c87a7896 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,8 +29,6 @@ theme: language: 'en' favicon: 'favicon.ico' custom_dir: 'src/docs/_overrides' - font: - text: 'Titillium Web' palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -131,10 +129,11 @@ nav: - Spacing: 'docs/css-helpers/spacing.md' - Typography: 'docs/css-helpers/typography.md' - JS Helpers: - - Classnames: 'docs/js-helpers/classnames.md' - - Transferring Props: 'docs/js-helpers/transferProps.md' + - ClassNames: 'helpers/classNames/README.md' + - TransferProps: 'helpers/transferProps/README.md' - Guides: - Customize: + - Font: 'docs/customize/font.md' - Theming: - Overview: 'docs/customize/theming/overview.md' - Forms: 'docs/customize/theming/forms.md' diff --git a/package-lock.json b/package-lock.json index b3d2903e8..d9e78e4fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@react-ui-org/react-ui", - "version": "0.58.0", + "version": "0.59.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@react-ui-org/react-ui", - "version": "0.58.0", + "version": "0.59.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -21,10 +21,11 @@ "@babel/preset-env": "^7.24.7", "@babel/preset-react": "^7.24.7", "@babel/register": "^7.24.6", + "@happy-dom/jest-environment": "^16.6.0", "@stylistic/stylelint-config": "^1.0.1", "@svgr/webpack": "^8.1.0", - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@visionappscz/eslint-config-visionapps": "^1.7.0", "@visionappscz/stylelint-config": "^4.0.0", @@ -42,7 +43,6 @@ "eslint-plugin-react-hooks": "^4.6.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", "markdownlint-cli2": "^0.13.0", "mini-css-extract-plugin": "^2.9.0", "postcss": "^8.4.39", @@ -2231,6 +2231,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@happy-dom/jest-environment": { + "version": "16.6.0", + "resolved": "https://registry.npmjs.org/@happy-dom/jest-environment/-/jest-environment-16.6.0.tgz", + "integrity": "sha512-uf47TEf1eL2oiDf9aBPtb4OjR7hoCyHoGLr00W56KlrkV+KwskIcmf5b7dzlNtI9MBSbW33DK/BYQKYdGlF0Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.4.0", + "@jest/fake-timers": "^29.4.0", + "@jest/types": "^29.4.0", + "happy-dom": "^16.6.0", + "jest-mock": "^29.4.0", + "jest-util": "^29.4.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -3578,13 +3596,13 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.4.8", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.8.tgz", - "integrity": "sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", "dev": true, + "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", - "@babel/runtime": "^7.9.2", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", @@ -3672,10 +3690,11 @@ } }, "node_modules/@testing-library/react": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.0.tgz", - "integrity": "sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==", + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz", + "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -3684,10 +3703,10 @@ }, "peerDependencies": { "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3703,6 +3722,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12", "npm": ">=6" @@ -3711,15 +3731,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -3836,17 +3847,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3883,12 +3883,6 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true - }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", @@ -4150,13 +4144,6 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true - }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -4169,16 +4156,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, "node_modules/acorn-import-attributes": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", @@ -4197,30 +4174,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", - "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", - "dev": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4526,12 +4479,6 @@ "node": ">=8" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, "node_modules/autoprefixer": { "version": "10.4.19", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", @@ -4607,6 +4554,7 @@ "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", @@ -5272,18 +5220,6 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -5644,30 +5580,6 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "dev": true }, - "node_modules/cssom": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "dev": true - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "dev": true, - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - }, "node_modules/d3": { "version": "3.5.17", "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", @@ -5680,20 +5592,6 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "dev": true, - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -5762,12 +5660,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dev": true - }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -5863,15 +5755,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5956,19 +5839,6 @@ } ] }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -6289,27 +6159,6 @@ "node": ">=0.8.0" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", @@ -7186,20 +7035,6 @@ "is-callable": "^1.1.3" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7526,6 +7361,20 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/happy-dom": { + "version": "16.6.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-16.6.0.tgz", + "integrity": "sha512-Zz5S9sog8a3p8XYZbO+eI1QMOAvCNnIoyrH8A8MLX+X2mJrzADTy+kdETmc4q+uD9AGAvQYGn96qBAn2RAciKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/harmony-reflect": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", @@ -7613,18 +7462,6 @@ "node": ">= 0.4" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dev": true, - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7643,33 +7480,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -7679,18 +7489,6 @@ "node": ">=10.17.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -8179,12 +7977,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true - }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -8478,6 +8270,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9161,33 +8954,6 @@ "node": ">=8" } }, - "node_modules/jest-environment-jsdom": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", - "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0", - "jsdom": "^20.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -10415,51 +10181,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "dev": true, - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -11153,12 +10874,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", - "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==", - "dev": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11426,18 +11141,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11931,12 +11634,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11971,12 +11668,6 @@ } ] }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12203,12 +11894,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -12360,12 +12045,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, "node_modules/sass": { "version": "1.77.8", "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", @@ -12423,18 +12102,6 @@ } } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -13443,12 +13110,6 @@ "node": ">= 10" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true - }, "node_modules/table": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", @@ -13657,33 +13318,6 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -13921,15 +13555,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", @@ -13969,16 +13594,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13999,18 +13614,6 @@ "node": ">=10.12.0" } }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dev": true, - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -14237,18 +13840,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -14258,19 +13849,6 @@ "node": ">=12" } }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14449,42 +14027,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index bae0e39ac..d1c1faccf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@react-ui-org/react-ui", "description": "React UI is a themeable UI library for React apps.", - "version": "0.58.0", + "version": "0.59.0", "keywords": [ "react", "ui", @@ -56,7 +56,7 @@ "precopy": "rm -rf dist && mkdir dist", "prepublishOnly": "npm run build", "start": "webpack --watch --mode=development", - "stylelint": "stylelint \"src/**/*.scss\" --config stylelint.config.js", + "stylelint": "stylelint \"src/**/*.{css,scss}\" \"!src/docs/_assets/generated/**\" --config stylelint.config.js", "test": "npm run jest" }, "dependencies": { @@ -76,10 +76,11 @@ "@babel/preset-env": "^7.24.7", "@babel/preset-react": "^7.24.7", "@babel/register": "^7.24.6", + "@happy-dom/jest-environment": "^16.6.0", "@stylistic/stylelint-config": "^1.0.1", "@svgr/webpack": "^8.1.0", - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", "@visionappscz/eslint-config-visionapps": "^1.7.0", "@visionappscz/stylelint-config": "^4.0.0", @@ -97,7 +98,6 @@ "eslint-plugin-react-hooks": "^4.6.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", "markdownlint-cli2": "^0.13.0", "mini-css-extract-plugin": "^2.9.0", "postcss": "^8.4.39", diff --git a/src/components/Alert/Alert.jsx b/src/components/Alert/Alert.jsx index fd74236dd..fa25e4853 100644 --- a/src/components/Alert/Alert.jsx +++ b/src/components/Alert/Alert.jsx @@ -2,8 +2,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; import { TranslationsContext } from '../../providers/translations'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootColorClassName } from '../_helpers/getRootColorClassName'; import styles from './Alert.module.scss'; @@ -69,9 +69,9 @@ Alert.propTypes = { children: PropTypes.node.isRequired, /** * Color variant to clarify importance and meaning of the alert. Implements - * [Feedback and Neutral color collections](/docs/foundation/collections#colors). + * [Feedback color collection](/docs/foundation/collections#colors). */ - color: PropTypes.oneOf(['success', 'warning', 'danger', 'help', 'info', 'note', 'light', 'dark']), + color: PropTypes.oneOf(['success', 'warning', 'danger', 'help', 'info', 'note']), /** * Optional element to be displayed next to the alert body. */ diff --git a/src/components/Alert/README.md b/src/components/Alert/README.md index 7b5e2dfa9..d431f3fd5 100644 --- a/src/components/Alert/README.md +++ b/src/components/Alert/README.md @@ -111,32 +111,6 @@ Neutral informative alert. ``` -### Light - -Light alert variant. - -```docoff-react-preview - - - Light alert: Stands out on dark backgrounds. - {' '} - - - -``` - -### Dark - -Dark alert variant. - -```docoff-react-preview - - Dark alert: Stands out on light backgrounds. - {' '} - - -``` - ## Alerts with Icons An icon can (and should) accompany the message. diff --git a/src/components/Alert/__tests__/Alert.test.jsx b/src/components/Alert/__tests__/Alert.test.jsx index 591cca145..1f58f671e 100644 --- a/src/components/Alert/__tests__/Alert.test.jsx +++ b/src/components/Alert/__tests__/Alert.test.jsx @@ -6,7 +6,6 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { feedbackColorPropTest } from '../../../../tests/propTests/feedbackColorPropTest'; -import { neutralColorPropTest } from '../../../../tests/propTests/neutralColorPropTest'; import defaultTranslations from '../../../translations/en'; import { Alert } from '../Alert'; @@ -22,7 +21,6 @@ describe('rendering', () => { (rootElement) => expect(within(rootElement).getByText('content text')), ], ...feedbackColorPropTest, - ...neutralColorPropTest, [ { icon: (icon) }, (rootElement) => expect(within(rootElement).getByText('icon')), diff --git a/src/components/Alert/_settings.scss b/src/components/Alert/_settings.scss index b7ab4487e..e9b852fd6 100644 --- a/src/components/Alert/_settings.scss +++ b/src/components/Alert/_settings.scss @@ -1,4 +1,3 @@ -@use "sass:list"; @use "sass:map"; @use "../../styles/settings/collections"; @use "../../styles/theme/typography"; @@ -8,5 +7,5 @@ $font-size: map.get(typography.$font-size-values, 1); $line-height: typography.$line-height-base; $min-height: calc(#{$font-size} * #{$line-height} + 2 * #{theme.$padding}); -$colors: list.join(collections.$feedback-colors, collections.$neutral-colors); +$colors: collections.$feedback-colors; $themeable-properties: color, foreground-color, background-color; diff --git a/src/components/Badge/Badge.jsx b/src/components/Badge/Badge.jsx index aa887a5ad..67ccda582 100644 --- a/src/components/Badge/Badge.jsx +++ b/src/components/Badge/Badge.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootColorClassName } from '../_helpers/getRootColorClassName'; import { getRootPriorityClassName } from '../_helpers/getRootPriorityClassName'; import styles from './Badge.module.scss'; diff --git a/src/components/Button/Button.jsx b/src/components/Button/Button.jsx index 320563181..9b4499f6b 100644 --- a/src/components/Button/Button.jsx +++ b/src/components/Button/Button.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootColorClassName } from '../_helpers/getRootColorClassName'; import { getRootPriorityClassName } from '../_helpers/getRootPriorityClassName'; import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; diff --git a/src/components/ButtonGroup/ButtonGroup.jsx b/src/components/ButtonGroup/ButtonGroup.jsx index 450a42ddd..3752f6315 100644 --- a/src/components/ButtonGroup/ButtonGroup.jsx +++ b/src/components/ButtonGroup/ButtonGroup.jsx @@ -3,8 +3,8 @@ import React, { useMemo, } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootPriorityClassName } from '../_helpers/getRootPriorityClassName'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import styles from './ButtonGroup.module.scss'; diff --git a/src/components/Card/Card.jsx b/src/components/Card/Card.jsx index 69045a962..69acb7366 100644 --- a/src/components/Card/Card.jsx +++ b/src/components/Card/Card.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootColorClassName } from '../_helpers/getRootColorClassName'; import styles from './Card.module.scss'; @@ -18,7 +18,7 @@ export const Card = ({ {...transferProps(restProps)} className={classNames( styles.root, - getRootColorClassName(color, styles), + color && getRootColorClassName(color, styles), dense && styles.isRootDense, raised && styles.isRootRaised, disabled && styles.isRootDisabled, @@ -29,7 +29,7 @@ export const Card = ({ ); Card.defaultProps = { - color: 'light', + color: undefined, dense: false, disabled: false, raised: false, @@ -45,9 +45,9 @@ Card.propTypes = { children: PropTypes.node.isRequired, /** * Color to clarify importance and meaning of the card. Implements - * [Feedback and Neutral color collections](/docs/foundation/collections#colors). + * [Feedback color collection](/docs/foundation/collections#colors). */ - color: PropTypes.oneOf(['success', 'warning', 'danger', 'help', 'info', 'note', 'light', 'dark']), + color: PropTypes.oneOf(['success', 'warning', 'danger', 'help', 'info', 'note']), /** * Make the card more compact. */ diff --git a/src/components/Card/Card.module.scss b/src/components/Card/Card.module.scss index 92032d794..cdfcf559f 100644 --- a/src/components/Card/Card.module.scss +++ b/src/components/Card/Card.module.scss @@ -12,9 +12,9 @@ flex-direction: column; min-width: 0; // 1. color: var(--rui-local-color); - border: theme.$border-width solid var(--rui-local-border-color); + border: theme.$border-width solid var(--rui-local-border-color, transparent); border-radius: theme.$border-radius; - background-color: var(--rui-local-background-color); + background-color: var(--rui-local-background-color, theme.$background-color); } .body { diff --git a/src/components/Card/CardBody.jsx b/src/components/Card/CardBody.jsx index 9037f1eca..e14eb8501 100644 --- a/src/components/Card/CardBody.jsx +++ b/src/components/Card/CardBody.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import styles from './Card.module.scss'; export const CardBody = ({ diff --git a/src/components/Card/CardFooter.jsx b/src/components/Card/CardFooter.jsx index 953c8141c..3d08ad38d 100644 --- a/src/components/Card/CardFooter.jsx +++ b/src/components/Card/CardFooter.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import { withGlobalProps } from '../../providers/globalProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import styles from './Card.module.scss'; diff --git a/src/components/Card/README.md b/src/components/Card/README.md index 42b2d3a1a..b59653a09 100644 --- a/src/components/Card/README.md +++ b/src/components/Card/README.md @@ -148,7 +148,7 @@ for card content. ## Color Variants To cover all possible needs of your project, Card is available in colors from -[Feedback and Neutral color collections](/docs/foundation/collections#colors). +[Feedback color collection](/docs/foundation/collections#colors). ```docoff-react-preview @@ -211,26 +211,6 @@ To cover all possible needs of your project, Card is available in colors from - - - Hello! I'm light (default) variant of card. - {' '} - - - - - - - - - Hello! I'm dark variant of card. - {' '} - - - - - - ``` ## States @@ -314,6 +294,7 @@ Separate your card actions with CardFooter. See | `--rui-Card__padding` | Padding shared by card header, body and footer | | `--rui-Card__border-width` | Border width | | `--rui-Card__border-radius` | Corner radius | +| `--rui-Card__background-color` | Card background color | | `--rui-Card--dense__padding` | Padding of dense card | | `--rui-Card--raised__box-shadow` | Box shadow of raised card | | `--rui-Card--disabled__background-color` | Card background color in disabled state | diff --git a/src/components/Card/__tests__/Card.test.jsx b/src/components/Card/__tests__/Card.test.jsx index 3b9029932..05a507df9 100644 --- a/src/components/Card/__tests__/Card.test.jsx +++ b/src/components/Card/__tests__/Card.test.jsx @@ -4,7 +4,6 @@ import { within, } from '@testing-library/react'; import { feedbackColorPropTest } from '../../../../tests/propTests/feedbackColorPropTest'; -import { neutralColorPropTest } from '../../../../tests/propTests/neutralColorPropTest'; import { raisedPropTest } from '../../../../tests/propTests/raisedPropTest'; import { ScrollView } from '../../ScrollView'; import { Card } from '../Card'; @@ -35,7 +34,6 @@ describe('rendering', () => { (rootElement) => expect(within(rootElement).getByText('scroll view content')), ], ...feedbackColorPropTest, - ...neutralColorPropTest, ...densePropTest('Root'), [ { disabled: true }, diff --git a/src/components/Card/_settings.scss b/src/components/Card/_settings.scss index c055c8b49..00b906a1d 100644 --- a/src/components/Card/_settings.scss +++ b/src/components/Card/_settings.scss @@ -1,5 +1,4 @@ -@use "sass:list"; @use "../../styles/settings/collections"; -$colors: list.join(collections.$feedback-colors, collections.$neutral-colors); +$colors: collections.$feedback-colors; $themeable-properties: color, border-color, background-color; diff --git a/src/components/Card/_theme.scss b/src/components/Card/_theme.scss index 819da99fb..191365c4f 100644 --- a/src/components/Card/_theme.scss +++ b/src/components/Card/_theme.scss @@ -1,6 +1,7 @@ $padding: var(--rui-Card__padding); $border-width: var(--rui-Card__border-width); $border-radius: var(--rui-Card__border-radius); +$background-color: var(--rui-Card__background-color); $dense-padding: var(--rui-Card--dense__padding); $raised-box-shadow: var(--rui-Card--raised__box-shadow); diff --git a/src/components/CheckboxField/CheckboxField.jsx b/src/components/CheckboxField/CheckboxField.jsx index e23e521c4..d393ce5a5 100644 --- a/src/components/CheckboxField/CheckboxField.jsx +++ b/src/components/CheckboxField/CheckboxField.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { FormLayoutContext } from '../FormLayout'; import styles from './CheckboxField.module.scss'; diff --git a/src/components/FileInputField/FileInputField.jsx b/src/components/FileInputField/FileInputField.jsx index 113139c6a..eb70f6534 100644 --- a/src/components/FileInputField/FileInputField.jsx +++ b/src/components/FileInputField/FileInputField.jsx @@ -1,10 +1,19 @@ import PropTypes from 'prop-types'; -import React, { useContext } from 'react'; +import React, { + useContext, + useImperativeHandle, + useRef, + useState, +} from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames'; +import { transferProps } from '../../helpers/transferProps'; +import { TranslationsContext } from '../../providers/translations'; +import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { resolveContextOrProp } from '../_helpers/resolveContextOrProp'; +import { InputGroupContext } from '../InputGroup'; +import { Text } from '../Text'; import { FormLayoutContext } from '../FormLayout'; import styles from './FileInputField.module.scss'; @@ -17,54 +26,156 @@ export const FileInputField = React.forwardRef((props, ref) => { isLabelVisible, label, layout, + multiple, + onFilesChanged, required, + size, validationState, validationText, ...restProps } = props; - const context = useContext(FormLayoutContext); + const internalInputRef = useRef(); + + // We need to have a reference to the input element to be able to call its methods, + // but at the same time we want to expose this reference to the parent component for + // case someone wants to call input methods from outside the component. + useImperativeHandle(ref, () => internalInputRef.current); + + const formLayoutContext = useContext(FormLayoutContext); + const inputGroupContext = useContext(InputGroupContext); + const translations = useContext(TranslationsContext); + + const [selectedFileNames, setSelectedFileNames] = useState([]); + const [isDragging, setIsDragging] = useState(false); + + const handleFileChange = (files, event) => { + if (files.length === 0) { + setSelectedFileNames([]); + return; + } + + // Mimic the native behavior of the `input` element: if multiple files are selected and the input + // does not accept multiple files, no files are processed. + if (files.length > 1 && !multiple) { + setSelectedFileNames([]); + return; + } + + const fileNames = []; + + [...files].forEach((file) => { + fileNames.push(file.name); + }); + + setSelectedFileNames(fileNames); + onFilesChanged(files, event); + }; + + const handleInputChange = (event) => { + handleFileChange(event.target.files, event); + }; + + const handleClick = () => { + internalInputRef?.current.click(); + }; + + const handleDrop = (event) => { + event.preventDefault(); + handleFileChange(event.dataTransfer.files, event); + setIsDragging(false); + }; + + const handleDragOver = (event) => { + if (!isDragging) { + setIsDragging(true); + } + event.preventDefault(); + }; + + const handleDragLeave = () => { + if (isDragging) { + setIsDragging(false); + } + }; return ( - - {label} - + + + + {!selectedFileNames.length && ( + <> + {translations.FileInputField.browse} + {' '} + {translations.FileInputField.drop} + > + )} + {selectedFileNames.length === 1 && selectedFileNames[0]} + {selectedFileNames.length > 1 && ( + <> + {selectedFileNames.length} + {' '} + {translations.FileInputField.filesSelected} + > + )} + + {helpText && ( {helpText} @@ -72,13 +183,13 @@ export const FileInputField = React.forwardRef((props, ref) => { {validationText && ( {validationText} )} - + ); }); @@ -86,10 +197,11 @@ FileInputField.defaultProps = { disabled: false, fullWidth: false, helpText: null, - id: undefined, isLabelVisible: true, layout: 'vertical', + multiple: false, required: false, + size: 'medium', validationState: null, validationText: null, }; @@ -116,7 +228,7 @@ FileInputField.propTypes = { * * `__helpText` * * `__validationText` */ - id: PropTypes.string, + id: PropTypes.string.isRequired, /** * If `false`, the label will be visually hidden (but remains accessible by assistive * technologies). @@ -134,10 +246,24 @@ FileInputField.propTypes = { * */ layout: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * If `true`, the input will accept multiple files. + */ + multiple: PropTypes.bool, + /** + * Callback fired when the value of the input changes. + */ + onFilesChanged: PropTypes.func.isRequired, /** * If `true`, the input will be required. */ required: PropTypes.bool, + /** + * Size of the field. + * + * Ignored if the component is rendered within `InputGroup` component as the value is inherited in such case. + */ + size: PropTypes.oneOf(['small', 'medium', 'large']), /** * Alter the field to provide feedback based on validation result. */ diff --git a/src/components/FileInputField/FileInputField.module.scss b/src/components/FileInputField/FileInputField.module.scss index fe1da7768..e71e75a9d 100644 --- a/src/components/FileInputField/FileInputField.module.scss +++ b/src/components/FileInputField/FileInputField.module.scss @@ -1,8 +1,16 @@ +// 1. The drop zone is constructed as a button to support keyboard operation. +// 2. Prevent pointer events on all children of the root element to not to trigger drag events on children. + @use "../../styles/tools/form-fields/box-field-elements"; @use "../../styles/tools/form-fields/box-field-layout"; +@use "../../styles/tools/form-fields/box-field-sizes"; @use "../../styles/tools/form-fields/foundation"; @use "../../styles/tools/form-fields/variants"; @use "../../styles/tools/accessibility"; +@use "../../styles/tools/links"; +@use "../../styles/tools/transition"; +@use "../../styles/tools/reset"; +@use "settings"; @layer components.file-input-field { // Foundation @@ -18,6 +26,54 @@ @include box-field-elements.input-container(); } + .input { + @include accessibility.hide-text(); + } + + .dropZone { + --rui-local-color: #{settings.$drop-zone-color}; + --rui-local-border-color: #{settings.$drop-zone-border-color}; + --rui-local-background: #{settings.$drop-zone-background-color}; + + @include reset.button(); // 1. + @include box-field-elements.base(); + + display: flex; + align-items: center; + justify-content: start; + font-weight: settings.$drop-zone-font-weight; + font-size: var(--rui-local-font-size); + line-height: settings.$drop-zone-line-height; + font-family: settings.$drop-zone-font-family; + border-style: dashed; + } + + .isRootDragging .dropZone { + --rui-local-border-color: #{settings.$drop-zone-dragging-border-color}; + } + + .isRootDisabled .dropZone { + cursor: settings.$drop-zone-disabled-cursor; + } + + .root:not(.isRootDisabled, .isRootDragging) .dropZone:hover { + --rui-local-border-color: #{settings.$drop-zone-hover-border-color}; + } + + .root:not(.isRootDisabled, .isRootDragging) .dropZone:active { + --rui-local-border-color: #{settings.$drop-zone-active-border-color}; + } + + .dropZoneLink { + @include links.base(); + + &::before { + content: ""; + position: absolute; + inset: 0; + } + } + .helpText, .validationText { @include foundation.help-text(); @@ -28,6 +84,18 @@ } // States + .isRootDisabled { + --rui-local-color: #{settings.$drop-zone-disabled-color}; + --rui-local-border-color: #{settings.$drop-zone-disabled-border-color}; + --rui-local-background: #{settings.$drop-zone-disabled-background-color}; + + @include variants.disabled-state(); + } + + .isRootDisabled .dropZoneLink { + cursor: inherit; + } + .isRootStateInvalid { @include variants.validation(invalid); } @@ -56,10 +124,28 @@ } .isRootFullWidth { - @include box-field-layout.full-width(); + @include box-field-layout.full-width($input-element-selector: ".dropZone"); } .isRootInFormLayout { @include box-field-layout.in-form-layout(); } + + // Sizes + .isRootSizeSmall { + @include box-field-sizes.size(small); + } + + .isRootSizeMedium { + @include box-field-sizes.size(medium); + } + + .isRootSizeLarge { + @include box-field-sizes.size(large); + } + + // Groups + .isRootGrouped { + @include box-field-elements.in-group-layout($input-element-selector: ".dropZone"); + } } diff --git a/src/components/FileInputField/README.md b/src/components/FileInputField/README.md index 87bc22574..8f80aef26 100644 --- a/src/components/FileInputField/README.md +++ b/src/components/FileInputField/README.md @@ -13,7 +13,7 @@ import { FileInputField } from '@react-ui-org/react-ui'; And use it: ```docoff-react-preview - + {}} /> ``` See [API](#api) for all available options. @@ -48,12 +48,37 @@ layout perspective, FileInputFields work just like any other form fields. ## Sizes +Aside from the default (medium) size, two additional sizes are available: small +and large. + +```docoff-react-preview + {}} + size="small" +/> + {}} +/> + {}} + size="large" +/> +``` + Full-width fields span the full width of a parent: ```docoff-react-preview {}} /> ``` @@ -68,8 +93,10 @@ dangerous to hide labels from users in most cases. Keep in mind you should ```docoff-react-preview {}} /> ``` @@ -81,14 +108,18 @@ supports this kind of layout as well. ```docoff-react-preview {}} /> {}} /> ``` @@ -100,18 +131,24 @@ filled. ```docoff-react-preview {}} /> {}} /> {}} /> ``` @@ -126,17 +163,23 @@ have. ```docoff-react-preview {}} validationState="valid" validationText="Looks good!" /> {}} validationState="invalid" validationText="Your file is too big. Please select something smaller." /> {}} validationState="warning" validationText={` You selected more than 10 files. @@ -152,7 +195,44 @@ It's possible to disable the whole input. ```docoff-react-preview {}} +/> +``` + +## Handling Files + +Files selected by the user are handled by providing a custom function to the +`onFilesChanged` prop. The `onFilesChanged` function is then called on the +`change` event of the `input` element and on the `drop` event of the root +`div` element. + +```docoff-react-preview + { + // Do something with the files… + console.log('Files selected:', files); + }} +/> +``` + +### Multiple Files + +By default, users can select only one file. To allow selecting multiple files, +set the `multiple` prop to `true`. + +```docoff-react-preview + { + // Do something with the files… + console.log('Files selected:', files); + }} /> ``` @@ -172,8 +252,9 @@ to improve its accessibility. Choose up to 10 files. Allowed extensions are .pdf, .jpg, .jpeg, or .png. Size limit is 10 MB. `} + id="my-file" label="Attachment" - multiple + onFilesChanged={() => {}} /> ``` diff --git a/src/components/FileInputField/__tests__/FileInputField.test.jsx b/src/components/FileInputField/__tests__/FileInputField.test.jsx index 66f579520..77881c584 100644 --- a/src/components/FileInputField/__tests__/FileInputField.test.jsx +++ b/src/components/FileInputField/__tests__/FileInputField.test.jsx @@ -13,13 +13,16 @@ import { formLayoutProviderTest } from '../../../../tests/providerTests/formLayo import { isLabelVisibleTest } from '../../../../tests/propTests/isLabelVisibleTest'; import { labelPropTest } from '../../../../tests/propTests/labelPropTest'; import { layoutPropTest } from '../../../../tests/propTests/layoutPropTest'; +import { sizePropTest } from '../../../../tests/propTests/sizePropTest'; import { requiredPropTest } from '../../../../tests/propTests/requiredPropTest'; import { validationStatePropTest } from '../../../../tests/propTests/validationStatePropTest'; import { validationTextPropTest } from '../../../../tests/propTests/validationTextPropTest'; import { FileInputField } from '../FileInputField'; const mandatoryProps = { + id: 'id', label: 'label', + onFilesChanged: () => {}, }; describe('rendering', () => { @@ -33,7 +36,6 @@ describe('rendering', () => { [ { helpText: 'help text', - id: 'id', validationText: 'validation text', }, (rootElement) => { @@ -41,13 +43,14 @@ describe('rendering', () => { expect(within(rootElement).getByText('label')).toHaveAttribute('id', 'id__labelText'); expect(within(rootElement).getByText('help text')).toHaveAttribute('id', 'id__helpText'); expect(within(rootElement).getByText('validation text')).toHaveAttribute('id', 'id__validationText'); - expect(rootElement).toHaveAttribute('id', 'id__label'); + expect(rootElement).toHaveAttribute('id', 'id__root'); }, ], ...isLabelVisibleTest(), ...labelPropTest(), ...layoutPropTest, ...requiredPropTest, + ...sizePropTest, ...validationStatePropTest, ...validationTextPropTest, ])('renders with props: "%s"', (testedProps, assert) => { @@ -68,12 +71,13 @@ describe('functionality', () => { render(( )); const file = new File(['hello'], 'hello.png', { type: 'image/png' }); - await userEvent.upload(screen.getByLabelText('label'), file); + await userEvent.upload(screen.getByTestId('id'), file); expect(spy).toHaveBeenCalled(); }); }); diff --git a/src/components/FileInputField/_settings.scss b/src/components/FileInputField/_settings.scss new file mode 100644 index 000000000..1b882d49f --- /dev/null +++ b/src/components/FileInputField/_settings.scss @@ -0,0 +1,15 @@ +@use "../../styles/theme/typography"; + +$drop-zone-color: var(--rui-color-text-primary); +$drop-zone-disabled-color: var(--rui-color-text-primary-disabled); +$drop-zone-border-color: var(--rui-color-border-primary); +$drop-zone-hover-border-color: var(--rui-color-border-primary-hover); +$drop-zone-active-border-color: var(--rui-color-border-primary-active); +$drop-zone-dragging-border-color: var(--rui-color-border-primary-active); +$drop-zone-disabled-border-color: var(--rui-color-border-primary); +$drop-zone-background-color: var(--rui-color-background-basic); +$drop-zone-disabled-background-color: var(--rui-color-background-disabled); +$drop-zone-disabled-cursor: var(--rui-cursor-not-allowed); +$drop-zone-font-weight: typography.$font-weight-base; +$drop-zone-line-height: typography.$line-height-base; +$drop-zone-font-family: typography.$font-family-base; diff --git a/src/components/FormLayout/FormLayout.jsx b/src/components/FormLayout/FormLayout.jsx index 2b385c5d1..b6a76f26f 100644 --- a/src/components/FormLayout/FormLayout.jsx +++ b/src/components/FormLayout/FormLayout.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useMemo } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import { FormLayoutContext } from './FormLayoutContext'; import styles from './FormLayout.module.scss'; diff --git a/src/components/FormLayout/FormLayoutCustomField.jsx b/src/components/FormLayout/FormLayoutCustomField.jsx index 933d8210c..095d56d87 100644 --- a/src/components/FormLayout/FormLayoutCustomField.jsx +++ b/src/components/FormLayout/FormLayoutCustomField.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; diff --git a/src/components/FormLayout/README.md b/src/components/FormLayout/README.md index 8c9f713d0..7bacef204 100644 --- a/src/components/FormLayout/README.md +++ b/src/components/FormLayout/README.md @@ -389,6 +389,7 @@ React.createElement(() => { /> {}} /> { { children: content text }, (rootElement) => expect(within(rootElement).getByText('content text')), ], + // The following tests must use `rootElement.outerHTML.includes()` to test presence of CSS variables in the DOM, + // because the `toHaveStyle` matcher does not support reading CSS variables via the `var()` function. [ { columnGap: responsiveSpacingBreakpoints }, - (rootElement) => expect(rootElement).toHaveStyle(responsiveSpacingStyles('column-gap')), + (rootElement) => { + Object.entries(responsiveSpacingStyles('column-gap')).forEach(([ccsAttribute, cssValue]) => { + expect(rootElement.outerHTML.includes(`${ccsAttribute}: ${cssValue}`)).toBeTruthy(); + }); + }, ], [ { columnGap: 0 }, - (rootElement) => expect(rootElement).toHaveStyle({ '--rui-local-column-gap-xs': 'var(--rui-dimension-space-0)' }), + (rootElement) => expect(rootElement.outerHTML.includes('--rui-local-column-gap-xs: var(--rui-dimension-space-0)')).toBeTruthy(), ], [ { columns: responsiveBreakpoints }, @@ -116,13 +122,19 @@ describe('rendering', () => { { justifyItems: 'placeholder' }, (rootElement) => expect(rootElement).toHaveStyle({ '--rui-local-justify-items-xs': 'placeholder' }), ], + // The following tests must use `rootElement.outerHTML.includes()` to test presence of CSS variables in the DOM, + // because the `toHaveStyle` matcher does not support reading CSS variables via the `var()` function. [ { rowGap: responsiveSpacingBreakpoints }, - (rootElement) => expect(rootElement).toHaveStyle(responsiveSpacingStyles('row-gap')), + (rootElement) => { + Object.entries(responsiveSpacingStyles('row-gap')).forEach(([cssAttribute, cssValue]) => { + expect(rootElement.outerHTML.includes(`${cssAttribute}: ${cssValue}`)).toBeTruthy(); + }); + }, ], [ { rowGap: 0 }, - (rootElement) => expect(rootElement).toHaveStyle({ '--rui-local-row-gap-xs': 'var(--rui-dimension-space-0)' }), + (rootElement) => expect(rootElement.outerHTML.includes('--rui-local-row-gap-xs: var(--rui-dimension-space-0)')).toBeTruthy(), ], [ { rows: responsiveBreakpoints }, diff --git a/src/components/InputGroup/InputGroup.jsx b/src/components/InputGroup/InputGroup.jsx index dcb230bec..cad554b60 100644 --- a/src/components/InputGroup/InputGroup.jsx +++ b/src/components/InputGroup/InputGroup.jsx @@ -4,8 +4,8 @@ import React, { useMemo, } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; diff --git a/src/components/InputGroup/InputGroup.module.scss b/src/components/InputGroup/InputGroup.module.scss index 16ac95ebe..0751322d9 100644 --- a/src/components/InputGroup/InputGroup.module.scss +++ b/src/components/InputGroup/InputGroup.module.scss @@ -84,14 +84,14 @@ // Sizes .isRootSizeSmall { - @include box-field-sizes.size(small, $has-input: false); + @include box-field-sizes.size(small); } .isRootSizeMedium { - @include box-field-sizes.size(medium, $has-input: false); + @include box-field-sizes.size(medium); } .isRootSizeLarge { - @include box-field-sizes.size(large, $has-input: false); + @include box-field-sizes.size(large); } } diff --git a/src/components/InputGroup/README.md b/src/components/InputGroup/README.md index dd16362d2..2bbd8e1f3 100644 --- a/src/components/InputGroup/README.md +++ b/src/components/InputGroup/README.md @@ -145,7 +145,7 @@ supports this kind of layout as well. label="Horizontal layout" layout="horizontal" > - + {}} /> ``` diff --git a/src/components/Modal/Modal.jsx b/src/components/Modal/Modal.jsx index fd95d7a1a..19e95ef3e 100644 --- a/src/components/Modal/Modal.jsx +++ b/src/components/Modal/Modal.jsx @@ -1,9 +1,19 @@ import PropTypes from 'prop-types'; -import React, { useRef } from 'react'; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; import { createPortal } from 'react-dom'; +import { classNames } from '../../helpers/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { getRootColorClassName } from '../_helpers/getRootColorClassName'; +import { dialogOnCancelHandler } from './_helpers/dialogOnCancelHandler'; +import { dialogOnClickHandler } from './_helpers/dialogOnClickHandler'; +import { dialogOnCloseHandler } from './_helpers/dialogOnCloseHandler'; +import { dialogOnKeyDownHandler } from './_helpers/dialogOnKeyDownHandler'; import { getPositionClassName } from './_helpers/getPositionClassName'; import { getSizeClassName } from './_helpers/getSizeClassName'; import { useModalFocus } from './_hooks/useModalFocus'; @@ -12,44 +22,37 @@ import styles from './Modal.module.scss'; const preRender = ( children, - childrenWrapperRef, - closeButtonRef, + color, + dialogRef, position, - restProps, size, + events, + restProps, ) => ( - { - e.preventDefault(); - if (closeButtonRef?.current != null) { - closeButtonRef.current.click(); - } - }} - role="presentation" + - { - e.stopPropagation(); - }} - ref={childrenWrapperRef} - role="presentation" - > - {children} - - + {children} + ); export const Modal = ({ + allowCloseOnBackdropClick, + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, autoFocus, children, closeButtonRef, + color, + dialogRef, portalId, position, preventScrollUnderneath, @@ -57,45 +60,89 @@ export const Modal = ({ size, ...restProps }) => { - const childrenWrapperRef = useRef(); + const internalDialogRef = useRef(); - useModalFocus( - autoFocus, - childrenWrapperRef, - primaryButtonRef, - closeButtonRef, - ); + useEffect(() => { + internalDialogRef.current.showModal(); + }, []); + // We need to have a reference to the dialog element to be able to call its methods, + // but at the same time we want to expose this reference to the parent component for + // case someone wants to call dialog methods from outside the component. + useImperativeHandle(dialogRef, () => internalDialogRef.current); + + useModalFocus(autoFocus, internalDialogRef, primaryButtonRef); useModalScrollPrevention(preventScrollUnderneath); + const onCancel = useCallback( + (e) => dialogOnCancelHandler(e, closeButtonRef, restProps.onCancel), + [closeButtonRef, restProps.onCancel], + ); + const onClick = useCallback( + (e) => dialogOnClickHandler(e, closeButtonRef, internalDialogRef, allowCloseOnBackdropClick), + [allowCloseOnBackdropClick, closeButtonRef, internalDialogRef], + ); + const onClose = useCallback( + (e) => dialogOnCloseHandler(e, closeButtonRef, restProps.onClose), + [closeButtonRef, restProps.onClose], + ); + const onKeyDown = useCallback( + (e) => dialogOnKeyDownHandler( + e, + closeButtonRef, + primaryButtonRef, + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, + ), + [ + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, + closeButtonRef, + primaryButtonRef, + ], + ); + const events = { + onCancel, + onClick, + onClose, + onKeyDown, + }; + if (portalId === null) { return preRender( children, - childrenWrapperRef, - closeButtonRef, + color, + internalDialogRef, position, - restProps, size, + events, + restProps, ); } return createPortal( preRender( children, - childrenWrapperRef, - closeButtonRef, + color, + internalDialogRef, position, - restProps, size, + events, + restProps, ), document.getElementById(portalId), ); }; Modal.defaultProps = { + allowCloseOnBackdropClick: true, + allowCloseOnEscapeKey: true, + allowPrimaryActionOnEnterKey: true, autoFocus: true, children: null, closeButtonRef: null, + color: undefined, + dialogRef: null, portalId: null, position: 'center', preventScrollUnderneath: window.document.body, @@ -104,6 +151,18 @@ Modal.defaultProps = { }; Modal.propTypes = { + /** + * If `true`, the `Modal` can be closed by clicking on the backdrop. + */ + allowCloseOnBackdropClick: PropTypes.bool, + /** + * If `true`, the `Modal` can be closed by pressing the Escape key. + */ + allowCloseOnEscapeKey: PropTypes.bool, + /** + * If `true`, the `Modal` can be submitted by pressing the Enter key. + */ + allowPrimaryActionOnEnterKey: PropTypes.bool, /** * If `true`, focus the first input element in the `Modal`, or primary button (referenced by the `primaryButtonRef` * prop), or other focusable element when the `Modal` is opened. If there are none or `autoFocus` is set to `false`, @@ -121,12 +180,25 @@ Modal.propTypes = { */ children: PropTypes.node, /** - * Reference to close button element. It is used to close modal when Escape key is pressed or the backdrop is clicked. + * Reference to close button element. It is used to close modal when Escape key is pressed + * or the backdrop is clicked. */ closeButtonRef: PropTypes.shape({ // eslint-disable-next-line react/forbid-prop-types current: PropTypes.any, }), + /** + * Color to clarify importance and meaning of the modal. Implements + * [Feedback color collection](/docs/foundation/collections#colors). + */ + color: PropTypes.oneOf(['success', 'warning', 'danger', 'help', 'info', 'note']), + /** + * Reference to dialog element + */ + dialogRef: PropTypes.shape({ + // eslint-disable-next-line react/forbid-prop-types + current: PropTypes.any, + }), /** * If set, modal is rendered in the React Portal with that ID. */ diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss index d7fa011d1..4afb7d0fb 100644 --- a/src/components/Modal/Modal.module.scss +++ b/src/components/Modal/Modal.module.scss @@ -1,9 +1,16 @@ +// 1. Modal uses element that uses the browser's built-in dialog functionality, so that: +// * visibility of the .root element and its backdrop is managed by the browser +// * positioning of the .root element and its backdrop is managed by the browser +// * z-index of the .root element and its backdrop is not needed as dialog is rendered in browser's Top layer + @use "sass:map"; @use "../../styles/theme/typography"; @use "../../styles/tools/accessibility"; @use "../../styles/tools/breakpoint"; +@use "../../styles/tools/collections"; @use "../../styles/tools/reset"; @use "../../styles/tools/spacing"; +@use "animations"; @use "settings"; @use "theme"; @@ -13,18 +20,16 @@ --rui-local-max-width: calc(100% - (2 * var(--rui-local-outer-spacing))); --rui-local-max-height: calc(100% - (2 * var(--rui-local-outer-spacing))); - position: fixed; - left: 50%; - z-index: settings.$z-index; - display: flex; flex-direction: column; max-width: var(--rui-local-max-width); max-height: var(--rui-local-max-height); + padding: 0; overflow-y: auto; + color: inherit; + border-width: 0; border-radius: settings.$border-radius; background: theme.$background; box-shadow: theme.$box-shadow; - transform: translateX(-50%); overscroll-behavior: contain; @include breakpoint.up(sm) { @@ -32,14 +37,20 @@ } } - .backdrop { - position: fixed; - top: 0; - left: 0; - z-index: settings.$backdrop-z-index; - width: 100vw; - height: 100vh; + .root[open] { + display: flex; + + @media (prefers-reduced-motion: no-preference) { + animation: fade-in theme.$animation-duration ease-out; + } + } + + .root[open]::backdrop { background: theme.$backdrop-background; + + @media (prefers-reduced-motion: no-preference) { + animation: inherit; + } } .isRootSizeSmall { @@ -64,17 +75,22 @@ } .isRootSizeAuto { - width: auto; min-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, min-width)}); max-width: min(var(--rui-local-max-width), #{map.get(theme.$sizes, auto, max-width)}); } - .isRootPositionCenter { - top: 50%; - transform: translate(-50%, -50%); - } - .isRootPositionTop { top: var(--rui-local-outer-spacing); + bottom: auto; + } + + @each $color in settings.$colors { + @include collections.generate-class( + $prefix: "rui-", + $component-name: "Modal", + $variant-name: "color", + $variant-value: $color, + $properties: settings.$themeable-properties, + ); } } diff --git a/src/components/Modal/ModalBody.jsx b/src/components/Modal/ModalBody.jsx index 0f54c497d..5f245c83c 100644 --- a/src/components/Modal/ModalBody.jsx +++ b/src/components/Modal/ModalBody.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import { getScrollingClassName } from './_helpers/getScrollingClassName'; import styles from './ModalBody.module.scss'; diff --git a/src/components/Modal/ModalBody.module.scss b/src/components/Modal/ModalBody.module.scss index 06f2340c8..5cdecbd2d 100644 --- a/src/components/Modal/ModalBody.module.scss +++ b/src/components/Modal/ModalBody.module.scss @@ -1,6 +1,24 @@ +// 1. Intentionally do not provide a fallback value for the border color. Setting a fallback value (e.g. `transparent`) +// will result in the border being skewed at both ends. + +@use "settings"; + @layer components.modal { .root { flex: 1 1 auto; + border-inline: settings.$border-width solid var(--rui-local-border-color); // 1. + + &:first-child { + border-top: settings.$border-width solid var(--rui-local-border-color); // 1. + border-top-left-radius: settings.$border-radius; + border-top-right-radius: settings.$border-radius; + } + + &:last-child { + border-bottom: settings.$border-width solid var(--rui-local-border-color); // 1. + border-bottom-right-radius: settings.$border-radius; + border-bottom-left-radius: settings.$border-radius; + } } .isRootScrollingAuto, diff --git a/src/components/Modal/ModalCloseButton.jsx b/src/components/Modal/ModalCloseButton.jsx index eeecccf64..71ac7622c 100644 --- a/src/components/Modal/ModalCloseButton.jsx +++ b/src/components/Modal/ModalCloseButton.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { TranslationsContext } from '../../providers/translations'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import styles from './ModalCloseButton.module.scss'; export const ModalCloseButton = React.forwardRef((props, ref) => { diff --git a/src/components/Modal/ModalContent.jsx b/src/components/Modal/ModalContent.jsx index 5374987ab..f8a2cf89d 100644 --- a/src/components/Modal/ModalContent.jsx +++ b/src/components/Modal/ModalContent.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import styles from './ModalContent.module.scss'; diff --git a/src/components/Modal/ModalFooter.jsx b/src/components/Modal/ModalFooter.jsx index a4be180a4..2705672b6 100644 --- a/src/components/Modal/ModalFooter.jsx +++ b/src/components/Modal/ModalFooter.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getJustifyClassName } from './_helpers/getJustifyClassName'; import styles from './ModalFooter.module.scss'; diff --git a/src/components/Modal/ModalFooter.module.scss b/src/components/Modal/ModalFooter.module.scss index 922d2c195..4702d028b 100644 --- a/src/components/Modal/ModalFooter.module.scss +++ b/src/components/Modal/ModalFooter.module.scss @@ -1,3 +1,6 @@ +// 1. Intentionally do not provide a fallback value for the border color. Setting a fallback value (e.g. `transparent`) +// will result in the border being skewed at both ends. + @use "settings"; @use "theme"; @@ -9,10 +12,11 @@ gap: theme.$footer-gap; align-items: center; padding: theme.$padding-y theme.$padding-x; - border-top: theme.$separator-width solid theme.$separator-color; + border: settings.$border-width solid var(--rui-local-border-color); // 1. + border-top: theme.$separator-width solid var(--rui-local-border-color, #{theme.$separator-color}); border-bottom-right-radius: settings.$border-radius; border-bottom-left-radius: settings.$border-radius; - background: theme.$footer-background; + background: var(--rui-local-background-color, #{theme.$footer-background}); } .isRootJustifiedToStart { diff --git a/src/components/Modal/ModalHeader.jsx b/src/components/Modal/ModalHeader.jsx index 6cf307aa9..102d7087e 100644 --- a/src/components/Modal/ModalHeader.jsx +++ b/src/components/Modal/ModalHeader.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getJustifyClassName } from './_helpers/getJustifyClassName'; import styles from './ModalHeader.module.scss'; diff --git a/src/components/Modal/ModalHeader.module.scss b/src/components/Modal/ModalHeader.module.scss index 116d55d59..a11ec02fa 100644 --- a/src/components/Modal/ModalHeader.module.scss +++ b/src/components/Modal/ModalHeader.module.scss @@ -1,3 +1,7 @@ +// 1. Intentionally do not provide a fallback value for the border color. Setting a fallback value (e.g. `transparent`) +// will result in the border being skewed at both ends. + +@use "settings"; @use "theme"; @layer components.modal { @@ -7,7 +11,10 @@ gap: theme.$header-gap; align-items: baseline; padding: theme.$padding-y theme.$padding-x; - border-bottom: theme.$separator-width solid theme.$separator-color; + border: settings.$border-width solid var(--rui-local-border-color); // 1. + border-bottom: theme.$separator-width solid var(--rui-local-border-color, #{theme.$separator-color}); + border-top-left-radius: settings.$border-radius; + border-top-right-radius: settings.$border-radius; } .isRootJustifiedToStart { diff --git a/src/components/Modal/ModalTitle.jsx b/src/components/Modal/ModalTitle.jsx index bd814062c..b419d8852 100644 --- a/src/components/Modal/ModalTitle.jsx +++ b/src/components/Modal/ModalTitle.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import styles from './ModalTitle.module.scss'; export const ModalTitle = ({ diff --git a/src/components/Modal/README.md b/src/components/Modal/README.md index 7ee9efa2f..cab4e6d5f 100644 --- a/src/components/Modal/README.md +++ b/src/components/Modal/README.md @@ -92,11 +92,24 @@ See [API](#api) for all available options. - **Modal actions** should correspond to the modal purpose, too. E.g. “Delete” tells better what happens rather than “OK”. -- Modal **automatically focuses the first non-disabled form field** by default - which allows users to confirm the modal by hitting the enter key. When no - field is found then the primary button (in the footer) is focused. To turn +- While native `` (that is used under the hood) can be present in DOM, + modal is a more feature-rich component that provides more control over the + modal behavior and shall be **removed from DOM when closed**. + +- Modal **automatically focuses the first non-disabled form field** by default. + When no field is found then the primary button (in the footer) is focused. To turn this feature off, set the `autofocus` prop to `false`. +- Modal **submits the form when the user presses the `Enter` key** . A click is + programmatically triggered on the primary button in this case. To turn this + feature off, set the `allowPrimaryActionOnEnterKey` prop to `false`. + +- Modal **closes when the user presses the `Escape` key**. A click is + programmatically triggered on the close button in this case. To turn this + feature off, set the `allowCloseOnEscapeKey` prop to `false`. Modal can be + also **closed by clicking on the backdrop**. To turn this feature off, + set the `allowCloseOnBackdropClick` prop to `false`. + - **Avoid stacking** of modals. While it may technically work, the modal is just not designed for that. @@ -114,143 +127,8 @@ Modal is decomposed into the following components: - [ModalFooter](#modalfooter) Using different combinations, you can compose different kinds of modals, -e.g. dialog modal, blocking modal, scrollable modal, etc. - -```docoff-react-preview -React.createElement(() => { - const [modalOpen, setModalOpen] = React.useState(null); - const modalPrimaryButtonRef = React.useRef(); - const modalCloseButtonRef = React.useRef(); - {/* - The `preventScrollUnderneath` feature is necessary for Modals to work in - React UI docs. You may not need it in your application. - */} - return ( - - { - setModalOpen(1); - setTimeout(() => setModalOpen(null), 2500); - }} - /> - { - setModalOpen(2); - setTimeout(() => setModalOpen(null), 3500); - }} - /> - setModalOpen(3)} - /> - setModalOpen(4)} - /> - - {modalOpen === 1 && ( - - - - - Application is being loaded. - - - - - - - - )} - {modalOpen === 2 && ( - - - Action finished - - - - - Action has been successfully finished. - You will be redirected within a few seconds. - - - - - )} - {modalOpen === 3 && ( - - - Delete the user? - setModalOpen(false)} /> - - - - - Do you really want to delete the user admin? - This cannot be undone. - - - - - setModalOpen(false)} - ref={modalPrimaryButtonRef} - /> - setModalOpen(false)} - priority="outline" - ref={modalCloseButtonRef} - /> - - - )} - {modalOpen === 4 && ( - - - Add new user - setModalOpen(false)} /> - - - - - - - - - - - setModalOpen(false)} - ref={modalPrimaryButtonRef} - /> - setModalOpen(false)} - priority="outline" - ref={modalCloseButtonRef} - /> - - - )} - - - ); -}); -``` +e.g. dialog modal, [modal with form](#forms), [blocking modal](#interaction-blocking), +[scrollable modal](#scrolling-long-content), etc. ### ModalHeader @@ -812,16 +690,106 @@ React.createElement(() => { }); ``` -## Keyboard Control +## Color Variants + +Modal can be colored using the `color` prop. The `color` prop implements the +[Feedback color collection](/docs/foundation/collections#colors) +and is applied to the border of the modal and the modal footer. + +```docoff-react-preview +React.createElement(() => { + const [modalOpen, setModalOpen] = React.useState(false); + const [modalColor, setModalColor] = React.useState('success'); + const modalCloseButtonRef = React.useRef(); + {/* + The `preventScrollUnderneath` feature is necessary for Modals to work in + React UI docs. You may not need it in your application. + */} + return ( + + setModalOpen(true)} + /> + + {modalOpen && ( + + + Modal color + setModalOpen(false)} /> + + + + setModalColor(e.target.value)} + options={[ + { + label: 'success', + value: 'success', + }, + { + label: 'warning', + value: 'warning', + }, + { + label: 'danger', + value: 'danger', + }, + { + label: 'info', + value: 'info', + }, + { + label: 'help', + value: 'help', + }, + { + label: 'note', + value: 'note', + }, + ]} + value={modalColor} + /> + + + + setModalOpen(false)} + ref={modalCloseButtonRef} + /> + + + )} + + + ); +}); +``` + +## Mouse and Keyboard Control Modal can be controlled either by mouse or keyboard. To enhance user -experience, primary action can be fired by pressing `Enter` key and the modal -can be closed by pressing the `Escape` key. +experience, primary action can be fired by pressing `Enter` key and +the modal can be closed by pressing the `Escape` key. Modal can be +also closed by clicking on the backdrop. To enable it, you just need to pass a reference to the buttons using -`primaryButtonRef` and `closeButtonRef` props on Modal. The advantage of passing -a reference to the button is that if the button is disabled, the key press will -not fire the event. +`primaryButtonRef` and `closeButtonRef` props on Modal. The advantage +of passing a reference to the button is that if the button is disabled, +the key press or the mouse click will not fire the event. + +As `primaryButtonRef` and `closeButtonRef` are used for more than just +actions mentioned above, you can explicitly disable the default behavior +by changing `allowCloseOnBackdropClick`, `allowCloseOnEscapeKey` or +`allowPrimaryActionOnEnterKey` to `false`. 👉 We strongly recommend using this feature together with Autofocus for a better user experience. @@ -839,7 +807,241 @@ is focused. Autofocus is enabled by default, so if you want to control the focus of elements manually, set the `autoFocus` prop on Modal to `false`. -## Scrolling Long Content +## Use Cases + +### Interaction blocking + +Modal can be used to block user interaction while an action is being +performed. + +```docoff-react-preview +React.createElement(() => { + const [modalOpen, setModalOpen] = React.useState(null); + const modalPrimaryButtonRef = React.useRef(); + const modalCloseButtonRef = React.useRef(); + {/* + The `preventScrollUnderneath` feature is necessary for Modals to work in + React UI docs. You may not need it in your application. + */} + return ( + + { + setModalOpen(1); + setTimeout(() => setModalOpen(null), 2500); + }} + /> + { + setModalOpen(2); + setTimeout(() => setModalOpen(null), 3500); + }} + /> + + {modalOpen === 1 && ( + + + + + Application is being loaded. + + + + + + + + )} + {modalOpen === 2 && ( + + + Action finished + + + + + Action has been successfully finished. + You will be redirected within a few seconds. + + + + + )} + + + ); +}); +``` + +### Forms + +Modal can be used to display forms. It is recommended to use +[FormLayout](/components/FormLayout) component to layout form fields. + +While we support only [controlled components][controlled-components], +and we encourage you to use them, it is possible to use native form and its +functionality inside the modal. This might be useful when you need to use +native form features like validation, submission, etc. + +To do so, you need to set `allowPrimaryActionOnEnterKey` to `false` and remove +`onClick` from the primary button. Then, you need to set `form` attribute on the +primary button to the `id` of the form to connect it with the form. + +```docoff-react-preview +React.createElement(() => { + const [modalOpen, setModalOpen] = React.useState(null); + const modalPrimaryButtonRef = React.useRef(); + const modalCloseButtonRef = React.useRef(); + {/* + The `preventScrollUnderneath` feature is necessary for Modals to work in + React UI docs. You may not need it in your application. + */} + return ( + + setModalOpen(1)} + /> + setModalOpen(2)} + /> + + {modalOpen === 1 && ( + + + Add new user + setModalOpen(false)} /> + + + + + + + + + + + {}} /> + Enter key is used for new line,so Enter won't submit the form.} + /> + + + + + setModalOpen(false)} + ref={modalPrimaryButtonRef} + /> + setModalOpen(false)} + priority="outline" + ref={modalCloseButtonRef} + /> + + + )} + {modalOpen === 2 && ( + { + console.log('cancel', e); + }} + onClose={(e) => { + console.log('close', e); + }} + primaryButtonRef={modalPrimaryButtonRef} + > + + Add new user using native form + setModalOpen(false)} /> + + + + + + + + + + + + + Enter key is used for new line,so Enter won't submit the form.} + /> + + + + + + + setModalOpen(false)} + priority="outline" + ref={modalCloseButtonRef} + /> + + + )} + + + ); +}); +``` + +### Scrolling Long Content When modals become too long for the user's viewport or device, they scroll independent of the page itself. This can be done in three ways using the @@ -1004,7 +1206,7 @@ React.createElement(() => { }); ``` -### Long Content and Autofocus +#### Long Content and Autofocus 👉 If you wrap ModalContent with ScrollView, you may want to turn `autoFocus` off to prevent the modal from scrolling to the end immediately after being @@ -1019,8 +1221,7 @@ can specify **any HTML attribute you like.** All attributes that don't interfere with the API of the React component and that aren't filtered out by [`transferProps`](/docs/js-helpers/transferProps) helper are forwarded to: -- the `` HTML element in case of the `Modal` component. This `` is not - the root, but its first child which represents the modal window. +- the `` HTML element in case of the `Modal` component. - the root `` HTML element in case of `ModalHeader`, `ModalBody`, `ModalContent` and `ModalFooter` components. - the heading (e.g. ``) HTML element in case of the `ModalTitle` component. @@ -1031,6 +1232,7 @@ accessibility. 👉 For the full list of supported attributes refer to: +- [`` HTML element attributes][dialog-attributes]{:target="_blank"} - [`` HTML element attributes][div-attributes]{:target="_blank"} - [``-`` HTML element attributes][heading-attributes]{:target="_blank"} - [`` HTML element attributes][button-attributes]{:target="_blank"} @@ -1066,29 +1268,47 @@ accessibility. ## Theming -| Custom Property | Description | -|------------------------------------------------------|---------------------------------------------------------------| -| `--rui-Modal__padding-x` | Inline padding of individual modal components | -| `--rui-Modal__padding-y` | Block padding of individual modal components | -| `--rui-Modal__background` | Modal background (including `url()` or gradient) | -| `--rui-Modal__box-shadow` | Modal box shadow | -| `--rui-Modal__separator__width` | Width of separator between modal header, body, and footer | -| `--rui-Modal__separator__color` | Color of separator between modal header, body, and footer | -| `--rui-Modal__outer-spacing-xs` | Spacing around modal, `xs` screen size | -| `--rui-Modal__outer-spacing-sm` | Spacing around modal, `sm` screen size and bigger | -| `--rui-Modal__header__gap` | Modal header gap between children | -| `--rui-Modal__footer__background` | Modal footer background (including `url()` or gradient) | -| `--rui-Modal__footer__gap` | Modal footer gap between children | -| `--rui-Modal__backdrop__background` | Modal backdrop background (including `url()` or gradient) | -| `--rui-Modal--auto__min-width` | Min width of auto-sized modal (when enough screen estate) | -| `--rui-Modal--auto__max-width` | Max width of auto-sized modal (when enough screen estate) | -| `--rui-Modal--small__width` | Width of small modal | -| `--rui-Modal--medium__width` | Width of medium modal | -| `--rui-Modal--large__width` | Width of large modal | -| `--rui-Modal--fullscreen__width` | Width of fullscreen modal | -| `--rui-Modal--fullscreen__height` | Height of fullscreen modal | +| Custom Property | Description | +|------------------------------------------------------|-------------------------------------------------------------| +| `--rui-Modal__padding-x` | Inline padding of individual modal components | +| `--rui-Modal__padding-y` | Block padding of individual modal components | +| `--rui-Modal__background` | Modal background (including `url()` or gradient) | +| `--rui-Modal__box-shadow` | Modal box shadow | +| `--rui-Modal__separator__width` | Width of separator between modal header, body, and footer | +| `--rui-Modal__separator__color` | Color of separator between modal header, body, and footer | +| `--rui-Modal__outer-spacing-xs` | Spacing around modal, `xs` screen size | +| `--rui-Modal__outer-spacing-sm` | Spacing around modal, `sm` screen size and bigger | +| `--rui-Modal__header__gap` | Modal header gap between children | +| `--rui-Modal__footer__background` | Modal footer background (including `url()` or gradient) | +| `--rui-Modal__footer__gap` | Modal footer gap between children | +| `--rui-Modal__backdrop__background` | Modal backdrop background (including `url()` or gradient) | +| `--rui-Modal--auto__min-width` | Min width of auto-sized modal (when enough screen estate) | +| `--rui-Modal--auto__max-width` | Max width of auto-sized modal (when enough screen estate) | +| `--rui-Modal--small__width` | Width of small modal | +| `--rui-Modal--medium__width` | Width of medium modal | +| `--rui-Modal--large__width` | Width of large modal | +| `--rui-Modal--fullscreen__width` | Width of fullscreen modal | +| `--rui-Modal--fullscreen__height` | Height of fullscreen modal | +| `--rui-Modal__animation__duration` | Duration of animation used (when opening modal) | + +### Theming Variants + +It's possible to adjust the theme of specific color variant. Naming convention +looks as follows: + +`--rui-Modal--__` + +Where: + +- `` is a value from supported + [color collections](/docs/foundation/collections#colors) + (check [color variants](#color-variants) and [API](#api) to see which + collections are supported), +- `` is one of `border-color` or `background-color`. [button-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attributes +[controlled-components]: /docs/getting-started/usage#foundation-css +[dialog-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#attributes [div-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div#attributes [heading-attributes]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#attributes [React common props]: https://react.dev/reference/react-dom/components/common#common-props diff --git a/src/components/Modal/__tests__/Modal.test.jsx b/src/components/Modal/__tests__/Modal.test.jsx index a4a0f50be..e32ffab35 100644 --- a/src/components/Modal/__tests__/Modal.test.jsx +++ b/src/components/Modal/__tests__/Modal.test.jsx @@ -6,6 +6,7 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { feedbackColorPropTest } from '../../../../tests/propTests/feedbackColorPropTest'; import { Button } from '../../..'; import { Modal } from '../Modal'; import { ModalBody } from '../ModalBody'; @@ -26,42 +27,39 @@ describe('rendering', () => { )); - expect(screen.getByTestId('portal-id').firstChild.firstChild).toHaveAttribute('id', 'id'); + expect(screen.getByTestId('portal-id').firstChild).toHaveAttribute('id', 'id'); document.body.innerHTML = ''; }); it.each([ + ...feedbackColorPropTest, [ { children: content text }, (rootElement) => expect(within(rootElement).getByText('content text')), ], [ { position: 'top' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootPositionTop'), - ], - [ - { position: 'center' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootPositionCenter'), + (rootElement) => expect(rootElement).toHaveClass('isRootPositionTop'), ], [ { size: 'small' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootSizeSmall'), + (rootElement) => expect(rootElement).toHaveClass('isRootSizeSmall'), ], [ { size: 'medium' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootSizeMedium'), + (rootElement) => expect(rootElement).toHaveClass('isRootSizeMedium'), ], [ { size: 'large' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootSizeLarge'), + (rootElement) => expect(rootElement).toHaveClass('isRootSizeLarge'), ], [ { size: 'fullscreen' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootSizeFullscreen'), + (rootElement) => expect(rootElement).toHaveClass('isRootSizeFullscreen'), ], [ { size: 'auto' }, - (rootElement) => expect(within(rootElement).getByRole('presentation')).toHaveClass('isRootSizeAuto'), + (rootElement) => expect(rootElement).toHaveClass('isRootSizeAuto'), ], ])('renders with props: "%s"', (testedProps, assert) => { const dom = render(( @@ -75,10 +73,7 @@ describe('rendering', () => { }); describe('functionality', () => { - it.each([ - () => userEvent.keyboard('{Escape}'), - () => userEvent.click(screen.getByTestId('id').parentNode), - ])('call close modal using `closeButtonRef` (%#)', async (action) => { + it('call close modal using `closeButtonRef` (%#)', async () => { const spy = jest.fn(); const ref = React.createRef(); render(( @@ -97,14 +92,11 @@ describe('functionality', () => { )); - await action(); + await userEvent.keyboard('{Escape}'); expect(spy).toHaveBeenCalled(); }); - it.each([ - () => userEvent.keyboard('{Escape}'), - () => userEvent.click(screen.getByTestId('id').parentNode), - ])('do not call close modal using `closeButtonRef` when button is disabled (%#)', async (action) => { + it('do not call close modal using `closeButtonRef` when button is disabled (%#)', async () => { const spy = jest.fn(); const ref = React.createRef(); render(( @@ -124,14 +116,11 @@ describe('functionality', () => { )); - await action(); + await userEvent.keyboard('{Escape}'); expect(spy).not.toHaveBeenCalled(); }); - it.each([ - () => userEvent.keyboard('{Escape}'), - () => userEvent.click(screen.getByTestId('id').parentNode), - ])('call close modal using `closeButtonRef` and `ModalCloseButton` (%#)', async (action) => { + it('call close modal using `closeButtonRef` and `ModalCloseButton` (%#)', async () => { const spy = jest.fn(); const ref = React.createRef(); render(( @@ -148,14 +137,11 @@ describe('functionality', () => { )); - await action(); + await userEvent.keyboard('{Escape}'); expect(spy).toHaveBeenCalled(); }); - it.each([ - () => userEvent.keyboard('{Escape}'), - () => userEvent.click(screen.getByTestId('id').parentNode), - ])('do not call close modal using `closeButtonRef` and `ModalCloseButton` when button is disabled (%#)', async (action) => { + it('do not call close modal using `closeButtonRef` and `ModalCloseButton` when button is disabled (%#)', async () => { const spy = jest.fn(); const ref = React.createRef(); render(( @@ -173,7 +159,7 @@ describe('functionality', () => { )); - await action(); + await userEvent.keyboard('{Escape}'); expect(spy).not.toHaveBeenCalled(); }); @@ -365,30 +351,6 @@ describe('functionality', () => { assertFocus(el, true); }); - it('traps focus', async () => { - const { container } = render(( - - - - - - - - - )); - - const firstEl = within(container).getByTestId('first'); - const secondEl = within(container).getByTestId('second'); - - assertFocus(firstEl, true); - await userEvent.tab(); - assertFocus(secondEl, true); - await userEvent.tab(); - assertFocus(firstEl, true); - await userEvent.tab({ shift: true }); - assertFocus(secondEl, true); - }); - it('prevents body from scrolling by default', () => { render(( diff --git a/src/components/Modal/__tests__/ModalTitle.test.jsx b/src/components/Modal/__tests__/ModalTitle.test.jsx index 59ad52417..5ee5fa881 100644 --- a/src/components/Modal/__tests__/ModalTitle.test.jsx +++ b/src/components/Modal/__tests__/ModalTitle.test.jsx @@ -30,7 +30,7 @@ describe('rendering', () => { level: 1, }, (rootElement) => { - expect(rootElement).toContainHTML(' { diff --git a/src/components/Modal/_animations.scss b/src/components/Modal/_animations.scss new file mode 100644 index 000000000..cb4e0be91 --- /dev/null +++ b/src/components/Modal/_animations.scss @@ -0,0 +1,9 @@ +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/src/components/Modal/_helpers/dialogOnCancelHandler.js b/src/components/Modal/_helpers/dialogOnCancelHandler.js new file mode 100644 index 000000000..8a64a4e51 --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnCancelHandler.js @@ -0,0 +1,28 @@ +// Disable coverage for the following function +/* istanbul ignore next */ + +/** + * Handles the cancel event of the dialog which is fired when the user presses the Escape key or triggers cancel event + * by native dialog mechanism. + * + * It prevents the default behaviour of the native dialog and closes the dialog manually by clicking the close button, + * if the close button is not disabled. + * + * @param e + * @param closeButtonRef + * @param onCancelHandler + */ +export const dialogOnCancelHandler = (e, closeButtonRef, onCancelHandler = undefined) => { + // Prevent the default behaviour of the event as we want to close dialog manually. + e.preventDefault(); + + // If the close button is not disabled, close the modal. + if (closeButtonRef?.current != null && closeButtonRef?.current?.disabled === false) { + closeButtonRef.current.click(); + } + + // This is a custom handler that is passed as a prop to the Modal component + if (onCancelHandler) { + onCancelHandler(e); + } +}; diff --git a/src/components/Modal/_helpers/dialogOnClickHandler.js b/src/components/Modal/_helpers/dialogOnClickHandler.js new file mode 100644 index 000000000..1f674b703 --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnClickHandler.js @@ -0,0 +1,46 @@ +// Disable coverage for the following function +/* istanbul ignore next */ + +/** + * Handles the click event of the dialog which is fired when the user clicks on the dialog or on its descendants. + * + * This handler is used to close the dialog when the user clicks on the backdrop, if it is allowed to close + * on backdrop click and the close button is not disabled. + * + * @param e + * @param closeButtonRef + * @param dialogRef + * @param allowCloseOnBackdropClick + */ +export const dialogOnClickHandler = ( + e, + closeButtonRef, + dialogRef, + allowCloseOnBackdropClick, +) => { + // If it is not allowed to close modal on backdrop click, do nothing. + if (!allowCloseOnBackdropClick) { + return; + } + + // Detection of the click on the backdrop is based on the following conditions: + // 1. The click target is the dialog itself. This prevents detection of clicks on the dialog's children. + // 2. The click is outside the dialog's boundaries. + const dialogRect = dialogRef.current.getBoundingClientRect(); + const isClickedOnBackdrop = dialogRef.current === e.target && ( + e.clientX < dialogRect.left + || e.clientX > dialogRect.right + || e.clientY < dialogRect.top + || e.clientY > dialogRect.bottom + ); + + // If user does not click on the backdrop, do nothing. + if (!isClickedOnBackdrop) { + return; + } + + // If the close button is not disabled, close the modal. + if (closeButtonRef?.current != null && closeButtonRef?.current?.disabled === false) { + closeButtonRef.current.click(); + } +}; diff --git a/src/components/Modal/_helpers/dialogOnCloseHandler.js b/src/components/Modal/_helpers/dialogOnCloseHandler.js new file mode 100644 index 000000000..e9712c40f --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnCloseHandler.js @@ -0,0 +1,28 @@ +// Disable coverage for the following function +/* istanbul ignore next */ + +/** + * Handles the close event of the dialog which is fired when the user presses the Escape key or triggers close event + * by native dialog mechanism. + * + * It prevents the default behaviour of the native dialog and closes the dialog manually by clicking the close button, + * if the close button is not disabled. + * + * @param e + * @param closeButtonRef + * @param onCloseHandler + */ +export const dialogOnCloseHandler = (e, closeButtonRef, onCloseHandler = undefined) => { + // Prevent the default behaviour of the event as we want to close dialog manually. + e.preventDefault(); + + // If the close button is not disabled, close the modal. + if (closeButtonRef?.current != null && closeButtonRef?.current?.disabled === false) { + closeButtonRef.current.click(); + } + + // This is a custom handler that is passed as a prop to the Modal component + if (onCloseHandler) { + onCloseHandler(e); + } +}; diff --git a/src/components/Modal/_helpers/dialogOnKeyDownHandler.js b/src/components/Modal/_helpers/dialogOnKeyDownHandler.js new file mode 100644 index 000000000..9afab1735 --- /dev/null +++ b/src/components/Modal/_helpers/dialogOnKeyDownHandler.js @@ -0,0 +1,62 @@ +// Disable coverage for the following function +/* istanbul ignore next */ + +/** + * Handles the keydown event of the dialog which is fired when the user presses a key within the dialog. + * + * This handler is used to stop propagation of the Escape key press, if it is not allowed to close + * on Escape key and the close button is disabled. + * + * It is also used to trigger the primary action when the user presses the Enter key, if it is allowed to trigger + * the primary action on Enter key and the primary button is not disabled. This applies only when the focused + * element is an input or select as other elements should not trigger the primary action. Textarea is omitted + * as Enter key is used for new line. + * + * @param e + * @param closeButtonRef + * @param primaryButtonRef + * @param allowCloseOnEscapeKey + * @param allowPrimaryActionOnEnterKey + */ +export const dialogOnKeyDownHandler = ( + e, + closeButtonRef, + primaryButtonRef, + allowCloseOnEscapeKey, + allowPrimaryActionOnEnterKey, +) => { + if (e.key === 'Escape') { + // Prevent closing the modal using the Escape key when one of the following conditions is met: + // 1. The close button is not present + // 2. The close button is disabled + // 3. `allowCloseOnEscapeKey` is set to `false` + // + // ⚠️ Else-if statement calling `closeButtonRef.current.click()` is necessary due to missing support + // of close event in happy-dom library. When this is fixed, the `else` statement can be removed + // as the `closeButtonRef.current.click()` will be handled by `dialogOnCancelHandler.js`. + if ( + closeButtonRef?.current == null + || closeButtonRef?.current?.disabled === true + || !allowCloseOnEscapeKey + ) { + e.preventDefault(); + } else if (process?.env?.NODE_ENV === 'test') { + closeButtonRef.current.click(); + } + } + + // Trigger the primary action when the Enter key is pressed and the following conditions are met: + // 1. The primary button is present + // 2. The primary button is not disabled + // 3. `allowPrimaryActionOnEnterKey` is set to `true` + // 4. The focused element is an input or select (text area is omitted as Enter key is used for new line) + if ( + e.key === 'Enter' + && primaryButtonRef?.current != null + && primaryButtonRef?.current?.disabled === false + && allowPrimaryActionOnEnterKey + && ['INPUT', 'SELECT'].includes(e.target.nodeName) + ) { + primaryButtonRef.current.click(); + } +}; diff --git a/src/components/Modal/_helpers/getPositionClassName.js b/src/components/Modal/_helpers/getPositionClassName.js index f022c7071..c2ef48967 100644 --- a/src/components/Modal/_helpers/getPositionClassName.js +++ b/src/components/Modal/_helpers/getPositionClassName.js @@ -3,5 +3,5 @@ export const getPositionClassName = (modalPosition, styles) => { return styles.isRootPositionTop; } - return styles.isRootPositionCenter; + return null; }; diff --git a/src/components/Modal/_hooks/useModalFocus.js b/src/components/Modal/_hooks/useModalFocus.js index aeb4a0f4c..643274469 100644 --- a/src/components/Modal/_hooks/useModalFocus.js +++ b/src/components/Modal/_hooks/useModalFocus.js @@ -2,9 +2,8 @@ import { useEffect } from 'react'; export const useModalFocus = ( autoFocus, - childrenWrapperRef, + dialogRef, primaryButtonRef, - closeButtonRef, ) => { useEffect( () => { @@ -12,115 +11,49 @@ export const useModalFocus = ( // field element (input, textarea or select) or primary button and focuses it. This is // necessary to have focus on one of those elements to be able to submit the form // by pressing Enter key. If there are neither, it tries to focus any other focusable - // elements. In case there are none or `autoFocus` is disabled, childrenWrapperElement + // elements. In case there are none or `autoFocus` is disabled, dialogElement // (Modal itself) is focused. - const childrenWrapperElement = childrenWrapperRef.current; + const dialogElement = dialogRef.current; - if (childrenWrapperElement == null) { + if (dialogElement == null) { return () => {}; } const childrenFocusableElements = Array.from( - childrenWrapperElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), + dialogElement.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), ); const firstFocusableElement = childrenFocusableElements[0]; - const lastFocusableElement = childrenFocusableElements[childrenFocusableElements.length - 1]; - const resolveFocusBeforeListener = () => { - if (!autoFocus || childrenFocusableElements.length === 0) { - childrenWrapperElement.tabIndex = -1; - childrenWrapperElement.focus(); - return; - } - - const firstFormFieldEl = childrenFocusableElements.find( - (element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled, - ); - - if (firstFormFieldEl) { - firstFormFieldEl.focus(); - return; - } - - if (primaryButtonRef?.current != null) { - primaryButtonRef.current.focus(); - return; - } - - firstFocusableElement.focus(); - }; - - const keyPressHandler = (e) => { - if (e.key === 'Escape' && closeButtonRef?.current != null) { - closeButtonRef.current.click(); - return; - } - - if ( - e.key === 'Enter' - && e.target.nodeName !== 'BUTTON' - && e.target.nodeName !== 'TEXTAREA' - && e.target.nodeName !== 'A' - && primaryButtonRef?.current != null - ) { - primaryButtonRef.current.click(); - return; - } - - // Following code traps focus inside Modal - - if (e.key !== 'Tab') { - return; - } - - if (childrenFocusableElements.length === 0) { - childrenWrapperElement.focus(); - e.preventDefault(); - return; - } - - if ( - ![ - ...childrenFocusableElements, - childrenWrapperElement, - ] - .includes(window.document.activeElement) - ) { - firstFocusableElement.focus(); - e.preventDefault(); - return; - } + if (!autoFocus || childrenFocusableElements.length === 0) { + dialogElement.tabIndex = -1; + dialogElement.focus(); + return () => {}; + } - if (!e.shiftKey && window.document.activeElement === lastFocusableElement) { - firstFocusableElement.focus(); - e.preventDefault(); - return; - } + const firstFormFieldEl = childrenFocusableElements.find( + (element) => ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && !element.disabled, + ); - if (e.shiftKey - && ( - window.document.activeElement === firstFocusableElement - || window.document.activeElement === childrenWrapperElement - ) - ) { - lastFocusableElement.focus(); - e.preventDefault(); - } - }; + if (firstFormFieldEl) { + firstFormFieldEl.focus(); + return () => {}; + } - resolveFocusBeforeListener(); + if (primaryButtonRef?.current != null && primaryButtonRef?.current?.disabled === false) { + primaryButtonRef.current.focus(); + return () => {}; + } - window.document.addEventListener('keydown', keyPressHandler, false); + firstFocusableElement.focus(); - return () => window.document.removeEventListener('keydown', keyPressHandler, false); + return () => {}; }, [ autoFocus, - childrenWrapperRef, + dialogRef, primaryButtonRef, - closeButtonRef, ], ); }; diff --git a/src/components/Modal/_settings.scss b/src/components/Modal/_settings.scss index db2a09f6c..88b026417 100644 --- a/src/components/Modal/_settings.scss +++ b/src/components/Modal/_settings.scss @@ -1,9 +1,10 @@ @use "sass:map"; -@use "../../styles/settings/z-indexes"; +@use "../../styles/settings/collections"; @use "../../styles/theme/borders"; @use "../../styles/theme/typography"; +$border-width: borders.$width; $border-radius: borders.$radius-2; -$z-index: z-indexes.$modal; -$backdrop-z-index: z-indexes.$modal-backdrop; $title-font-size: map.get(typography.$font-size-values, 2); +$colors: collections.$feedback-colors; +$themeable-properties: border-color, background-color; diff --git a/src/components/Modal/_theme.scss b/src/components/Modal/_theme.scss index 070fb4097..c4c22ec59 100644 --- a/src/components/Modal/_theme.scss +++ b/src/components/Modal/_theme.scss @@ -10,6 +10,7 @@ $footer-gap: var(--rui-Modal__footer__gap); $backdrop-background: var(--rui-Modal__backdrop__background); $outer-spacing-xs: var(--rui-Modal__outer-spacing--xs); $outer-spacing-sm: var(--rui-Modal__outer-spacing--sm); +$animation-duration: var(--rui-Modal__animation__duration); $sizes: ( auto: ( diff --git a/src/components/Paper/Paper.jsx b/src/components/Paper/Paper.jsx index 4780bd119..1dcf60371 100644 --- a/src/components/Paper/Paper.jsx +++ b/src/components/Paper/Paper.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import styles from './Paper.module.scss'; export const Paper = ({ diff --git a/src/components/Popover/Popover.jsx b/src/components/Popover/Popover.jsx index 44097e85f..74be059ee 100644 --- a/src/components/Popover/Popover.jsx +++ b/src/components/Popover/Popover.jsx @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import { createPortal } from 'react-dom'; +import { transferProps } from '../../helpers/transferProps'; +import { classNames } from '../../helpers/classNames'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; import cleanPlacementStyle from './_helpers/cleanPlacementStyle'; import getRootSideClassName from './_helpers/getRootSideClassName'; import getRootAlignmentClassName from './_helpers/getRootAlignmentClassName'; diff --git a/src/components/Popover/PopoverWrapper.jsx b/src/components/Popover/PopoverWrapper.jsx index 0c1c6e7b4..118d1ab3c 100644 --- a/src/components/Popover/PopoverWrapper.jsx +++ b/src/components/Popover/PopoverWrapper.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import styles from './PopoverWrapper.module.scss'; export const PopoverWrapper = ({ diff --git a/src/components/Radio/Radio.jsx b/src/components/Radio/Radio.jsx index 19f080c69..64b0a85e8 100644 --- a/src/components/Radio/Radio.jsx +++ b/src/components/Radio/Radio.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { resolveContextOrProp } from '../_helpers/resolveContextOrProp'; import { FormLayoutContext } from '../FormLayout'; diff --git a/src/components/Radio/__tests__/Radio.test.jsx b/src/components/Radio/__tests__/Radio.test.jsx index 2f586c424..dcc0685db 100644 --- a/src/components/Radio/__tests__/Radio.test.jsx +++ b/src/components/Radio/__tests__/Radio.test.jsx @@ -79,8 +79,8 @@ describe('rendering', () => { [ { options: mandatoryProps.options }, (rootElement) => { - expect(within(rootElement).getByLabelText('option 1')).not.toHaveAttribute('checked'); - expect(within(rootElement).getByLabelText('option 2')).not.toHaveAttribute('checked'); + expect(within(rootElement).getByLabelText('option 1')).not.toBeChecked(); + expect(within(rootElement).getByLabelText('option 2')).not.toBeChecked(); expect(within(rootElement).getByLabelText('option 2')).toBeDisabled(); }, ], @@ -93,14 +93,14 @@ describe('rendering', () => { onChange: () => {}, value: 'option2', }, - (rootElement) => expect(within(rootElement).getByLabelText('option 2')).toHaveAttribute('checked'), + (rootElement) => expect(within(rootElement).getByLabelText('option 2')).toBeChecked(), ], [ { onChange: () => {}, value: 1, }, - (rootElement) => expect(within(rootElement).getByLabelText('option 1')).toHaveAttribute('checked'), + (rootElement) => expect(within(rootElement).getByLabelText('option 1')).toBeChecked(), ], ])('renders with props: "%s"', (testedProps, assert) => { const dom = render(( diff --git a/src/components/ScrollView/ScrollView.jsx b/src/components/ScrollView/ScrollView.jsx index 87a73411c..6d51916a8 100644 --- a/src/components/ScrollView/ScrollView.jsx +++ b/src/components/ScrollView/ScrollView.jsx @@ -8,8 +8,8 @@ import React, { } from 'react'; import { TranslationsContext } from '../../providers/translations'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getElementsPositionDifference } from './_helpers/getElementsPositionDifference'; import { useLoadResize } from './_hooks/useLoadResizeHook'; import { useScrollPosition } from './_hooks/useScrollPositionHook'; diff --git a/src/components/SelectField/SelectField.jsx b/src/components/SelectField/SelectField.jsx index e8731e28a..587c7635a 100644 --- a/src/components/SelectField/SelectField.jsx +++ b/src/components/SelectField/SelectField.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { resolveContextOrProp } from '../_helpers/resolveContextOrProp'; diff --git a/src/components/Table/Table.jsx b/src/components/Table/Table.jsx index cb2e386ee..090a8e388 100644 --- a/src/components/Table/Table.jsx +++ b/src/components/Table/Table.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import { TableHeaderCell } from './_components/TableHeaderCell'; import { TableBodyCell } from './_components/TableBodyCell'; import styles from './Table.module.scss'; diff --git a/src/components/Tabs/Tabs.jsx b/src/components/Tabs/Tabs.jsx index 5d5b8ceb1..aa9357444 100644 --- a/src/components/Tabs/Tabs.jsx +++ b/src/components/Tabs/Tabs.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import styles from './Tabs.module.scss'; export const Tabs = ({ diff --git a/src/components/Tabs/TabsItem.jsx b/src/components/Tabs/TabsItem.jsx index 74ae2a56b..12502fc66 100644 --- a/src/components/Tabs/TabsItem.jsx +++ b/src/components/Tabs/TabsItem.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import styles from './TabsItem.module.scss'; export const TabsItem = ({ diff --git a/src/components/Text/Text.jsx b/src/components/Text/Text.jsx index 0800f04c2..73145380f 100644 --- a/src/components/Text/Text.jsx +++ b/src/components/Text/Text.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import { getRootClampClassName } from './_helpers/getRootClampClassName'; import { getRootHyphensClassName } from './_helpers/getRootHyphensClassName'; diff --git a/src/components/TextArea/TextArea.jsx b/src/components/TextArea/TextArea.jsx index 4d52943bb..b3c7de74d 100644 --- a/src/components/TextArea/TextArea.jsx +++ b/src/components/TextArea/TextArea.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { resolveContextOrProp } from '../_helpers/resolveContextOrProp'; diff --git a/src/components/TextField/TextField.jsx b/src/components/TextField/TextField.jsx index 9464ab59f..cfc40b7a5 100644 --- a/src/components/TextField/TextField.jsx +++ b/src/components/TextField/TextField.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootSizeClassName } from '../_helpers/getRootSizeClassName'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { resolveContextOrProp } from '../_helpers/resolveContextOrProp'; diff --git a/src/components/TextLink/TextLink.jsx b/src/components/TextLink/TextLink.jsx index b4ae9e50d..7053e1268 100644 --- a/src/components/TextLink/TextLink.jsx +++ b/src/components/TextLink/TextLink.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { transferProps } from '../../utils/transferProps'; +import { transferProps } from '../../helpers/transferProps'; import styles from './TextLink.module.scss'; export const TextLink = ({ diff --git a/src/components/Toggle/Toggle.jsx b/src/components/Toggle/Toggle.jsx index 44ff357c0..870ebf876 100644 --- a/src/components/Toggle/Toggle.jsx +++ b/src/components/Toggle/Toggle.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { useContext } from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { getRootValidationStateClassName } from '../_helpers/getRootValidationStateClassName'; import { FormLayoutContext } from '../FormLayout'; import styles from './Toggle.module.scss'; diff --git a/src/components/Toolbar/Toolbar.jsx b/src/components/Toolbar/Toolbar.jsx index 59ddc121d..f10b5a29e 100644 --- a/src/components/Toolbar/Toolbar.jsx +++ b/src/components/Toolbar/Toolbar.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import { getAlignClassName } from './_helpers/getAlignClassName'; import { getJustifyClassName } from './_helpers/getJustifyClassName'; diff --git a/src/components/Toolbar/ToolbarGroup.jsx b/src/components/Toolbar/ToolbarGroup.jsx index 8c4fd6252..f3085a0a6 100644 --- a/src/components/Toolbar/ToolbarGroup.jsx +++ b/src/components/Toolbar/ToolbarGroup.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withGlobalProps } from '../../providers/globalProps'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import { getAlignClassName } from './_helpers/getAlignClassName'; import styles from './Toolbar.module.scss'; diff --git a/src/components/Toolbar/ToolbarItem.jsx b/src/components/Toolbar/ToolbarItem.jsx index 31a7b77e0..6f44a6d0d 100644 --- a/src/components/Toolbar/ToolbarItem.jsx +++ b/src/components/Toolbar/ToolbarItem.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { classNames } from '../../utils/classNames'; -import { transferProps } from '../../utils/transferProps'; +import { classNames } from '../../helpers/classNames/classNames'; +import { transferProps } from '../../helpers/transferProps'; import { withGlobalProps } from '../../providers/globalProps'; import { isChildrenEmpty } from '../_helpers/isChildrenEmpty'; import styles from './Toolbar.module.scss'; diff --git a/src/docs/_assets/stylesheets/extra.css b/src/docs/_assets/stylesheets/extra.css index 8bd87351b..6a4f07d60 100644 --- a/src/docs/_assets/stylesheets/extra.css +++ b/src/docs/_assets/stylesheets/extra.css @@ -1,69 +1,96 @@ +/* stylelint-disable + custom-property-pattern, + declaration-no-important, + no-descending-specificity, + selector-class-pattern, + selector-max-compound-selectors, + selector-max-specificity, + selector-no-qualifying-type + -- We are overriding third-party styles. +*/ + :root { - /* Visual configuration of the `` and `` code */ - /* The Prism theme CSS file, for options see: https://unpkg.com/browse/prismjs/themes/ */ - /* NOTE: Patch version is omitted due to parsing bug in Safari: https://bugs.webkit.org/show_bug.cgi?id=229816 */ - --docoff-code-prism-css: https://unpkg.com/prismjs@1.29/themes/prism-twilight.min.css; - --docoff-code-font-size: 1rem; - --docoff-code-line-height: 1.5; - --docoff-code-font-family: "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace; - - /* Visual configuration of the `` live preview */ - --docoff-preview-border-color: #ced4de; - --docoff-preview-border-radius: 0.35em; - /* To simplify multiple component presentation we add margin to all top level elements */ - --docoff-preview-children-margin: 0.25em; - /* To improve component presentation we add padding inside the shadow DOM */ - --docoff-preview-padding: 1em; - /* Custom preview CSS file, typically this would be the CSS of your component */ - --docoff-preview-css: /docs/_assets/generated/react-ui.development.css; - - /* Visual configuration of the `` element */ - --docoff-placeholder-background-dark: #4d4d4d; - --docoff-placeholder-background-light: #fff; - --docoff-placeholder-border-color: #ccc; - --docoff-placeholder-border-width: 2px; + /* Visual configuration of the `` and `` code */ + + /* The Prism theme CSS file, for options see: https://unpkg.com/browse/prismjs/themes/ + NOTE: Patch version is omitted due to parsing bug in Safari: https://bugs.webkit.org/show_bug.cgi?id=229816 */ + /* stylelint-disable -- Allow URL without quotes. */ + --docoff-code-prism-css: https://unpkg.com/prismjs@1.29/themes/prism-twilight.min.css; + --docoff-code-font-size: 1rem; + /* stylelint-enable */ + + --docoff-code-line-height: 1.5; + --docoff-code-font-family: + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + monospace; + + /* Visual configuration of the `` live preview */ + --docoff-preview-border-color: #ced4de; + --docoff-preview-border-radius: 0.35em; + + /* To simplify multiple component presentation we add margin to all top level elements */ + --docoff-preview-children-margin: 0.25em; + + /* To improve component presentation we add padding inside the shadow DOM */ + --docoff-preview-padding: 1em; + + /* Custom preview CSS file, typically this would be the CSS of your component */ + --docoff-preview-css: /docs/_assets/generated/react-ui.development.css; + + /* Visual configuration of the `` element */ + --docoff-placeholder-background-dark: #4d4d4d; + --docoff-placeholder-background-light: #fff; + --docoff-placeholder-border-color: #ccc; + --docoff-placeholder-border-width: 2px; } /* Color schemes */ [data-md-color-scheme="default"] { - --md-primary-fg-color: #00778b; - --md-primary-fg-color--light: #fff; - --md-primary-fg-color--dark: #006b7d; - --md-accent-fg-color: #007bff; - --md-typeset-a-color: #007bff; - --md-typeset-a-hover-color: #006fe6; - --md-default-bg-color: #fff; - --md-typeset-color: #2d3747; - --md-code-bg-color: rgb(242, 242, 242); + --md-primary-fg-color: #00778b; + --md-primary-fg-color--light: #fff; + --md-primary-fg-color--dark: #006b7d; + --md-accent-fg-color: #007bff; + --md-typeset-a-color: #007bff; + --md-typeset-a-hover-color: #006fe6; + --md-default-bg-color: #fff; + --md-typeset-color: #2d3747; + --md-code-bg-color: rgb(242 242 242); } [data-md-color-scheme="slate"] { - --md-primary-fg-color: #1a1a1a; - --md-primary-fg-color--light: #fff; - --md-primary-fg-color--dark: #005f6f; - --md-accent-fg-color: #007bff; - --md-typeset-a-color: #007bff; - --md-typeset-a-hover-color: #ffede8; - --md-default-bg-color: #1a1a1a; - --md-typeset-color: #f2f2f2; - --md-code-bg-color: #000; + --md-primary-fg-color: #1a1a1a; + --md-primary-fg-color--light: #fff; + --md-primary-fg-color--dark: #005f6f; + --md-accent-fg-color: #007bff; + --md-typeset-a-color: #007bff; + --md-typeset-a-hover-color: #ffede8; + --md-default-bg-color: #1a1a1a; + --md-typeset-color: #f2f2f2; + --md-code-bg-color: #000; } /* Document */ html { - font-size: 100%; - scroll-margin-top: 4rem; - scroll-padding-top: 3.2rem; + font-size: 100%; + scroll-padding-top: 3.2rem; + scroll-margin-top: 4rem; } body { - font-size: 1rem; - background-color: var(--md-default-bg-color); + font-size: 1rem; + background-color: var(--md-default-bg-color); } /* Typography */ -code, kbd, pre { - font-family: var(--docoff-code-font-family); +code, +kbd, +pre { + font-family: var(--docoff-code-font-family); } .md-nav--primary .md-nav__item, @@ -81,7 +108,7 @@ code, kbd, pre { .md-search-result__terms, .md-source, .md-top { - font-size: 1rem; + font-size: 0.875rem; } .md-nav, @@ -92,318 +119,332 @@ code, kbd, pre { .md-typeset .footnote, .md-typeset .footnote-ref, .md-typeset .tabbed-labels > label { - font-size: 0.85rem; + font-size: 0.875rem; } .md-typeset { - font-size: 1.125rem; + font-size: 1rem; } .md-typeset a { - color: var(--md-typeset-a-color); - text-decoration: none; - text-underline-offset: 0.1875em; + text-decoration: none; + text-underline-offset: 0.1875em; + color: var(--md-typeset-a-color); } .md-typeset a:hover { - color: var(--md-typeset-a-hover-color); - text-decoration: underline; + text-decoration: underline; + color: var(--md-typeset-a-hover-color); } .md-typeset :is(h1, h2, h3, h4, h5, h6) { - font-weight: 700; + font-weight: 700; } .md-typeset h1 { - margin-top: 0.8rem; - margin-bottom: 2rem; - font-size: 3rem; - color: inherit; + margin-top: 0.8rem; + margin-bottom: 0.4rem; + font-weight: 800; + font-size: 2.4rem; + color: inherit; } .md-content .md-typeset h1 + p { - font-size: 1.5rem; + font-size: 1.3rem; } .md-typeset h2 { - margin-top: 3.2rem; - margin-bottom: 0.8rem; - font-size: 2rem; + margin-top: 3.2rem; + margin-bottom: 0.25rem; + font-size: 1.65rem; } .md-typeset h3 { - margin-top: 2.4rem; - margin-bottom: 0.8rem; - font-size: 1.75rem; + margin-top: 2.4rem; + margin-bottom: 0.15rem; + font-size: 1.45rem; } .md-typeset h4 { - margin-top: 1.6rem; - margin-bottom: 0.4rem; - font-size: 1.5rem; + margin-top: 1.6rem; + margin-bottom: 0.25rem; + font-size: 1.25rem; } + .md-typeset h5, .md-typeset h6 { - margin-top: 1.6rem; - margin-bottom: 0.4rem; - font-size: 1.25rem; + margin-top: 1.6rem; + margin-bottom: 0.25rem; + font-size: 1.125rem; } .md-typeset blockquote { - margin-block: 2.4rem; - padding: 1.6rem 2rem; - background-color: var(--md-default-fg-color--lightest); + padding: 1.6rem 2rem; + margin-block: 2.4rem; + background-color: var(--md-default-fg-color--lightest); } [dir="ltr"] .md-typeset blockquote { - padding-left: 2rem; + padding-left: 2rem; } .md-typeset blockquote p { - font-size: 1.25rem; + font-size: 1.25rem; } .md-typeset blockquote p:last-child { - margin-bottom: 0; + margin-bottom: 0; } .md-typeset ul { - list-style-type: square; + list-style-type: square; } .md-typeset :is(ol, ul) li { - margin-bottom: 0.25rem; + margin-bottom: 0.55rem; } .md-typeset ol li blockquote, .md-typeset ol li p, .md-typeset ul li blockquote, .md-typeset ul li p { - margin-top: 0; - margin-bottom: 0.8rem; + margin-block: 0; } .md-typeset hr { - margin-block: 3.2rem; + margin-block: 3.2rem; } /* Tables */ table { - width: 100%; - table-layout: auto; - display: table !important; + display: table !important; + table-layout: auto; + width: 100%; } .md-typeset__table { - min-width: 100%; + min-width: 100%; } .md-typeset table:not([class]) { - font-size: 1rem; - border: none; + font-size: 0.875rem; + border: none; } .md-typeset table:not([class]) :is(th, td) { - padding: 0.75rem 0.5rem; - vertical-align: middle; - border-style: none none solid; - border-width: 1px; - border-color: var(--md-typeset-table-color); + padding: 1rem 0.5rem; + vertical-align: middle; + border-width: 1px; + border-style: none none solid; + border-color: var(--md-typeset-table-color); } /* Layout */ .md-header { - height: 4rem; + height: 4rem; } .md-header__inner { - height: 100%; - padding: 0 0.5rem; + height: 100%; + padding: 0 0.5rem; } .md-header__title { - font-size: 1.5rem; + font-size: 1.5rem; } .md-header__button { - margin-inline: 0.75rem; + margin-inline: 0.75rem; } .md-main__inner { - margin-top: 0; + margin-top: 0; } .md-tabs__link, .md-source { - color: var(--md-primary-fg-color--light); + color: var(--md-primary-fg-color--light); } .md-tabs__link { - font-size: 1.25rem; - text-transform: uppercase; + font-size: 1.25rem; + text-transform: uppercase; } .md-header, .md-tabs { - background-color: var(--md-primary-fg-color--dark); + background-color: var(--md-primary-fg-color--dark); } .md-nav { - line-height: 1.5; + line-height: 1.5; } .md-nav__link--active { - font-weight: 700; + font-weight: 700; } .md-content__inner { - max-width: 50rem; - min-width: 0; - margin: 3.2rem 3rem 6.4rem; - padding-top: 0; + min-width: 0; + max-width: 50rem; + padding-top: 0; + margin: 3.2rem 3rem 6.4rem; } .md-sidebar { - padding-block: 1.6rem; + padding-block: 1.6rem; } -@media screen and (min-width: 76.25em) { - .md-grid { - max-width: 96%; - } +@media screen and (width >= 76.25em) { + .md-grid { + max-width: 96%; + } - .md-content { - display: grid; - justify-content: center; - } + .md-content { + display: grid; + justify-content: center; + } } -@media screen and (max-width: 59.9375em) { - .md-content__inner { - margin-inline: 8vw; - } +@media screen and (width <= 59.9375em) { + .md-content__inner { + margin-inline: 8vw; + } } -@media screen and (max-width: 76.1875em) { - .md-nav--primary .md-nav__title .md-nav__icon { - top: 1rem; - } +@media screen and (width <= 76.1875em) { + .md-nav--primary .md-nav__title .md-nav__icon { + top: 1rem; + } } -@media screen and (min-width: 76.25em) { - - [dir="ltr"] .md-sidebar--secondary:not([hidden]) ~ .md-content > .md-content__inner, - [dir="rtl"] .md-sidebar--primary:not([hidden]) ~ .md-content > .md-content__inner { - margin-right: 3rem; - } +@media screen and (width >= 76.25em) { + [dir="ltr"] .md-sidebar--secondary:not([hidden]) ~ .md-content > .md-content__inner, + [dir="rtl"] .md-sidebar--primary:not([hidden]) ~ .md-content > .md-content__inner { + margin-right: 3rem; + } - [dir="ltr"] .md-sidebar--primary:not([hidden]) ~ .md-content > .md-content__inner { - margin-left: 3rem; - } + [dir="ltr"] .md-sidebar--primary:not([hidden]) ~ .md-content > .md-content__inner { + margin-left: 3rem; + } } -@media screen and (max-width: 59.9375em) { - .md-nav__source { - padding-block: 0.8rem; - } +@media screen and (width <= 59.9375em) { + .md-nav__source { + padding-block: 0.8rem; + } } /* Search */ .md-search-result__meta { - padding-block: 0.8rem; + padding-block: 0.8rem; } .md-search-result__more > summary > div { - font-size: 0.85rem; + font-size: 0.85rem; } -@media screen and (min-width: 60em) { - .md-search { - padding: 0; - } +@media screen and (width >= 60em) { + .md-search { + padding: 0; + } - .md-search__form { - height: 2.4rem; - } + .md-search__form { + height: 2.4rem; + } - .md-search__icon[for="__search"], - .md-search__options { - top: 0.6rem; - } + .md-search__icon[for="__search"], + .md-search__options { + top: 0.6rem; + } - .md-search__output { - top: calc(100% - 2px); - } + .md-search__output { + top: calc(100% - 2px); + } - .md-search-result__meta { - padding-block: 0.8rem; - } + .md-search-result__meta { + padding-block: 0.8rem; + } } /* Clipboard */ .md-clipboard, .md-clipboard:is(:focus, :hover) { - color: white; + color: white; } .md-clipboard::after { - color: var(--md-typeset-a-color); + color: var(--md-typeset-a-color); } :hover > .md-clipboard { - color: white; + color: white; } /* Code */ .md-typeset code { - padding: 0.15rem 0.25rem; - border-radius: 0.1875rem; + padding: 0.15rem 0.25rem; + border-radius: 0.1875rem; } .md-typeset table pre > code { - display: inline; - padding: 0.15rem 0.25rem; - white-space: pre-wrap; + display: inline; + padding: 0.15rem 0.25rem; + white-space: pre-wrap; } .md-typeset pre > code { - padding: 0; + padding: 0; } pre[class*="language-"] { - background: rgb(40, 40, 40); - border-radius: 0.35em; - border: none; - overflow: hidden; + overflow: hidden; + border: none; + border-radius: 0.35em; + background: rgb(40 40 40); } code[class*="language-"], pre[class*="language-"] { - font-family: var(--docoff-code-font-family) !important; - font-weight: 400; - font-size: 1rem; - text-shadow: none; + font-weight: 400; + font-size: 1rem; + font-family: var(--docoff-code-font-family) !important; + text-shadow: none; } pre[class*="language-"], -.docoff-Root, +.docoff-Root { + margin-top: 0; + margin-bottom: 2.4rem; +} + .md-typeset :is(p, ul, ol, dl) { - margin-top: 0; - margin-bottom: 1.6rem; + max-width: 40rem; + margin-top: 0; + margin-bottom: 1.6rem; } /* API */ docoff-react-props { - display: block; - overflow-x: auto; + display: block; + overflow-x: auto; } -@media screen and (max-width: 44.9375em) { - docoff-react-props, - .docoff-Root, - .md-content__inner > .highlight { - margin-top: 0; - margin-bottom: 1.6rem; - margin-inline: -1rem; - } +docoff-react-props > table tr { + transition: background-color 125ms; +} + +docoff-react-props > table tr:hover { + background-color: var(--md-typeset-table-color--light); + box-shadow: 0 0.05rem 0 var(--md-default-bg-color) inset; +} + +@media screen and (width <= 44.9375em) { + docoff-react-props, + .docoff-Root, + .md-content__inner > .highlight { + margin-inline: -1rem; + margin-top: 0; + margin-bottom: 1.6rem; + } } diff --git a/src/docs/contribute/composition.md b/src/docs/contribute/composition.md index 2c116ef71..a48a0d683 100644 --- a/src/docs/contribute/composition.md +++ b/src/docs/contribute/composition.md @@ -8,16 +8,16 @@ There are several types of composition approaches. 2. **Components with subcomponents:** subcomponents cannot exist on their own outside their parent components. - - **Mandatory subcomponents:** subcomponent must be used at least once in - order for the composition to work. E.g. `Tabs` + `TabsItem`. + - **Mandatory subcomponents:** subcomponent must be used at least once in + order for the composition to work. E.g. `Tabs` + `TabsItem`. - - **Optional subcomponents:** optional subcomponents may be used to achieve - special results. E.g. `FormLayout` + `FormLayoutCustomField` or `Grid` + - `GridSpan`. + - **Optional subcomponents:** optional subcomponents may be used to achieve + special results. E.g. `FormLayout` + `FormLayoutCustomField` or `Grid` + + `GridSpan`. - - **Both mandatory and optional subcomponents:** e.g. `Card` + `CardBody` - (mandatory) + `CardFooter` (optional), `Toolbar` + `ToolbarItem` - (mandatory) + `ToolbarGroup` (optional), etc. + - **Both mandatory and optional subcomponents:** e.g. `Card` + `CardBody` + (mandatory) + `CardFooter` (optional), `Toolbar` + `ToolbarItem` + (mandatory) + `ToolbarGroup` (optional), etc. 3. **Wrappers for other components:** component is designed to wrap other self-contained components. E.g. `FormLayout` + form fields (`CheckboxField`, diff --git a/src/docs/contribute/css.md b/src/docs/contribute/css.md index c5b16eb87..96955214d 100644 --- a/src/docs/contribute/css.md +++ b/src/docs/contribute/css.md @@ -183,14 +183,14 @@ class. 3. **Component's top-level HTML element must have `root` class name.** However, this rule has a few exceptions: - 1. When the component is a subcomponent, it's usually better to use - subcomponent's name, e.g. `item` or `group`. This enables us to keep - related CSS of both the main component and its subcomponents in a single - file and see the big picture during development. - - 2. When no CSS on the root element is necessary and styling only takes place - once a visual modification is invoked by component props, `root` class - name can be omitted entirely. + 1. When the component is a subcomponent, it's usually better to use + subcomponent's name, e.g. `item` or `group`. This enables us to keep + related CSS of both the main component and its subcomponents in a single + file and see the big picture during development. + + 2. When no CSS on the root element is necessary and styling only takes place + once a visual modification is invoked by component props, `root` class + name can be omitted entirely. 4. **Modifier class names related to the current HTML element must start with `is`** and contain the name of the target element, e.g. `isRootLoading` diff --git a/src/docs/customize/font.md b/src/docs/customize/font.md new file mode 100644 index 000000000..f8943b922 --- /dev/null +++ b/src/docs/customize/font.md @@ -0,0 +1,28 @@ +# Font + +React UI uses [native font stack][sm-native-font-stack] for optimum text +rendering on every device and OS. + +This is a good practice because it reduces the size of the data transferred, and +it also ensures that the text is displayed in the font that the user is most +comfortable with. + +You can change it to a custom font by loading the font in your project: + +```html + +``` + +… and [overriding](/docs/customize/theming/overview) the +`--rui-font-family-base` CSS custom property: + +```css +:root { + --rui-font-family-base: 'Titillium Web', helvetica, roboto, arial, sans-serif; +} +``` + +[sm-native-font-stack]: https://www.smashingmagazine.com/2015/11/using-system-ui-fonts-practical-guide/ diff --git a/src/docs/foundation/collections.md b/src/docs/foundation/collections.md index 62a6e08bf..323e29e52 100644 --- a/src/docs/foundation/collections.md +++ b/src/docs/foundation/collections.md @@ -17,8 +17,8 @@ used to ensure consistency across the design system. The following color names are designed for use in components that support the `color` prop: -| Collection | Available values | -|------------|--------------------------------------------------------| -| Action | `primary`, `secondary`, `selected` | -| Feedback | `success`, `warning`, `danger`, `info`, `help`, `note` | -| Neutral | `light`, `dark` | +| Collection | Available values | Description | +|------------|--------------------------------------------------------|------------------------------------------------------------------------------------| +| Action | `primary`, `secondary`, `selected` | Reserved for actionable elements, such as buttons and navigation links | +| Feedback | `success`, `warning`, `danger`, `info`, `help`, `note` | For components with feedback state, such as alerts and buttons | +| Neutral | `light`, `dark` | For components that require a neutral background color, such as badges and buttons | diff --git a/src/docs/foundation/typography.md b/src/docs/foundation/typography.md index a1294713d..50c213664 100644 --- a/src/docs/foundation/typography.md +++ b/src/docs/foundation/typography.md @@ -59,16 +59,11 @@ Styling of basic HTML elements: ## Font -React UI is designed with [Titillium Web] font, a geometric sans with a wide -variety of weights and styles. You can change it to a custom font by -[overriding](/docs/customize/theming/overview) the `--rui-font-family-base` CSS -custom property: +React UI uses [native font stack][sm-native-font-stack] for optimum text +rendering on every device and OS. -```css -:root { - --rui-font-family-base: 'Titillium Web', helvetica, roboto, arial, sans-serif; -} -``` +👉 You can replace the native font stack with a +[custom font](/docs/customize/font). ## Customization @@ -101,4 +96,4 @@ Font size, font weight, and line height values can be } ``` -[Titillium Web]: https://fonts.google.com/specimen/Titillium+Web +[sm-native-font-stack]: https://www.smashingmagazine.com/2015/11/using-system-ui-fonts-practical-guide/ diff --git a/src/docs/getting-started/usage.md b/src/docs/getting-started/usage.md index c9b1a238a..2f74989ef 100644 --- a/src/docs/getting-started/usage.md +++ b/src/docs/getting-started/usage.md @@ -11,21 +11,6 @@ add the responsive viewport meta tag to your `` element: ``` -## Fonts - -React UI is designed with the Titillium Web font. Add it to your project e.g. via -Google Fonts in your `` element: - -```html - -``` - -Remember to include all necessary font weights (and only them — for better -performance). - ## CSS React UI styles are written in Sass and compiled to CSS. You can import them @@ -80,10 +65,6 @@ Example HTML: React UI Example - diff --git a/src/docs/js-helpers/classnames.md b/src/docs/js-helpers/classnames.md deleted file mode 100644 index 3f66fbcc5..000000000 --- a/src/docs/js-helpers/classnames.md +++ /dev/null @@ -1,28 +0,0 @@ -# Classnames - -The `classNames` helper joins all classnames you pass into the function as -single classname. It automatically filters out empty strings and values that -are not strings, so you can conditionally assemble classnames and `classNames` -function will take care about the single format of classname for you. - -## Basic Usage - -To use `classNames` helper, you need to import it first: - -```js -import { classNames } from '@react-ui-org/react-ui'; -``` - -And use it: - -```docoff-react-preview - 1609455600 && 'text-warning', - Date.now() > 1622498400 ? 'text-secondary' : null, - )} -> - {(new Date()).toLocaleDateString()} - -``` diff --git a/src/helpers/classNames/README.md b/src/helpers/classNames/README.md new file mode 100644 index 000000000..b8b6f306c --- /dev/null +++ b/src/helpers/classNames/README.md @@ -0,0 +1,65 @@ +# classNames + +The `classNames` helper function simplifies creating a string passable to +the `class` / `className` attribute. + +It accepts multiple arguments, filters out invalid values, and returns a single +string where the remaining parameters are joined by a space. + +## Usage + +To use `classNames` helper, you need to import it first: + +```js +import { classNames } from '@react-ui-org/react-ui'; +``` + +And use it: + +```docoff-react-preview +<> + + {(new Date()).toLocaleDateString()} + + + {(new Date()).toLocaleDateString()} + +> +``` + +## Parameter Filtering + +The `classNames` function: + +* filters out all values that are not strings +* filters out empty strings +* filters out whitespace only strings + + +```docoff-react-preview +{classNames( + 'class-1', + 'class-2 class-3', + ' ', + ' ', // non-breakable space + ' ', // tab + '', + 0, + 1, + null, + undefined, + true, + false, +)} +``` + diff --git a/src/utils/__tests__/classNames.test.js b/src/helpers/classNames/__tests__/classNames.test.js similarity index 53% rename from src/utils/__tests__/classNames.test.js rename to src/helpers/classNames/__tests__/classNames.test.js index 02061094b..030ef8566 100644 --- a/src/utils/__tests__/classNames.test.js +++ b/src/helpers/classNames/__tests__/classNames.test.js @@ -6,6 +6,9 @@ describe('classNames', () => { 'class-1', 'class-2 class-3', ' ', + ' ', // non=-breakable space + ' ', // eslint-disable-line no-tabs + '', 0, 1, null, @@ -17,17 +20,14 @@ describe('classNames', () => { expect(result).toEqual('class-1 class-2 class-3'); }); - it('returns undefined if all class names are filtered out', () => { - const result = classNames( - ' ', - ' ', - 0, - 1, - null, - undefined, - true, - false, - ); + it('returns `undefined` if called with no params', () => { + const result = classNames(); + + expect(result).toEqual(undefined); + }); + + it('returns `undefined` if called with params that get all filtered out', () => { + const result = classNames(false); expect(result).toEqual(undefined); }); diff --git a/src/helpers/classNames/classNames.js b/src/helpers/classNames/classNames.js new file mode 100644 index 000000000..e1f7d69ae --- /dev/null +++ b/src/helpers/classNames/classNames.js @@ -0,0 +1,11 @@ +export const classNames = (...classes) => { + const filteredClassNames = classes.filter( + (className) => typeof className === 'string' + && className.trim().length > 0, + ); + + return filteredClassNames.length > 0 + ? filteredClassNames.join(' ') + // React does not render attributes whose value is `undefined` and we do not want an empty `class` attribute in HTML + : undefined; +}; diff --git a/src/helpers/classNames/index.js b/src/helpers/classNames/index.js new file mode 100644 index 000000000..b27a95c76 --- /dev/null +++ b/src/helpers/classNames/index.js @@ -0,0 +1 @@ +export { classNames } from './classNames'; diff --git a/src/docs/js-helpers/transferProps.md b/src/helpers/transferProps/README.md similarity index 97% rename from src/docs/js-helpers/transferProps.md rename to src/helpers/transferProps/README.md index 3cb698855..0c6618d3c 100644 --- a/src/docs/js-helpers/transferProps.md +++ b/src/helpers/transferProps/README.md @@ -1,4 +1,4 @@ -# Transferring Props +# transferProps The `transferProps` helper controls passing of props from the React component to the HTML element. diff --git a/src/utils/__tests__/transferProps.test.js b/src/helpers/transferProps/__tests__/transferProps.test.js similarity index 100% rename from src/utils/__tests__/transferProps.test.js rename to src/helpers/transferProps/__tests__/transferProps.test.js diff --git a/src/helpers/transferProps/index.js b/src/helpers/transferProps/index.js new file mode 100644 index 000000000..7e3d117d6 --- /dev/null +++ b/src/helpers/transferProps/index.js @@ -0,0 +1 @@ +export { transferProps } from './transferProps'; diff --git a/src/utils/transferProps.js b/src/helpers/transferProps/transferProps.js similarity index 100% rename from src/utils/transferProps.js rename to src/helpers/transferProps/transferProps.js diff --git a/src/index.js b/src/index.js index 8436ff781..061a7ac31 100644 --- a/src/index.js +++ b/src/index.js @@ -61,6 +61,6 @@ export { export { GlobalPropsProvider } from './providers/globalProps'; export { TranslationsProvider } from './providers/translations'; -// Utils -export { classNames } from './utils/classNames'; -export { transferProps } from './utils/transferProps'; +// Helpers +export { classNames } from './helpers/classNames'; +export { transferProps } from './helpers/transferProps'; diff --git a/src/styles/elements/_links.scss b/src/styles/elements/_links.scss index 724a92315..573e3fccc 100644 --- a/src/styles/elements/_links.scss +++ b/src/styles/elements/_links.scss @@ -1,17 +1,5 @@ -@use "../theme/links"; +@use "../tools/links"; a { - text-decoration: links.$decoration; - text-underline-offset: links.$underline-offset; - color: links.$color; - - &:hover { - text-decoration: links.$hover-decoration; - color: links.$hover-color; - } - - &:active { - text-decoration: links.$active-decoration; - color: links.$active-color; - } + @include links.base(); } diff --git a/src/styles/generic/_focus.scss b/src/styles/generic/_focus.scss index ce8f0f37f..6d8f537a1 100644 --- a/src/styles/generic/_focus.scss +++ b/src/styles/generic/_focus.scss @@ -6,6 +6,6 @@ outline: none; } -:is(a, button, input, select, textarea, [type="button"], [type="submit"]) { +:is(a, button, input, select, textarea, [type="button"], [type="submit"]):focus-visible { @include accessibility.focus-ring(); } diff --git a/src/styles/settings/_z-indexes.scss b/src/styles/settings/_z-indexes.scss deleted file mode 100644 index 8af49f161..000000000 --- a/src/styles/settings/_z-indexes.scss +++ /dev/null @@ -1,2 +0,0 @@ -$modal-backdrop: 2000; -$modal: 2100; diff --git a/src/styles/theme/_form-fields.scss b/src/styles/theme/_form-fields.scss index f7cd2b5b2..bf3f71d98 100644 --- a/src/styles/theme/_form-fields.scss +++ b/src/styles/theme/_form-fields.scss @@ -33,15 +33,15 @@ $horizontal-full-width-label-width: var(--rui-FormField--horizontal--full-width_ // Form fields: links in validation states $link-validation-colors: ( invalid: ( + default: var(--rui-color-feedback-danger), + hover: var(--rui-color-feedback-danger-hover), + active: var(--rui-color-feedback-danger-active), + ), + valid: ( default: var(--rui-color-feedback-success), hover: var(--rui-color-feedback-success-hover), active: var(--rui-color-feedback-success-active), ), - valid: ( - default: var(--rui-color-feedback-valid), - hover: var(--rui-color-feedback-valid-hover), - active: var(--rui-color-feedback-valid-active), - ), warning: ( default: var(--rui-color-feedback-warning), hover: var(--rui-color-feedback-warning-hover), diff --git a/src/styles/tools/_accessibility.scss b/src/styles/tools/_accessibility.scss index 1b27a3985..8da3c190e 100644 --- a/src/styles/tools/_accessibility.scss +++ b/src/styles/tools/_accessibility.scss @@ -45,9 +45,7 @@ } @mixin focus-ring() { - &:focus-visible { - outline: theme.$focus-outline; - outline-offset: theme.$focus-outline-offset; - box-shadow: theme.$focus-box-shadow; - } + outline: theme.$focus-outline; + outline-offset: theme.$focus-outline-offset; + box-shadow: theme.$focus-box-shadow; } diff --git a/src/styles/tools/_collections.scss b/src/styles/tools/_collections.scss index 1fe3a690f..dc0cbf9e9 100644 --- a/src/styles/tools/_collections.scss +++ b/src/styles/tools/_collections.scss @@ -25,22 +25,6 @@ + ")"; } -// Function to get the matching link color for a component variant. -// -// @param {String} $value - The value to get the link color for. - -@function _get-link-color-by-value($value) { - @if $value == "light" { - @return "dark"; - } - - @if $value == "dark" { - @return "light"; - } - - @return $value; -} - // Mixin to generate CSS custom properties for a component theme. // // 1. Generates a CSS custom property for each property in the `$properties` list. @@ -133,11 +117,10 @@ @mixin generate-link-properties($prefix, $variant-value) { $color-category: _get-category-by-value($value: $variant-value, $collections: collections.$colors); - $resolved-variant-value: _get-link-color-by-value($variant-value); - --#{$prefix}local-link-color: var(--rui-color-#{$color-category}-#{$resolved-variant-value}); - --#{$prefix}local-link-color-hover: var(--rui-color-#{$color-category}-#{$resolved-variant-value}-hover); - --#{$prefix}local-link-color-active: var(--rui-color-#{$color-category}-#{$resolved-variant-value}-active); + --#{$prefix}local-link-color: var(--rui-color-#{$color-category}-#{$variant-value}); + --#{$prefix}local-link-color-hover: var(--rui-color-#{$color-category}-#{$variant-value}-hover); + --#{$prefix}local-link-color-active: var(--rui-color-#{$color-category}-#{$variant-value}-active); } // Mixin to generate CSS classes for a component variant. diff --git a/src/styles/tools/_links.scss b/src/styles/tools/_links.scss new file mode 100644 index 000000000..19d8a4961 --- /dev/null +++ b/src/styles/tools/_links.scss @@ -0,0 +1,17 @@ +@use "../theme/links"; + +@mixin base() { + text-decoration: links.$decoration; + text-underline-offset: links.$underline-offset; + color: links.$color; + + &:hover { + text-decoration: links.$hover-decoration; + color: links.$hover-color; + } + + &:active { + text-decoration: links.$active-decoration; + color: links.$active-color; + } +} diff --git a/src/styles/tools/form-fields/_box-field-elements.scss b/src/styles/tools/form-fields/_box-field-elements.scss index 5bf4256c0..65051f0ba 100644 --- a/src/styles/tools/form-fields/_box-field-elements.scss +++ b/src/styles/tools/form-fields/_box-field-elements.scss @@ -4,6 +4,7 @@ // 3. Let inputs properly fit various layout scenarios. // 4. Leave out space for SelectField caret. // 5. Use a block-level display mode to prevent extra white space below grouped inputs in Safari. +// 6. Pull out the focused input from the group. @use "../../settings/form-fields" as settings; @use "../../theme/form-fields" as theme; @@ -18,24 +19,29 @@ max-width: 100%; // 3. } -@mixin input() { +@mixin base() { @include transition.add((opacity, color, border-color, background-color, box-shadow)); - appearance: none; width: theme.$box-input-width; min-width: theme.$box-input-min-width; max-width: 100%; // 3. height: var(--rui-local-height); padding: var(--rui-local-padding-y) var(--rui-local-padding-x); + color: var(--rui-local-color); + border: theme.$box-border-width solid var(--rui-local-border-color); + border-radius: theme.$box-border-radius; + background: var(--rui-local-background); +} + +@mixin input() { + @include base(); + + appearance: none; font-weight: settings.$box-input-font-weight; font-size: var(--rui-local-font-size); line-height: settings.$box-input-line-height; font-family: settings.$box-input-font-family; vertical-align: middle; - color: var(--rui-local-color); - border: theme.$box-border-width solid var(--rui-local-border-color); - border-radius: theme.$box-border-radius; - background: var(--rui-local-background); box-shadow: var(--rui-local-box-shadow); &::placeholder { @@ -124,17 +130,23 @@ } } -@mixin in-group-layout() { +@mixin in-group-layout($input-element-selector: ".input") { + // 6. + &:focus-within { + isolation: isolate; + z-index: 1; + } + .inputContainer { display: block; // 5. } - &:not(:first-child) .input { + &:not(:first-child) #{$input-element-selector} { border-start-start-radius: var(--rui-local-inner-border-radius); border-end-start-radius: var(--rui-local-inner-border-radius); } - &:not(:last-child) .input { + &:not(:last-child) #{$input-element-selector} { border-start-end-radius: var(--rui-local-inner-border-radius); border-end-end-radius: var(--rui-local-inner-border-radius); } diff --git a/src/styles/tools/form-fields/_box-field-layout.scss b/src/styles/tools/form-fields/_box-field-layout.scss index e76386730..1ab157f1d 100644 --- a/src/styles/tools/form-fields/_box-field-layout.scss +++ b/src/styles/tools/form-fields/_box-field-layout.scss @@ -166,14 +166,14 @@ } } -@mixin full-width() { +@mixin full-width($input-element-selector: ".input") { display: flex; flex-direction: column; width: 100%; .field, .inputContainer, - .input { + #{$input-element-selector} { width: 100%; } diff --git a/src/styles/tools/form-fields/_box-field-sizes.scss b/src/styles/tools/form-fields/_box-field-sizes.scss index 4049d9ff3..d12bb0d79 100644 --- a/src/styles/tools/form-fields/_box-field-sizes.scss +++ b/src/styles/tools/form-fields/_box-field-sizes.scss @@ -1,22 +1,18 @@ @use "sass:map"; @use "../../theme/form-fields" as theme; -@mixin size($size, $has-input: true, $is-multiline: false) { +@mixin size($size, $is-multiline: false) { $size-properties: map.get(theme.$box-sizes, $size); + --rui-local-height: #{map.get($size-properties, height)}; --rui-local-padding-y: #{map.get($size-properties, padding-y)}; --rui-local-padding-x: #{map.get($size-properties, padding-x)}; + --rui-local-font-size: #{map.get($size-properties, font-size)}; - @if $has-input { - --rui-local-font-size: #{map.get($size-properties, font-size)}; - + @if $is-multiline { .input { - @if $is-multiline { - height: auto; - min-height: map.get($size-properties, height); - } @else { - --rui-local-height: #{map.get($size-properties, height)}; - } + height: auto; + min-height: map.get($size-properties, height); } } } diff --git a/src/styles/tools/form-fields/_variants.scss b/src/styles/tools/form-fields/_variants.scss index d132eecb4..7b03eba48 100644 --- a/src/styles/tools/form-fields/_variants.scss +++ b/src/styles/tools/form-fields/_variants.scss @@ -13,11 +13,6 @@ @use "../../settings/form-fields" as settings; @use "../../theme/form-fields" as theme; -@mixin _disabled-state() { - opacity: theme.$disabled-opacity; - cursor: theme.$disabled-cursor; -} - // 1. @mixin _generate-custom-properties($type, $variant, $state) { @if not list.index(map.keys(map.get(settings.$themeable-variant-states, $type)), $variant) { @@ -113,11 +108,16 @@ } } +@mixin disabled-state() { + opacity: theme.$disabled-opacity; + cursor: theme.$disabled-cursor; +} + @mixin visual($type, $variant: "default", $has-caret: false) { @include _get-theme($type, $variant); .input:disabled { - @include _disabled-state(); + @include disabled-state(); } @if $type == "box" and $variant == "filled" { @@ -137,15 +137,15 @@ } &.isRootDisabled .caret { - @include _disabled-state(); + @include disabled-state(); } } } @mixin validation($variant) { - --rui-local-link-color: map.get(theme.$link-validation-colors, $variant, default); - --rui-local-link-color-hover: map.get(theme.$link-validation-colors, $variant, hover); - --rui-local-link-color-active: map.get(theme.$link-validation-colors, $variant, active); + --rui-local-link-color: #{map.get(theme.$link-validation-colors, $variant, default)}; + --rui-local-link-color-hover: #{map.get(theme.$link-validation-colors, $variant, hover)}; + --rui-local-link-color-active: #{map.get(theme.$link-validation-colors, $variant, active)}; @include _get-theme(validation, $variant); } diff --git a/src/theme.scss b/src/theme.scss index 8415f20bf..d9291a104 100644 --- a/src/theme.scss +++ b/src/theme.scss @@ -49,7 +49,31 @@ // Font Families // ============= - --rui-font-family-base: "Titillium Web", helvetica, roboto, arial, sans-serif; + // Native font stack inspired by Bootstrap. + // @see https://getbootstrap.com/docs/5.0/content/reboot/#native-font-stack + // @see https://css-tricks.com/snippets/css/system-font-stack/ + --rui-font-family-base: + /* Cross-platform generic font family (default user interface font) */ + system-ui, + /* Safari for macOS and iOS (San Francisco)*/ + -apple-system, + /* Windows*/ + "Segoe UI", + /* Android*/ + roboto, + /* Basic web fallback*/ + "Helvetica Neue", + arial, + /* Linux*/ + "Noto Sans", + "Liberation Sans", + /* Sans serif fallback*/ + sans-serif, + /* Emoji fonts*/ + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; --rui-font-family-monospace: "SFMono-Regular", "Menlo", @@ -803,6 +827,7 @@ --rui-Card__padding: var(--rui-dimension-space-4); --rui-Card__border-width: var(--rui-dimension-border-width-1); --rui-Card__border-radius: var(--rui-dimension-radius-2); + --rui-Card__background-color: var(--rui-color-background-layer-1); --rui-Card--dense__padding: var(--rui-dimension-space-2); --rui-Card--raised__box-shadow: var(--rui-shadow-layer-1); --rui-Card--disabled__background-color: var(--rui-color-background-disabled); @@ -1012,6 +1037,31 @@ --rui-Modal--large__width: 60rem; --rui-Modal--fullscreen__width: 100%; --rui-Modal--fullscreen__height: 100%; + --rui-Modal__animation__duration: 0.25s; + + // Modal: success variant + --rui-Modal--success__border-color: var(--rui-color-feedback-success); + --rui-Modal--success__background-color: var(--rui-color-background-success); + + // Modal: warning variant + --rui-Modal--warning__border-color: var(--rui-color-feedback-warning); + --rui-Modal--warning__background-color: var(--rui-color-background-warning); + + // Modal: danger variant + --rui-Modal--danger__border-color: var(--rui-color-feedback-danger); + --rui-Modal--danger__background-color: var(--rui-color-background-danger); + + // Modal: info variant + --rui-Modal--info__border-color: var(--rui-color-feedback-info); + --rui-Modal--info__background-color: var(--rui-color-background-info); + + // Modal: help variant + --rui-Modal--help__border-color: var(--rui-color-feedback-help); + --rui-Modal--help__background-color: var(--rui-color-background-help); + + // Modal: note variant + --rui-Modal--note__border-color: var(--rui-color-feedback-note); + --rui-Modal--note__background-color: var(--rui-color-background-note); // // Paper diff --git a/src/translations/en.js b/src/translations/en.js index a51e7aeea..2fd451c57 100644 --- a/src/translations/en.js +++ b/src/translations/en.js @@ -2,6 +2,11 @@ export default { Alert: { close: 'Close', }, + FileInputField: { + browse: 'Browse', + drop: 'or drop file here', + filesSelected: 'files selected', + }, ModalCloseButton: { close: 'Close', }, diff --git a/src/utils/classNames.js b/src/utils/classNames.js deleted file mode 100644 index 0d26a6e96..000000000 --- a/src/utils/classNames.js +++ /dev/null @@ -1,8 +0,0 @@ -export const classNames = (...classes) => { - const filteredClassNames = classes.filter( - (className) => typeof className === 'string' - && className.trim().length > 0, - ); - - return filteredClassNames.length > 0 ? filteredClassNames.join(' ') : undefined; -}; diff --git a/tests/propTests/tagPropTest.js b/tests/propTests/tagPropTest.js index ca89973c1..4cc80505b 100644 --- a/tests/propTests/tagPropTest.js +++ b/tests/propTests/tagPropTest.js @@ -1,6 +1,6 @@ export const tagPropTest = [ [ { tag: 'section' }, - (rootElement) => expect(rootElement).toContainHTML(' expect(rootElement.tagName).toEqual('SECTION'), ], ]; diff --git a/webpack.config.babel.js b/webpack.config.babel.js index c9263f81b..9e7312eca 100644 --- a/webpack.config.babel.js +++ b/webpack.config.babel.js @@ -4,8 +4,8 @@ const StyleLintPlugin = require('stylelint-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const VisualizerPlugin = require('webpack-visualizer-plugin2'); -const MAX_DEVELOPMENT_OUTPUT_SIZE = 3300000; -const MAX_PRODUCTION_OUTPUT_SIZE = 440000; +const MAX_DEVELOPMENT_OUTPUT_SIZE = 3400000; +const MAX_PRODUCTION_OUTPUT_SIZE = 450000; module.exports = (env, argv) => ({ devtool: argv.mode === 'production'
- Application is being loaded. - - - -
- Action has been successfully finished. - You will be redirected within a few seconds. -
- Do you really want to delete the user admin? - This cannot be undone. -
admin
+ Application is being loaded. + + + +
+ Action has been successfully finished. + You will be redirected within a few seconds. +