diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5593984949..36964ddc1f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,6 +53,13 @@ jobs: name: dev-pages-react${{ matrix.react }} path: pages/lib/static-default + - name: Upload test utils selectors artifact + if: matrix.react == 18 + uses: actions/upload-artifact@v4 + with: + name: test-utils-selectors + path: lib/components + deploy: needs: quick-build name: deploy${{ matrix.react != 16 && format(' (React {0})', matrix.react) || '' }} @@ -65,3 +72,14 @@ jobs: with: artifact-name: dev-pages-react${{ matrix.react }} deployment-path: pages/lib/static-default + + visual: + name: Visual regression + needs: quick-build + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + uses: ./.github/workflows/visual-regression.yml + secrets: inherit + with: + pr-artifact-name: dev-pages-react18 + test-utils-artifact-name: test-utils-selectors + caller-run-id: ${{ github.run_id }} diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml new file mode 100644 index 0000000000..b7eddf67d4 --- /dev/null +++ b/.github/workflows/visual-regression.yml @@ -0,0 +1,194 @@ +name: Visual Regression Tests + +on: + workflow_call: + inputs: + pr-artifact-name: + description: 'Name of the artifact containing PR pages (built by the caller workflow).' + required: true + type: string + test-utils-artifact-name: + description: 'Name of the artifact containing test-utils selectors.' + required: true + type: string + caller-run-id: + description: 'The run ID of the calling workflow, used to download artifacts it uploaded.' + required: true + type: string + +defaults: + run: + shell: bash + +permissions: + id-token: write + contents: read + actions: read + deployments: write + +jobs: + # Build the baseline (main branch) pages once and share them across all browser jobs. + build-baseline: + name: Build baseline pages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm i + + # Use a git worktree so the baseline has its own directory and its own + # node_modules. This means a PR that changes package-lock.json will still + # produce a correct baseline: the baseline installs from main's lockfile + # and the PR build installs from the PR's lockfile, so both sides use the + # dependency versions that are correct for their respective source trees. + - name: Create baseline worktree from origin/main + run: git worktree add /tmp/baseline origin/main + + - name: Install baseline dependencies + run: npm i + working-directory: /tmp/baseline + + - name: Build baseline pages + run: npx gulp quick-build + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Bundle baseline pages + run: node_modules/.bin/webpack --config pages/webpack.config.integ.cjs --output-path ${{ github.workspace }}/pages/lib/static-visual-baseline + working-directory: /tmp/baseline + env: + NODE_ENV: production + + - name: Upload baseline artifact + uses: actions/upload-artifact@v4 + with: + name: visual-baseline-pages + path: pages/lib/static-visual-baseline + retention-days: 1 + + visual: + name: Visual regression (shard ${{ matrix.shard }}) + needs: [build-baseline] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Set up Chrome and ChromeDriver + uses: browser-actions/setup-chrome@v1 + with: + chrome-version: stable + + - name: Install dependencies + run: npm i + + - name: Download PR pages artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.pr-artifact-name }} + path: pages/lib/static-default + github-token: ${{ github.token }} + run-id: ${{ inputs.caller-run-id }} + + - name: Download baseline artifact + uses: actions/download-artifact@v4 + with: + name: visual-baseline-pages + path: pages/lib/static-visual-baseline + + - name: Download test utils artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.test-utils-artifact-name }} + path: lib/components + github-token: ${{ github.token }} + run-id: ${{ inputs.caller-run-id }} + + # ── Run tests ───────────────────────────────────────────────────────── + + - name: Start test server (port 8080) + run: npx --yes serve --no-clipboard --listen 8080 pages/lib/static-default & + + - name: Start baseline server (port 8081) + run: npx --yes serve --no-clipboard --listen 8081 pages/lib/static-visual-baseline & + + - name: Wait for servers to be ready + run: node_modules/.bin/wait-on http://localhost:8080 http://localhost:8081 + + - name: Run visual regression tests + run: NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js --shard=${{ matrix.shard }}/1 + env: + TZ: UTC + + - name: Upload diff artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-diffs-shard-${{ matrix.shard }} + path: visual-regression-output/ + retention-days: 14 + + - name: Upload Allure results + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-results-shard-${{ matrix.shard }} + path: allure-results/ + retention-days: 3 + + report: + name: Generate Allure Report + if: always() + needs: [visual] + runs-on: ubuntu-latest + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Download all Allure results + uses: actions/download-artifact@v4 + with: + pattern: allure-results-shard-* + path: allure-results + merge-multiple: true + + - name: Generate Allure HTML report + run: npx --yes allure generate allure-results -o allure-report + + - name: Upload Allure report artifact + uses: actions/upload-artifact@v4 + with: + name: allure-report + path: allure-report/ + retention-days: 14 + + deploy-report: + name: Deploy Allure Report + if: always() + needs: [report] + uses: cloudscape-design/actions/.github/workflows/deploy.yml@main + secrets: inherit + with: + artifact-name: allure-report + deployment-path: allure-report diff --git a/.gitignore b/.gitignore index db3d62b944..9b14cf8ced 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ coverage lib # generated sources src/index.ts +allure-results +allure-report src/test-utils/dom/index.ts src/test-utils/selectors src/icon/generated diff --git a/build-tools/tasks/package-json.js b/build-tools/tasks/package-json.js index a7c69a53f5..2c447682a1 100644 --- a/build-tools/tasks/package-json.js +++ b/build-tools/tasks/package-json.js @@ -103,6 +103,10 @@ const devPagesPackageJson = generatePackageJson(path.join(workspace.targetPath, const testDefinitionsPackageJson = generatePackageJson(path.join(workspace.targetPath, 'test-definitions'), { name: '@cloudscape-design/test-definitions', + exports: { + '.': './index.js', + './types': './types.js', + }, }); module.exports = parallel([ diff --git a/build-tools/visual/global-setup.js b/build-tools/visual/global-setup.js new file mode 100644 index 0000000000..52ce2f271c --- /dev/null +++ b/build-tools/visual/global-setup.js @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +module.exports = async () => { + const { startWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); + await startWebdriver(); +}; diff --git a/build-tools/visual/global-teardown.js b/build-tools/visual/global-teardown.js new file mode 100644 index 0000000000..0fa05eebfe --- /dev/null +++ b/build-tools/visual/global-teardown.js @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +module.exports = () => { + const { shutdownWebdriver } = require('@cloudscape-design/browser-test-tools/chrome-launcher'); + shutdownWebdriver(); +}; diff --git a/build-tools/visual/setup.js b/build-tools/visual/setup.js new file mode 100644 index 0000000000..d52cd606fb --- /dev/null +++ b/build-tools/visual/setup.js @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +/* global jest */ +const { configure } = require('@cloudscape-design/browser-test-tools/use-browser'); + +configure({ + browserName: 'ChromeHeadlessIntegration', + browserCreatorOptions: { + seleniumUrl: 'http://localhost:9515', + }, + webdriverOptions: { + baseUrl: 'http://localhost:8080', + }, +}); + +jest.retryTimes(2, { logErrorsBeforeRetry: true }); diff --git a/docs/RUNNING_TESTS.md b/docs/RUNNING_TESTS.md index 525cdf181d..4db82e361b 100644 --- a/docs/RUNNING_TESTS.md +++ b/docs/RUNNING_TESTS.md @@ -60,11 +60,60 @@ TZ=UTC npx jest -u -c jest.unit.config.js src/ ``` ## Visual Regression Tests -> **Note:** The components repository does not have visual regression tests on GitHub. This section applies to other repositories such as chat-components, code-view, chart-components, and board-components. +Visual regression tests run automatically when opening a pull request in GitHub (see `.github/workflows/visual-regression.yml`). -Visual regression tests for permutation pages run automatically when opening a pull request in GitHub. +They compare permutation pages between the PR build and a baseline build of `main`, both served locally in the same CI job. Each side installs from its own `package-lock.json` via a git worktree, so dependency changes in the PR are handled correctly and unpinned updates in sister repositories affect both sides equally. -To check results: look at the "Visual Regression Tests" action in the PR. The "Test for regressions" step logs which pages failed. For a full report, download the `visual-regression-snapshots-results` artifact from the action summary. +### How it works -If there are unexpected regressions, fix your pull request. -If the changes are expected, call this out in your pull request comments. +1. The PR pages are built and served on port 8080. +2. A git worktree of `origin/main` is created, its dependencies installed, and its pages built and served on port 8081. +3. The single test runner (`test/visual.test.ts`) iterates over all test definitions, captures the `.screenshot-area` element from both servers for each test, and fails if any pixels differ. + +### Running locally + +``` +npm run test:visual +``` + +This handles the full build and comparison in one command. If both outputs are already built, skip the build step: + +``` +NODE_OPTIONS=--experimental-vm-modules node_modules/.bin/jest -c jest.visual.config.js +``` + +(Requires both servers to be running — start the PR build with `npm run start:integ` on port 8080 and the baseline build on port 8081, or set `NEW_HOST` / `OLD_HOST` env vars to point at different hosts.) + +### Adding tests for a new component + +Create `test/definitions/visual/.ts`: + +```ts +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'my-component', + tests: [ + { + description: 'permutations', + path: 'my-component/permutations', + }, + ], +}; + +export default suite; +``` + +Then run the generation script to pick it up automatically: + +```bash +node build-tools/visual/generate-tests.js +``` + +This generates both the test runner (`test/visual/my-component.test.ts`) and updates `test/definitions/index.ts`. No manual imports needed. + +### Reviewing failures + +If the CI job fails, download the `visual-regression-diffs` artifact from the Actions summary. + +If the diff is expected (intentional visual change), note it in your PR description. diff --git a/eslint.config.mjs b/eslint.config.mjs index 0d9423aa5b..b92a169696 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -225,7 +225,7 @@ export default tsEslint.config( }, }, { - files: ['**/__integ__/**', '**/__motion__/**', '**/__a11y__/**'], + files: ['**/__integ__/**', '**/__motion__/**', '**/__a11y__/**', 'test/definitions/**'], rules: { // useBrowser is not a hook 'react-hooks/rules-of-hooks': 'off', diff --git a/jest.visual.config.js b/jest.visual.config.js new file mode 100644 index 0000000000..98938768b3 --- /dev/null +++ b/jest.visual.config.js @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const path = require('path'); +const os = require('os'); + +module.exports = { + verbose: true, + testEnvironment: 'allure-jest/node', + testEnvironmentOptions: { + resultsDir: 'allure-results', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.visual.json', + }, + ], + }, + reporters: ['default', 'github-actions'], + testTimeout: 240_000, // 4min — pages can be tall and slow to capture + maxWorkers: os.cpus().length * (process.env.GITHUB_ACTION ? 3 : 1), + globalSetup: '/build-tools/visual/global-setup.js', + globalTeardown: '/build-tools/visual/global-teardown.js', + setupFilesAfterEnv: [path.join(__dirname, 'build-tools', 'visual', 'setup.js')], + moduleFileExtensions: ['js', 'ts'], + testMatch: ['/test/visual/**/*.test.ts'], +}; diff --git a/package-lock.json b/package-lock.json index 1c7a32ecfd..9ccb757b17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", "@types/node": "^20.17.14", + "@types/pixelmatch": "^5.2.6", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-is": "^18.2.0", @@ -60,6 +61,7 @@ "@types/react-test-renderer": "^16.9.12", "@types/react-transition-group": "^4.4.4", "@types/webpack-env": "^1.16.3", + "allure-jest": "^3.9.0", "axe-core": "^4.7.2", "babel-jest": "^29.7.0", "change-case": "^4.1.2", @@ -96,6 +98,7 @@ "mockdate": "^3.0.5", "npm-run-all": "^4.1.5", "prettier": "^3.6.1", + "puppeteer-core": "^24.43.1", "react": "^16.14.0", "react-dom": "^16.14.0", "react-dom18": "npm:react-dom@^18.3.1", @@ -3521,9 +3524,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", - "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4846,6 +4849,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pixelmatch": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz", + "integrity": "sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pngjs": { "version": "6.0.5", "dev": true, @@ -5892,6 +5905,58 @@ "dev": true, "license": "MIT" }, + "node_modules/allure-jest": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/allure-jest/-/allure-jest-3.9.0.tgz", + "integrity": "sha512-hEW4DKjvb3engGoHUPQaDEdyrFkUxQnqULiSQAehL1eDEggqdPbQro86Nch8Cj1yuIqUTn9UP1FMuuuwl/5jnQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "allure-js-commons": "3.9.0" + }, + "peerDependencies": { + "jest": ">=24.8.0", + "jest-circus": ">=24.8.0", + "jest-cli": ">=24.8.0", + "jest-environment-jsdom": ">=24.8.0", + "jest-environment-node": ">=24.8.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "jest-circus": { + "optional": true + }, + "jest-cli": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "jest-environment-node": { + "optional": true + } + } + }, + "node_modules/allure-js-commons": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/allure-js-commons/-/allure-js-commons-3.9.0.tgz", + "integrity": "sha512-uVQcGE6MWIvGR/zW1XEUwHXUQa1EJKY0Cah+0TZK1qKuw6ptyhftDr34XE3wExTyCZirRrI98dbRtPeYYuyI+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "md5": "^2.3.0" + }, + "peerDependencies": { + "allure-playwright": "3.9.0" + }, + "peerDependenciesMeta": { + "allure-playwright": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "dev": true, @@ -7154,6 +7219,16 @@ "node": ">=10" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/cheerio": { "version": "1.1.0", "dev": true, @@ -7262,6 +7337,20 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "dev": true, @@ -7848,6 +7937,16 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/css-declaration-sorter": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", @@ -8778,6 +8877,13 @@ "dev": true, "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/diff-sequences": { "version": "29.6.3", "dev": true, @@ -12559,6 +12665,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-builtin-module": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", @@ -15238,6 +15351,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "dev": true, @@ -17735,6 +17860,25 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "dev": true, @@ -19238,7 +19382,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -21147,6 +21293,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typedarray": { "version": "0.0.6", "dev": true, @@ -21664,6 +21817,13 @@ "node": ">=18.20.0" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webdriver/node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -22441,6 +22601,16 @@ "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index fda995324a..b672732db3 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", "@types/node": "^20.17.14", + "@types/pixelmatch": "^5.2.6", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-is": "^18.2.0", @@ -83,6 +84,7 @@ "@types/react-test-renderer": "^16.9.12", "@types/react-transition-group": "^4.4.4", "@types/webpack-env": "^1.16.3", + "allure-jest": "^3.9.0", "axe-core": "^4.7.2", "babel-jest": "^29.7.0", "change-case": "^4.1.2", @@ -119,6 +121,7 @@ "mockdate": "^3.0.5", "npm-run-all": "^4.1.5", "prettier": "^3.6.1", + "puppeteer-core": "^24.43.1", "react": "^16.14.0", "react-dom": "^16.14.0", "react-dom18": "npm:react-dom@^18.3.1", diff --git a/pages/collection-preferences/content-display-groups.page.tsx b/pages/collection-preferences/content-display-groups.page.tsx new file mode 100644 index 0000000000..90291499b4 --- /dev/null +++ b/pages/collection-preferences/content-display-groups.page.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences'; +import SpaceBetween from '~components/space-between'; + +import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; +import { + baseProperties, + contentDisplayGroups, + groupedContentDisplay, + groupedContentDisplayOptions, +} from './shared-configs'; + +export default function ContentDisplayGroupsPage() { + const [preferences, setPreferences] = useState({ + contentDisplay: groupedContentDisplay, + }); + + return ( + +

Content Display with Groups

+ + setPreferences(detail)} + contentDisplayPreference={{ + title: 'Column preferences', + description: 'Customize column visibility and order.', + options: groupedContentDisplayOptions, + groups: contentDisplayGroups, + enableColumnFiltering: true, + ...contentDisplayPreferenceI18nStrings, + }} + /> + + Current preferences.contentDisplay +
+        {JSON.stringify(preferences.contentDisplay, null, 2)}
+      
+
+ ); +} diff --git a/pages/collection-preferences/shared-configs.tsx b/pages/collection-preferences/shared-configs.tsx index f474598fe8..17d254856d 100644 --- a/pages/collection-preferences/shared-configs.tsx +++ b/pages/collection-preferences/shared-configs.tsx @@ -96,3 +96,56 @@ export const customPreference = (customState: boolean) => ( View as ); + +export const groupedContentDisplayOptions: CollectionPreferencesProps.ContentDisplayOption[] = [ + { id: 'id', label: 'Instance ID', alwaysVisible: true }, + { id: 'name', label: 'Name' }, + { id: 'type', label: 'Instance type' }, + { id: 'az', label: 'Availability zone' }, + { id: 'state', label: 'State' }, + { id: 'cpu', label: 'CPU (%)' }, + { id: 'memory', label: 'Memory (%)' }, + { id: 'netIn', label: 'Network in (MB/s)' }, + { id: 'netOut', label: 'Network out (MB/s)' }, + { id: 'cost', label: 'Monthly cost ($)' }, +]; + +export const contentDisplayGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [ + { id: 'config', label: 'Configuration' }, + { id: 'performance', label: 'Performance' }, + { id: 'network', label: 'Network' }, +]; + +export const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, +]; diff --git a/pages/common/i18n-strings.ts b/pages/common/i18n-strings.ts index 5b6c57edda..5a0f52d93c 100644 --- a/pages/common/i18n-strings.ts +++ b/pages/common/i18n-strings.ts @@ -16,6 +16,7 @@ export const contentDisplayPreferenceI18nStrings: Partial `${label}, ${count} ${count === 1 ? 'item' : 'items'}`, i18nStrings: { columnFilteringPlaceholder: 'Filter columns', columnFilteringAriaLabel: 'Filter columns', diff --git a/pages/pagination/simple.page.tsx b/pages/pagination/simple.page.tsx new file mode 100644 index 0000000000..68a9a460ba --- /dev/null +++ b/pages/pagination/simple.page.tsx @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import Pagination, { PaginationProps } from '~components/pagination'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const paginationLabels: PaginationProps.Labels = { + nextPageLabel: 'Next page', + previousPageLabel: 'Previous page', + pageLabel: pageNumber => `Page ${pageNumber} of all pages`, + jumpToPageButton: 'Go to page', +}; + +const i18nStrings: PaginationProps.I18nStrings = { + jumpToPageInputLabel: 'Page number', + jumpToPageError: 'Enter a valid page number', + jumpToPageLoadingText: 'Loading page', +}; + +export default function PaginationSimplePage() { + const [basicPageIndex, setBasicPageIndex] = useState(1); + const [jumpPageIndex, setJumpPageIndex] = useState(1); + + return ( + +

Pagination simple

+ +

Basic pagination with 20 pages

+ setBasicPageIndex(event.detail.currentPageIndex)} + /> + +

Basic pagination with jump to page

+ setJumpPageIndex(event.detail.currentPageIndex)} + /> +
+
+ ); +} diff --git a/pages/table/column-groups.page.tsx b/pages/table/column-groups.page.tsx new file mode 100644 index 0000000000..34327f6175 --- /dev/null +++ b/pages/table/column-groups.page.tsx @@ -0,0 +1,440 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { + Box, + FormField, + Header, + Input, + Link, + Pagination, + Select, + SpaceBetween, + Table, + TableProps, + TextFilter, + Toggle, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +// ============================================================================ +// Data +// ============================================================================ + +interface Instance { + id: string; + name: string; + type: string; + az: string; + state: string; + cpu: number; + memory: number; + netIn: number; + netOut: number; + cost: number; +} + +const TYPES = ['t3.medium', 't3.large', 'r5.xlarge', 'c5.large', 'p3.2xlarge']; +const AZS = ['us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d']; +const STATES = ['running', 'stopped', 'pending']; + +const allInstances: Instance[] = Array.from({ length: 35 }, (_, i) => ({ + id: `i-${String(i + 1).padStart(3, '0')}`, + name: `instance-${i + 1}`, + type: TYPES[i % TYPES.length], + az: AZS[i % AZS.length], + state: STATES[i % STATES.length], + cpu: +(Math.random() * 100).toFixed(1), + memory: +(Math.random() * 100).toFixed(1), + netIn: Math.round(Math.random() * 10000), + netOut: Math.round(Math.random() * 10000), + cost: +(Math.random() * 500).toFixed(2), +})); + +// ============================================================================ +// Column & group definitions +// ============================================================================ + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'id', + header: 'Instance ID', + cell: item => {item.id}, + sortingField: 'id', + isRowHeader: true, + }, + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, + { id: 'az', header: 'AZ', cell: item => item.az, sortingField: 'az' }, + { id: 'state', header: 'State', cell: item => item.state, sortingField: 'state' }, + { id: 'cpu', header: 'CPU (%)', cell: item => `${item.cpu}%`, sortingField: 'cpu' }, + { id: 'memory', header: 'Memory (%)', cell: item => `${item.memory}%`, sortingField: 'memory' }, + { id: 'netIn', header: 'Network in', cell: item => item.netIn.toLocaleString(), sortingField: 'netIn' }, + { id: 'netOut', header: 'Network out', cell: item => item.netOut.toLocaleString(), sortingField: 'netOut' }, + { id: 'cost', header: 'Cost ($)', cell: item => `$${item.cost}`, sortingField: 'cost' }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'performance', header: 'Performance' }, + { id: 'network', header: 'Network' }, + { id: 'metrics', header: 'Metrics' }, +]; + +// ============================================================================ +// Column display presets +// ============================================================================ + +type GroupingPreset = 'flat' | 'single-level' | 'nested' | 'single-child-groups'; + +const columnDisplayPresets: Record = { + flat: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + { id: 'cost', visible: true }, + ], + 'single-level': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, + ], + nested: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + ], + }, + { id: 'cost', visible: true }, + ], + 'single-child-groups': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', id: 'config', visible: true, children: [{ id: 'type', visible: true }] }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + { id: 'memory', visible: true }, + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + { id: 'cost', visible: true }, + ], +}; + +const presetOptions = [ + { value: 'single-level', label: 'Single-level groups' }, + { value: 'nested', label: 'Nested groups (3 levels)' }, + { value: 'single-child-groups', label: 'Single-child groups' }, + { value: 'flat', label: 'Without grouping' }, +]; + +// ============================================================================ +// Page component +// ============================================================================ + +type DemoContext = React.Context< + AppContextType<{ + groupingPreset: GroupingPreset; + variant: TableProps.Variant; + selectionType: string; + resizable: boolean; + stickyHeader: boolean; + stickyHeaderOffset: number; + firstSticky: number; + lastSticky: number; + wrapLines: boolean; + stripedRows: boolean; + contentDensity: string; + enableKeyboardNavigation: boolean; + loading: boolean; + empty: boolean; + cellVerticalAlign: string; + sortingDisabled: boolean; + }> +>; + +export default function ColumnGroupsPage() { + const { + urlParams: { + groupingPreset = 'single-level' as GroupingPreset, + variant = 'container' as TableProps.Variant, + selectionType = 'multi', + resizable = true, + stickyHeader = false, + stickyHeaderOffset = 0, + firstSticky = 0, + lastSticky = 0, + wrapLines = false, + stripedRows = false, + contentDensity = 'comfortable', + enableKeyboardNavigation = true, + loading = false, + empty = false, + cellVerticalAlign = 'middle', + sortingDisabled = false, + }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + const [columnDisplay, setColumnDisplay] = useState( + columnDisplayPresets[groupingPreset] + ); + + const tableItems = empty ? [] : allInstances; + + const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(tableItems, { + filtering: { + empty: No instances, + noMatch: No matches, + }, + pagination: { pageSize: 25 }, + sorting: {}, + selection: {}, + }); + + return ( + +
+ Feature controls +
+ + + + setUrlParams({ variant: detail.selectedOption.value as TableProps.Variant })} + ariaLabel="Table variant" + /> + + + setUrlParams({ cellVerticalAlign: detail.selectedOption.value! })} + ariaLabel="Cell vertical align" + /> + + + + + + setUrlParams({ firstSticky: +detail.value })} + value={String(firstSticky)} + inputMode="numeric" + type="number" + /> + + + setUrlParams({ lastSticky: +detail.value })} + value={String(lastSticky)} + inputMode="numeric" + type="number" + /> + + + setUrlParams({ stickyHeaderOffset: +detail.value })} + value={String(stickyHeaderOffset)} + inputMode="numeric" + type="number" + /> + + + + + setUrlParams({ resizable: detail.checked })}> + Resizable columns + + setUrlParams({ stickyHeader: detail.checked })}> + Sticky header + + setUrlParams({ wrapLines: detail.checked })}> + Wrap lines + + setUrlParams({ stripedRows: detail.checked })}> + Striped rows + + setUrlParams({ contentDensity: detail.checked ? 'compact' : 'comfortable' })} + > + Compact mode + + setUrlParams({ enableKeyboardNavigation: detail.checked })} + > + Keyboard navigation + + setUrlParams({ sortingDisabled: detail.checked })} + > + Sorting disabled + + setUrlParams({ loading: detail.checked })}> + Loading state + + setUrlParams({ empty: detail.checked })}> + Empty state + + + + } + > + `${selectedItems.length} items selected`, + itemSelectionLabel: (_, item) => `Select ${item.name}`, + }} + header={
Instances
} + filter={ + + } + pagination={} + empty={No instances} + /> + + ); +} diff --git a/pages/table/progressive-loading.page.tsx b/pages/table/progressive-loading.page.tsx new file mode 100644 index 0000000000..c8e9cf2e4b --- /dev/null +++ b/pages/table/progressive-loading.page.tsx @@ -0,0 +1,668 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Grid from '~components/grid'; +import Header from '~components/header'; +import Input from '~components/input'; +import Pagination from '~components/pagination'; +import Skeleton from '~components/skeleton'; +import SpaceBetween from '~components/space-between'; +import Spinner from '~components/spinner'; +import StatusIndicator from '~components/status-indicator'; +import Table from '~components/table'; +import TextFilter from '~components/text-filter'; +import Toggle from '~components/toggle'; + +import { useAppContext } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface DataItem { + id: string; + name: string; + description: string; + status: string; + date: string; +} + +const sampleData: DataItem[] = [ + { + id: 'item-1', + name: 'Production Database', + description: 'Primary production database instance', + status: 'Active', + date: '2026-04-15', + }, + { + id: 'item-2', + name: 'Development Environment', + description: 'Testing and development environment', + status: 'Active', + date: '2026-04-14', + }, + { + id: 'item-3', + name: 'Analytics Pipeline', + description: 'Data processing pipeline for analytics', + status: 'Pending', + date: '2026-04-13', + }, + { + id: 'item-4', + name: 'Backup Server', + description: 'Secondary backup server for disaster recovery', + status: 'Active', + date: '2026-04-12', + }, +]; + +const statuses = ['Active', 'Pending', 'Stopped', 'Terminated']; + +function generateLargeDataset(count: number): DataItem[] { + return Array.from({ length: count }, (_, i) => ({ + id: `item-${i + 1}`, + name: `Resource ${i + 1}`, + description: `Description for resource ${i + 1}`, + status: statuses[i % statuses.length], + date: `2026-04-${String((i % 28) + 1).padStart(2, '0')}`, + })); +} + +const largeData = generateLargeDataset(25); +const PAGE_SIZE = 10; + +export default function ProgressiveLoadingExplorations() { + const { urlParams, setUrlParams } = useAppContext<'manyItems' | 'skeletonRows'>(); + + // Page-level settings from URL params + const manyItems = urlParams.manyItems !== 'false' && urlParams.manyItems !== false && !!urlParams.manyItems; + const skeletonRowsCount = String(urlParams.skeletonRows || '10'); + const skeletonRows = parseInt(skeletonRowsCount, 10) || 10; + const items = manyItems ? largeData : sampleData; + + // Shared pagination helper + function paginate(allItems: DataItem[], page: number) { + const start = (page - 1) * PAGE_SIZE; + return allItems.slice(start, start + PAGE_SIZE); + } + + function totalPages(allItems: DataItem[]) { + return Math.ceil(allItems.length / PAGE_SIZE); + } + + // Progressive Row Loading state + const [rowPage, setRowPage] = useState(1); + const rowPageTimerRef = useRef | null>(null); + const rowPageItems = paginate(items, rowPage); + + const [rowLoadedState, setRowLoadedState] = useState(() => rowPageItems.map(() => false)); + const rowTimerRefs = useRef[]>([]); + const [rowLoading, setRowLoading] = useState(false); + const [rowPaused, setRowPaused] = useState(false); + const rowNextIndexRef = useRef(0); + + // Reset row loading when items change (toggle switch) + useEffect(() => { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowLoadedState(rowPageItems.map(() => false)); + setRowPage(1); + }, [items]); // eslint-disable-line react-hooks/exhaustive-deps + + // Once all rows are loaded, show only real items. + const allRowsLoaded = rowLoadedState.length > 0 && rowLoadedState.every(Boolean); + + function handleRowPageChange(page: number) { + if (rowPageTimerRef.current) { + clearTimeout(rowPageTimerRef.current); + } + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowPage(page); + const pageItems = paginate(items, page); + setRowLoadedState(pageItems.map(() => false)); + rowNextIndexRef.current = 0; + setRowLoading(true); + setRowPaused(false); + scheduleRowLoading(0); + } + + function scheduleRowLoading(startIndex: number) { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + rowPageItems.slice(startIndex).forEach((_, i) => { + const timer = setTimeout( + () => { + setRowLoadedState(prev => { + const next = [...prev]; + next[startIndex + i] = true; + return next; + }); + rowNextIndexRef.current = startIndex + i + 1; + if (startIndex + i === rowPageItems.length - 1) { + setRowLoading(false); + } + }, + (i + 1) * 800 + ); + rowTimerRefs.current.push(timer); + }); + } + + function startRowLoading() { + const initial = rowPageItems.map(() => false); + setRowLoadedState(initial); + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + rowNextIndexRef.current = 0; + setRowLoading(true); + setRowPaused(false); + scheduleRowLoading(0); + } + + function pauseRowLoading() { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowPaused(true); + } + + function resumeRowLoading() { + setRowPaused(false); + scheduleRowLoading(rowNextIndexRef.current); + } + + function resetRowLoading() { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowLoadedState(rowPageItems.map(() => false)); + setRowLoading(false); + setRowPaused(false); + rowNextIndexRef.current = 0; + } + + // Progressive Column Loading state + // Step 1: initial data load (all columns except Status) + // Step 2: Status column loads later + const [colPage, setColPage] = useState(1); + const [colPageLoading, setColPageLoading] = useState(false); + const colPageTimerRef = useRef | null>(null); + const colPageItems = paginate(items, colPage); + + function handleColPageChange(page: number) { + setColPageLoading(true); + setColDataLoaded(false); + setColStatusLoaded(false); + if (colPageTimerRef.current) { + clearTimeout(colPageTimerRef.current); + } + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + colPageTimerRef.current = setTimeout(() => { + setColPage(page); + setColPageLoading(false); + // After page loads, simulate the 2-step column loading + const timer1 = setTimeout(() => { + setColDataLoaded(true); + }, 1500); + const timer2 = setTimeout(() => { + setColStatusLoaded(true); + }, 3500); + colTimerRefs.current.push(timer1, timer2); + }, 1000); + } + + const [colDataLoaded, setColDataLoaded] = useState(false); + const [colStatusLoaded, setColStatusLoaded] = useState(false); + const colTimerRefs = useRef[]>([]); + + function startColLoading() { + setColDataLoaded(true); + setColStatusLoaded(false); + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + // Status column loads after 2s + const timer = setTimeout(() => { + setColStatusLoaded(true); + }, 2000); + colTimerRefs.current.push(timer); + } + + function resetColLoading() { + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + setColDataLoaded(false); + setColStatusLoaded(false); + } + + // Async Filtering state + const [filterPage, setFilterPage] = useState(1); + const [filterText, setFilterText] = useState(''); + const [filterLoading, setFilterLoading] = useState(false); + const [filteredItems, setFilteredItems] = useState(sampleData); + const filterTimerRef = useRef | null>(null); + + // Reset filtering when items change (toggle switch) + useEffect(() => { + setFilterText(''); + setFilterLoading(false); + setFilteredItems(items); + setFilterPage(1); + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + }, [items]); + + function handleFilterChange(text: string) { + setFilterText(text); + setFilterLoading(true); + setFilterPage(1); + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + filterTimerRef.current = setTimeout(() => { + const lower = text.toLowerCase(); + setFilteredItems( + items.filter( + item => + item.name.toLowerCase().includes(lower) || + item.description.toLowerCase().includes(lower) || + item.status.toLowerCase().includes(lower) + ) + ); + setFilterLoading(false); + }, 1500); + } + + function resetFiltering() { + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + setFilterText(''); + setFilterLoading(false); + setFilteredItems(items); + setFilterPage(1); + } + + const filterPageTimerRef = useRef | null>(null); + const [filterPageLoading, setFilterPageLoading] = useState(false); + const filterPageItems = paginate(filterLoading ? [] : filteredItems, filterPage); + + function handleFilterPageChange(page: number) { + setFilterPageLoading(true); + if (filterPageTimerRef.current) { + clearTimeout(filterPageTimerRef.current); + } + filterPageTimerRef.current = setTimeout(() => { + setFilterPage(page); + setFilterPageLoading(false); + }, 1000); + } + + useEffect(() => { + const rowTimers = rowTimerRefs.current; + const colTimers = colTimerRefs.current; + const filterTimer = filterTimerRef.current; + const rowPageTimer = rowPageTimerRef.current; + const colPageTimer = colPageTimerRef.current; + const filterPageTimer = filterPageTimerRef.current; + return () => { + rowTimers.forEach(clearTimeout); + colTimers.forEach(clearTimeout); + if (filterTimer) { + clearTimeout(filterTimer); + } + if (rowPageTimer) { + clearTimeout(rowPageTimer); + } + if (colPageTimer) { + clearTimeout(colPageTimer); + } + if (filterPageTimer) { + clearTimeout(filterPageTimer); + } + }; + }, []); + + return ( + + + +
+ Progressive Loading Explorations +
+ setUrlParams({ manyItems: detail.checked })}> + Many items (25) + + + setUrlParams({ skeletonRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + Progressive Row Loading}> + + + {!rowLoading ? ( + + ) : rowPaused ? ( + + ) : ( + + )} + + + +
Skeleton} + items={rowPageItems.filter((_, index) => rowLoadedState[index])} + trackBy="id" + loading={!allRowsLoaded} + loadingText="Loading items..." + skeleton={!allRowsLoaded ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={rowPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleRowPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> +
Spinner} + items={rowPageItems.filter((_, index) => rowLoadedState[index])} + trackBy="id" + loading={!rowLoadedState.some(Boolean)} + loadingText="Loading items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={rowPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleRowPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + getLoadingStatus={() => (rowLoadedState.every(Boolean) ? 'finished' : 'loading')} + renderLoaderLoading={() => Loading items} + /> + + + + Progressive Column Loading}> + + + + + + +
Skeleton} + items={colDataLoaded && !colPageLoading ? colPageItems : []} + loading={!colDataLoaded || colPageLoading} + loadingText="Loading items..." + skeleton={!colDataLoaded || colPageLoading ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={colPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleColPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => (colStatusLoaded ? item.status : ), + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> +
Spinner} + items={colDataLoaded && !colPageLoading ? colPageItems : []} + loading={!colDataLoaded || colPageLoading} + loadingText="Loading items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={colPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleColPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: + colDataLoaded && !colStatusLoaded ? ( + + Status + + + ) : ( + 'Status' + ), + cell: (item: DataItem) => (colStatusLoaded ? item.status : ''), + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> + + + + Asynchronous Filtering}> + + + + + +
Skeleton} + items={filterLoading || filterPageLoading ? [] : filterPageItems} + loading={filterLoading || filterPageLoading} + loadingText="Filtering items..." + skeleton={filterLoading || filterPageLoading ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={filterPage} + pagesCount={totalPages(filteredItems)} + onChange={({ detail }) => handleFilterPageChange(detail.currentPageIndex)} + /> + } + filter={ + handleFilterChange(detail.filteringText)} + filteringPlaceholder="Filter items" + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + empty={No matching items} + /> +
Spinner} + items={filterLoading || filterPageLoading ? [] : filterPageItems} + loading={filterLoading || filterPageLoading} + loadingText="Filtering items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={filterPage} + pagesCount={totalPages(filteredItems)} + onChange={({ detail }) => handleFilterPageChange(detail.currentPageIndex)} + /> + } + filter={ + handleFilterChange(detail.filteringText)} + filteringPlaceholder="Filter items" + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + empty={No matching items} + /> + + + + + + + ); +} diff --git a/pages/table/skeleton-rows.page.tsx b/pages/table/skeleton-rows.page.tsx new file mode 100644 index 0000000000..1c6fae6d38 --- /dev/null +++ b/pages/table/skeleton-rows.page.tsx @@ -0,0 +1,173 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Checkbox from '~components/checkbox'; +import ColumnLayout from '~components/column-layout'; +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import RadioGroup from '~components/radio-group'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { useAppContext } from '../app/app-context'; + +interface Item { + id: string; + name: string; + description: string; + type: string; +} + +function generateItems(count: number): Item[] { + return Array.from({ length: count }, (_, i) => ({ + id: `item-${i + 1}`, + name: `Item ${i + 1}`, + description: `Description for item ${i + 1}`, + type: i % 2 === 0 ? 'Type A' : 'Type B', + })); +} + +type LoadingState = 'skeleton' | 'loading' | 'data'; +type SelectionMode = 'none' | 'single' | 'multi'; + +export default function TableSkeletonRowsPage() { + const { urlParams, setUrlParams } = useAppContext< + 'loadingState' | 'skeletonRows' | 'dataRows' | 'stripedRows' | 'selectionMode' + >(); + + const loadingState = (urlParams.loadingState || 'skeleton') as LoadingState; + const skeletonRowsCount = String(urlParams.skeletonRows || '5'); + const dataRowsCount = String(urlParams.dataRows || '10'); + const stripedRows = urlParams.stripedRows !== 'false' && urlParams.stripedRows !== false; + const selectionMode = (urlParams.selectionMode || 'multi') as SelectionMode; + + const [selectedItems, setSelectedItems] = useState([]); + + const skeletonRows = parseInt(skeletonRowsCount, 10) || 0; + const dataRows = parseInt(dataRowsCount, 10) || 0; + const items = loadingState === 'data' ? generateItems(dataRows) : []; + + const columnDefinitions = [ + { + id: 'id', + header: 'ID', + cell: (item: Item) => item.id, + }, + { + id: 'name', + header: 'Name', + cell: (item: Item) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: Item) => item.description, + }, + { + id: 'type', + header: 'Type', + cell: (item: Item) => item.type, + }, + ]; + + return ( +
+ +
Table with Skeleton Rows - Interactive Demo
+ + Controls}> + + + + setUrlParams({ loadingState: detail.value })} + items={[ + { value: 'skeleton', label: 'Skeleton Rows', description: 'Show skeleton placeholders' }, + { value: 'loading', label: 'Standard Loading', description: 'Show spinner with loading text' }, + { value: 'data', label: 'Actual Data', description: 'Display real table data' }, + ]} + /> + + + + setUrlParams({ selectionMode: detail.value })} + items={[ + { value: 'none', label: 'None', description: 'No selection' }, + { value: 'single', label: 'Single', description: 'Single row selection' }, + { value: 'multi', label: 'Multi', description: 'Multiple row selection' }, + ]} + /> + + + + + + setUrlParams({ skeletonRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + + + setUrlParams({ dataRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + + + setUrlParams({ stripedRows: detail.checked })} + > + Striped rows + + + + + + +
setSelectedItems(detail.selectedItems) : undefined + } + ariaLabels={{ + selectionGroupLabel: 'Item selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_, item) => item.name, + }} + stripedRows={stripedRows} + header={ +
+ Table Demo +
+ } + /> + + + ); +} diff --git a/pages/tree-view/items/basic-page-items.tsx b/pages/tree-view/items/basic-page-items.tsx index 4e9aecb1af..4e7cbaaf09 100644 --- a/pages/tree-view/items/basic-page-items.tsx +++ b/pages/tree-view/items/basic-page-items.tsx @@ -6,11 +6,14 @@ import Badge from '~components/badge'; import Popover from '~components/popover'; import SpaceBetween from '~components/space-between'; import StatusIndicator from '~components/status-indicator'; +import * as tokens from '~design-tokens'; const progressiveStepContent = (
Checked 5 nodes -
+
1 diff --git a/pages/tree-view/items/permutations-items.tsx b/pages/tree-view/items/permutations-items.tsx index 180f9336b8..b45b2d492a 100644 --- a/pages/tree-view/items/permutations-items.tsx +++ b/pages/tree-view/items/permutations-items.tsx @@ -7,6 +7,7 @@ import Box from '~components/box'; import Icon from '~components/icon'; import SpaceBetween from '~components/space-between'; import StatusIndicator from '~components/status-indicator'; +import * as tokens from '~design-tokens'; import { Actions } from '../common'; @@ -195,7 +196,9 @@ export const statusIndicatorItems: Item[] = [ content: (
Checked 5 nodes -
+
1 1 @@ -252,7 +255,9 @@ export const statusIndicatorItems: Item[] = [ content: (
Running automation -
+
1 1 diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 3fcf9aa5d4..5f33c3ee4f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -8916,6 +8916,9 @@ It contains the following: - \`title\` (string) - Specifies the text displayed at the top of the preference. - \`description\` (string) - Specifies the description displayed below the title. - \`options\` - Specifies an array of options for reordering and visible content selection. +- \`groups\` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains: + - \`id\` (string) - A unique identifier for the group. + - \`label\` (string) - The text displayed as the group label. - \`enableColumnFiltering\` (boolean) - Adds a columns filter. - \`liveAnnouncementDndStarted\` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked. - \`liveAnnouncementDndDiscarded\` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled. @@ -8923,13 +8926,24 @@ It contains the following: - \`liveAnnouncementDndItemCommitted\` ((initialPosition: number, finalPosition: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when a reordering action is committed. - \`dragHandleAriaDescription\` (string) - (Optional) Adds an ARIA description for the drag handle. - \`dragHandleAriaLabel\` (string) - (Optional) Adds an ARIA label for the drag handle. +- \`liveAnnouncementDndGroupLabel\` ((label: string, count: number) => string) - (Optional) Adds a label for a group item to be announced by screen readers during drag and drop operations. Each option contains the following: - \`id\` (string) - Corresponds to a table column \`id\`. - \`label\` (string) - Specifies a short description of the content. - \`alwaysVisible\` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to \`false\` by default. -You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property.", +You must provide an ordered list of the items to display in the \`preferences.contentDisplay\` property. +Each content display item is one of the following: +- \`ContentDisplayColumn\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. + - \`visible\` (boolean) - Whether the column is visible. +- \`ContentDisplayGroup\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group.", "i18nTag": true, "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreference", @@ -8954,6 +8968,11 @@ You must provide an ordered list of the items to display in the \`preferences.co "optional": true, "type": "boolean", }, + { + "name": "groups", + "optional": true, + "type": "ReadonlyArray", + }, { "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings", @@ -9006,6 +9025,26 @@ You must provide an ordered list of the items to display in the \`preferences.co "optional": true, "type": "string", }, + { + "inlineType": { + "name": "(label: string, count: number) => string", + "parameters": [ + { + "name": "label", + "type": "string", + }, + { + "name": "count", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementDndGroupLabel", + "optional": true, + "type": "((label: string, count: number) => string)", + }, { "inlineType": { "name": "(initialPosition: number, finalPosition: number, total: number) => string", @@ -27809,7 +27848,18 @@ To target individual cells use \`columnDefinitions.verticalAlign\`, that takes p If not set, all columns are displayed and the order is dictated by the \`columnDefinitions\` property. -Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component.", +Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component. + +Each entry is one of the following: +- \`ColumnDisplay\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. Must match a column \`id\` from \`columnDefinitions\`. + - \`visible\` (boolean) - Whether the column is visible. +- \`GroupDisplay\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. Must match a group \`id\` from \`groupDefinitions\`. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group.", "name": "columnDisplay", "optional": true, "type": "ReadonlyArray", @@ -28031,6 +28081,20 @@ table with \`item=null\` and then for each expanded item. The function result is "optional": true, "type": "TableProps.GetLoadingStatus", }, + { + "description": "Defines the column groups. Each group has an \`id\` and \`header\` used to label the group header cell. + +When using grouped columns, you must also provide the \`columnDisplay\` property with \`{ type: 'group', id, children }\` entries +to assign columns to their respective groups and define the display hierarchy. + +Each group definition contains the following: +- \`id\` (string) - A unique identifier for the group. +- \`header\` (ReactNode) - The content displayed in the group header cell. +- \`ariaLabel\` ((LabelData) => string) - (Optional) A function that provides an \`aria-label\` for the group header.", + "name": "groupDefinitions", + "optional": true, + "type": "ReadonlyArray>", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -28072,7 +28136,8 @@ by the \`cell\` property of each column definition in the \`columnDefinitions\` "type": "boolean", }, { - "description": "Specifies the text that's displayed when the table is in a loading state.", + "description": "Specifies the text that's displayed when the table is in a loading state. +In skeleton-loading mode this will be used as a label for screenreaders.", "name": "loadingText", "optional": true, "type": "string", @@ -28216,6 +28281,26 @@ the table items array is empty.", "optional": true, "type": "string", }, + { + "description": "Renders skeleton placeholder rows to fill the table while data is loading. Accepts: +- \`totalRows\` (number) - The total number of rows that should be rendered. If \`items\` + are also provided, those items will be rendered first, and \`totalRows - items.length\` + additional skeleton rows rendered after.", + "inlineType": { + "name": "TableProps.SkeletonConfig", + "properties": [ + { + "name": "totalRows", + "optional": false, + "type": "number", + }, + ], + "type": "object", + }, + "name": "skeleton", + "optional": true, + "type": "TableProps.SkeletonConfig", + }, { "description": "Specifies the definition object of the currently sorted column. Make sure you pass an object that's present in the \`columnDefinitions\` array.", @@ -38035,9 +38120,23 @@ Returns the current value of the input.", }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -38076,6 +38175,33 @@ Returns the current value of the input.", }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "Array", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -38105,7 +38231,8 @@ Returns the current value of the input.", }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { @@ -43148,8 +43275,22 @@ Returns the current value of the input.", }, }, { + "description": "Returns column header cells from the table's header region. + +By default, returns all leaf-column headers (\`scope="col"\`). +For tables without column grouping this is equivalent to the previous behavior. +For tables with column grouping this excludes group header cells.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -43184,9 +43325,11 @@ Returns the current value of the input.", }, }, { + "description": "Returns the clickable sorting area of a column header.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, @@ -48970,9 +49113,23 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -49006,6 +49163,33 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -49025,7 +49209,8 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { @@ -52495,8 +52680,22 @@ In this case, use findContentEditableElement() instead.", }, }, { + "description": "Returns column header cells from the table's header region. + +By default, returns all leaf-column headers (\`scope="col"\`). +For tables without column grouping this is equivalent to the previous behavior. +For tables with column grouping this excludes group header cells.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -52526,9 +52725,11 @@ In this case, use findContentEditableElement() instead.", }, }, { + "description": "Returns the clickable sorting area of a column header.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 5e1d0b1e2d..90d771415e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -155,6 +155,8 @@ exports[`test-utils selectors 1`] = ` "awsui_content-before_tc96w", "awsui_content-density_tc96w", "awsui_content-display-description_tc96w", + "awsui_content-display-group-children_tc96w", + "awsui_content-display-group-header_tc96w", "awsui_content-display-no-match_tc96w", "awsui_content-display-option-content_tc96w", "awsui_content-display-option-label_tc96w", diff --git a/src/app-layout/runtime-drawer/index.tsx b/src/app-layout/runtime-drawer/index.tsx index 009d8f2826..ab7c359a62 100644 --- a/src/app-layout/runtime-drawer/index.tsx +++ b/src/app-layout/runtime-drawer/index.tsx @@ -95,7 +95,7 @@ function mapRuntimeHeaderActionsToHeaderActions( // eslint-disable-next-line no-restricted-syntax -- Runtime plugin API: property not in TS type ...('pressedIconSvg' in runtimeHeaderAction && runtimeHeaderAction.pressedIconSvg && { - iconSvg: convertRuntimeTriggerToReactNode(runtimeHeaderAction.pressedIconSvg), + pressedIconSvg: convertRuntimeTriggerToReactNode(runtimeHeaderAction.pressedIconSvg), }), }; }); diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 073175e210..c0ca366ec6 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -3,13 +3,14 @@ import React, { useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; -import { useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton, InternalButtonProps } from '../button/internal'; import Dropdown from '../dropdown/internal'; +import { IconProps } from '../icon/interfaces'; import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; import OptionsList from '../internal/components/options-list'; @@ -141,13 +142,14 @@ const InternalButtonDropdown = React.forwardRef( const canBeFullWidth = !!fullWidth && (variant === 'primary' || variant === 'normal'); const triggerVariant = variant === 'navigation' ? undefined : variant === 'inline-icon' ? 'inline-icon' : variant; - const iconProps: Partial = + const iconProps: Partial = variant === 'icon' || variant === 'inline-icon' ? { iconName: 'ellipsis', } : { - iconName: 'caret-down-filled', + iconName: isThemeActive(Theme.OneTheme) ? 'angle-down' : 'caret-down-filled', + __iconSize: isThemeActive(Theme.OneTheme) ? 'x-small' : 'normal', iconAlign: 'right', __iconClass: spinWhenOpen(styles, 'rotate', canBeOpened && isOpen), }; diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index 789901cf71..72e16ce1ef 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -50,6 +50,8 @@ $dropdown-trigger-icon-offset: 2px; } .trigger-button { + display: flex; + align-items: center; &.full-width { display: grid; grid-template-columns: 1fr auto; diff --git a/src/button-group/__tests__/button-group.test.tsx b/src/button-group/__tests__/button-group.test.tsx index b398ea6209..e40e03ea5e 100644 --- a/src/button-group/__tests__/button-group.test.tsx +++ b/src/button-group/__tests__/button-group.test.tsx @@ -105,3 +105,63 @@ describe('ButtonGroup Style API', () => { expect(getComputedStyle(itemElement).getPropertyValue(customCssProps.styleFocusRingBorderWidth)).toBe('2px'); }); }); + +test('icon-toggle-button maintains correct icon on state change', () => { + const { rerender, container } = render( + + + + ), + pressedIconSvg: ( + + + + ), + }, + ]} + /> + ); + const wrapper = createWrapper(container).findButtonGroup()!; + + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.default-icon')).toBeTruthy(); + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.pressed-icon')).toBeFalsy(); + + // Rerender with pressed state + rerender( + + + + ), + pressedIconSvg: ( + + + + ), + }, + ]} + /> + ); + + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.default-icon')).toBeFalsy(); + expect(wrapper.findToggleButtonById('test-toggle-state')!.find('.pressed-icon')).toBeTruthy(); +}); diff --git a/src/button-group/icon-toggle-button-item.tsx b/src/button-group/icon-toggle-button-item.tsx index fcbce964ca..f72888ed6a 100644 --- a/src/button-group/icon-toggle-button-item.tsx +++ b/src/button-group/icon-toggle-button-item.tsx @@ -55,7 +55,7 @@ const IconToggleButtonItem = forwardRef( iconSvg={item.iconSvg} pressedIconName={hasIcon ? item.pressedIconName : 'close'} pressedIconUrl={item.pressedIconUrl} - pressedIconSvg={item.pressedIconUrl} + pressedIconSvg={item.pressedIconSvg} ariaLabel={item.text} onChange={event => fireCancelableEvent(onItemClick, { id: item.id, pressed: event.detail.pressed })} ref={ref} diff --git a/src/button/internal.tsx b/src/button/internal.tsx index 8ef126bf7b..0253e78bc6 100644 --- a/src/button/internal.tsx +++ b/src/button/internal.tsx @@ -11,6 +11,7 @@ import { } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { useInternalI18n } from '../i18n/context'; +import { IconProps } from '../icon/interfaces'; import Icon from '../icon/internal'; import { FunnelMetrics } from '../internal/analytics'; import { useFunnel, useFunnelStep, useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; @@ -51,6 +52,7 @@ export type InternalButtonProps = Omit & { badge?: boolean; analyticsAction?: string; __iconClass?: string; + __iconSize?: IconProps.Size; __focusable?: boolean; __injectAnalyticsComponentMetadata?: boolean; __title?: string; @@ -64,6 +66,7 @@ export const InternalButton = React.forwardRef( children, iconName, __iconClass, + __iconSize, onClick, onFollow, iconAlign = 'left', @@ -237,7 +240,7 @@ export const InternalButton = React.forwardRef( variant, badge, iconClass: __iconClass, - iconSize: variant === 'modal-dismiss' ? 'medium' : 'normal', + iconSize: __iconSize ?? (variant === 'modal-dismiss' ? 'medium' : 'normal'), }; const buttonContent = ( <> diff --git a/src/collection-preferences/__tests__/shared.tsx b/src/collection-preferences/__tests__/shared.tsx index 3c4900f691..ea377e431c 100644 --- a/src/collection-preferences/__tests__/shared.tsx +++ b/src/collection-preferences/__tests__/shared.tsx @@ -21,6 +21,8 @@ const i18nMessages = { "Use Space or Enter to activate drag for an item, then use the arrow keys to move the item's position. To complete the position move, use Space or Enter, or to discard the move, use Escape. You may need to toggle your browsing mode on your screen reader.", 'contentDisplayPreference.liveAnnouncementDndStarted': 'Picked up item at position {position} of {total}', 'contentDisplayPreference.liveAnnouncementDndDiscarded': 'Reordering canceled', + 'contentDisplayPreference.liveAnnouncementDndGroupLabel': + '{label}, {count, plural, one {1 item} other {{count} items}}', 'contentDisplayPreference.liveAnnouncementDndItemReordered': '{isInitialPosition, select, true {Moving item back to position {currentPosition} of {total}} false {Moving item to position {currentPosition} of {total}} other {}}', 'contentDisplayPreference.liveAnnouncementDndItemCommitted': diff --git a/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts new file mode 100644 index 0000000000..b5ad71acb6 --- /dev/null +++ b/src/collection-preferences/content-display/__integ__/content-display-groups.test.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../../lib/components/test-utils/selectors'; +import ContentDisplayPageObject from './pages/content-display-page'; + +const windowDimensions = { + width: 1200, + height: 1200, +}; + +const setupTest = (testFn: (page: ContentDisplayPageObject) => Promise) => { + return useBrowser(async browser => { + const page = new ContentDisplayPageObject(browser); + await browser.url('#/light/collection-preferences/content-display-groups'); + await page.setWindowSize(windowDimensions); + page.wrapper = createWrapper().findCollectionPreferences(); + await page.openCollectionPreferencesModal(); + await testFn(page); + }); +}; + +describe('Collection preferences - Grouped Content Display', () => { + test( + 'reorders a top-level item with drag and drop', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const options = modal.findOptions(); + + // findLabel() on group items returns the first nested child's label + expect(await page.getOptionLabels(options, 6)).toEqual([ + 'Instance ID', + 'Name', + 'Instance type', + 'CPU (%)', + 'Network in (MB/s)', + 'Monthly cost ($)', + ]); + + // Drag Instance ID past Name + const activeDragHandle = page.findDragHandle(0); + const targetDragHandle = page.findDragHandle(1); + await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); + + expect(await page.getOptionLabels(options, 6)).toEqual([ + 'Name', + 'Instance ID', + 'Instance type', + 'CPU (%)', + 'Network in (MB/s)', + 'Monthly cost ($)', + ]); + }) + ); + + test( + 'reorders an individual item within a group with drag and drop', + setupTest(async page => { + const modal = page.wrapper.findModal().findContentDisplayPreference(); + const configGroup = modal.findOptions().get(3); + const children = configGroup.findChildrenOptions()!; + + expect(await page.getOptionLabels(children, 3)).toEqual(['Instance type', 'Availability zone', 'State']); + + // Drag Instance type past Availability zone + const activeDragHandle = children.get(1).findDragHandle(); + const targetDragHandle = children.get(2).findDragHandle(); + await page.dragAndDropTo(activeDragHandle.toSelector(), targetDragHandle.toSelector()); + + expect(await page.getOptionLabels(children, 3)).toEqual(['Availability zone', 'Instance type', 'State']); + }) + ); +}); diff --git a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts index 2b5c52a690..002accd846 100644 --- a/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts +++ b/src/collection-preferences/content-display/__integ__/pages/content-display-page.ts @@ -15,6 +15,24 @@ export default class ContentDisplayPageObject extends CollectionPreferencesPageO return true; } + async getOptionLabels( + options: { get(index: number): { findLabel(): { toSelector(): string } } }, + count: number + ): Promise { + const labels: string[] = []; + for (let i = 0; i < count; i++) { + labels.push( + await this.getText( + options + .get(i + 1) + .findLabel() + .toSelector() + ) + ); + } + return labels; + } + async expectAnnouncement(announcement: string) { const liveRegion = await this.browser.$('[aria-live="assertive"]'); // Using getHTML because getText returns an empty string if the live region is outside the viewport. diff --git a/src/collection-preferences/content-display/__tests__/content-display.test.tsx b/src/collection-preferences/content-display/__tests__/content-display.test.tsx index 4600424105..0c6fafb8ae 100644 --- a/src/collection-preferences/content-display/__tests__/content-display.test.tsx +++ b/src/collection-preferences/content-display/__tests__/content-display.test.tsx @@ -569,3 +569,211 @@ function expectLabelForToggle(option: ContentDisplayOptionWrapper) { function pressKey(element: HTMLElement, key: string) { fireEvent.keyDown(element, { key, code: key }); } + +describe('Content Display preference with groups', () => { + const groupedPreference: CollectionPreferencesProps.ContentDisplayPreference = { + ...contentDisplayPreference, + groups: [ + { id: 'g1', label: 'Group 1' }, + { id: 'g2', label: 'Group 2' }, + ], + }; + + const groupedContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ]; + + function renderGroupedContentDisplay(props: Partial = {}) { + const wrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + ...props, + }, + true + ); + wrapper.findTriggerButton().click(); + return wrapper.findModal()!.findContentDisplayPreference()!; + } + + it('renders group headers', () => { + const wrapper = renderGroupedContentDisplay(); + const element = wrapper.getElement(); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).toContain('Group 2'); + }); + + it('renders leaf options within groups', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // findOptions returns all items (groups + leaves) in DOM order + // Verify leaf labels are present + const labels = options.map(opt => opt.findLabel()?.getElement().textContent).filter(Boolean); + expect(labels).toContain('Item 1'); + expect(labels).toContain('Item 2'); + expect(labels).toContain('Item 3'); + expect(labels).toContain('Item 4'); + }); + + it('renders options with correct visibility state', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + const toggleStates = options.map(opt => opt.findVisibilityToggle().findNativeInput().getElement().checked); + // All items with visibility toggles in DOM order: id1, g1, id2, id3, g2, id4 + expect(toggleStates).toEqual([true, true, true, false, true, true]); + }); + + it('renders nested lists with aria-label for groups', () => { + const wrapper = renderGroupedContentDisplay(); + const lists = wrapper.findAll('ol'); + // Top-level list + 2 nested lists (one per group) + expect(lists).toHaveLength(3); + const ariaLabels = lists.map(l => l.getElement().getAttribute('aria-label')).filter(Boolean); + expect(ariaLabels).toEqual(['Group 1', 'Group 2']); + }); + + it('filters options within groups', () => { + const wrapper = renderGroupedContentDisplay({ + contentDisplayPreference: { ...groupedPreference, enableColumnFiltering: true }, + }); + const filterInput = wrapper.findTextFilter()!; + filterInput.findInput().setInputValue('Item 2'); + // Only Item 2 and its parent group should be visible + const element = wrapper.getElement(); + expect(element.textContent).toContain('Item 2'); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).not.toContain('Item 4'); + }); + + it('shows no match state when filter has no results', () => { + const wrapper = renderGroupedContentDisplay({ + contentDisplayPreference: { + ...groupedPreference, + enableColumnFiltering: true, + i18nStrings: { columnFilteringNoMatchText: 'No matches found', columnFilteringClearFilterText: 'Clear' }, + }, + }); + const filterInput = wrapper.findTextFilter()!; + filterInput.findInput().setInputValue('nonexistent'); + expect(wrapper.getElement().textContent).toContain('No matches found'); + }); + + it('reorders top-level items via keyboard drag and drop', async () => { + const onConfirm = jest.fn(); + const collectionPreferencesWrapper = renderCollectionPreferences( + { + contentDisplayPreference: { + ...groupedPreference, + groups: [{ id: 'g1', label: 'Group 1' }], + }, + preferences: { + contentDisplay: [ + { id: 'id1', visible: true }, + { type: 'group', id: 'g1', visible: true, children: [] }, + { id: 'id2', visible: true }, + ], + }, + onConfirm, + }, + true + ); + collectionPreferencesWrapper.findTriggerButton().click(); + const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; + + const dragHandle = wrapper.findOptionByIndex(1)!.findDragHandle().getElement(); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Picked up item at position 1 of 3'); + pressKey(dragHandle, 'ArrowDown'); + await expectAnnouncement('Moving item to position 2 of 3'); + pressKey(dragHandle, 'Space'); + await expectAnnouncement('Item moved from position 1 to position 2 of 3'); + + // Confirm and verify reorder + collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click(); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + contentDisplay: [ + { type: 'group', id: 'g1', visible: true, children: [] }, + { id: 'id1', visible: true }, + { id: 'id2', visible: true }, + ], + }, + }) + ); + }); + + it('has drag handles for items within groups', () => { + const wrapper = renderGroupedContentDisplay(); + const options = wrapper.findOptions(); + // All items (including those in groups) should have drag handles + const id2Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 2'); + expect(id2Option).toBeDefined(); + expect(id2Option!.findDragHandle()).not.toBeNull(); + expect(id2Option!.findDragHandle().getElement().getAttribute('aria-disabled')).toBe('false'); + }); + + it('renders correct nested leaf options within a group', () => { + const wrapper = renderGroupedContentDisplay(); + // Group 1 contains Item 2 and Item 3 + const element = wrapper.getElement(); + expect(element.textContent).toContain('Group 1'); + expect(element.textContent).toContain('Item 2'); + expect(element.textContent).toContain('Item 3'); + // Group 2 contains Item 4 + expect(element.textContent).toContain('Group 2'); + expect(element.textContent).toContain('Item 4'); + }); + + it('toggling a grouped leaf option calls onConfirm with the updated tree structure', () => { + const onConfirm = jest.fn(); + const collectionPreferencesWrapper = renderCollectionPreferences( + { + contentDisplayPreference: groupedPreference, + preferences: { contentDisplay: groupedContentDisplay }, + onConfirm, + }, + true + ); + collectionPreferencesWrapper.findTriggerButton().click(); + const wrapper = collectionPreferencesWrapper.findModal()!.findContentDisplayPreference()!; + + // Find id3 (Item 3, currently visible: false) and toggle it to visible + const options = wrapper.findOptions(); + const id3Option = options.find(opt => opt.findLabel()?.getElement().textContent === 'Item 3'); + expect(id3Option).toBeDefined(); + id3Option!.findVisibilityToggle().findNativeInput().click(); + + // Confirm + collectionPreferencesWrapper.findModal()!.findConfirmButton()!.click(); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + contentDisplay: [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: true }, + ], + }, + { type: 'group', id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ], + }, + }) + ); + }); +}); diff --git a/src/collection-preferences/content-display/__tests__/utils.test.ts b/src/collection-preferences/content-display/__tests__/utils.test.ts index 94d4fb52ae..ceca062ed7 100644 --- a/src/collection-preferences/content-display/__tests__/utils.test.ts +++ b/src/collection-preferences/content-display/__tests__/utils.test.ts @@ -1,6 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getSortedOptions } from '../utils'; +import { collectVisibleIds } from '../../../../lib/components/collection-preferences/utils'; +import { + buildOptionTree, + getFilteredOptions, + getFilteredTree, + getSortedOptions, + OptionGroupNode, + toContentDisplayItems, + walkLeaves, +} from '../utils'; describe('getSortedOptions', () => { it('returns the passed-in options with the desired order and visibility', () => { @@ -71,3 +80,254 @@ describe('getSortedOptions', () => { ]); }); }); + +describe('walkLeaves', () => { + it('extracts leaves from flat list', () => { + const items = [ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]; + expect(walkLeaves(items)).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]); + }); + + it('extracts leaves from nested groups', () => { + const items = [ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]; + expect(walkLeaves(items)).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ]); + }); +}); + +describe('buildOptionTree', () => { + it('returns flat leaf nodes when no groups provided', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + const contentDisplay = [ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]; + const tree = buildOptionTree(options, [], contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A', visible: true }); + expect(tree[1]).toMatchObject({ type: 'leaf' as const, id: 'b', label: 'B', visible: false }); + }); + + it('builds grouped tree from contentDisplay', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ]; + const groups = [{ id: 'g1', label: 'Group 1' }]; + const contentDisplay = [ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]; + const tree = buildOptionTree(options, groups, contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[0]).toMatchObject({ type: 'leaf' as const, id: 'a', label: 'A' }); + expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'g1', label: 'Group 1', visible: true }); + expect((tree[1] as OptionGroupNode).children).toHaveLength(2); + expect((tree[1] as OptionGroupNode).children[0]).toMatchObject({ type: 'leaf' as const, id: 'b', visible: true }); + expect((tree[1] as OptionGroupNode).children[1]).toMatchObject({ type: 'leaf' as const, id: 'c', visible: false }); + }); + + it('uses group id as label when group definition not found', () => { + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + const groups = [{ id: 'existing', label: 'Existing' }]; + const contentDisplay = [ + { id: 'a', visible: true }, + { type: 'group' as const, id: 'nonexistent', visible: true, children: [{ id: 'b', visible: true }] }, + ]; + const tree = buildOptionTree(options, groups, contentDisplay); + expect(tree).toHaveLength(2); + expect(tree[1]).toMatchObject({ type: 'group' as const, id: 'nonexistent', label: 'nonexistent' }); + }); +}); + +describe('toContentDisplayItems', () => { + it('converts leaf nodes back to ContentDisplayItem', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'A', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'B', visible: false }, + ]; + const result = toContentDisplayItems(tree); + expect(result).toEqual([ + { id: 'a', visible: true }, + { id: 'b', visible: false }, + ]); + }); + + it('converts group nodes back to ContentDisplayGroup', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'A', visible: true }, + { + type: 'group' as const, + id: 'g1', + label: 'G1', + visible: true, + children: [ + { type: 'leaf' as const, id: 'b', label: 'B', visible: true }, + { type: 'leaf' as const, id: 'c', label: 'C', visible: false }, + ], + }, + ]; + const result = toContentDisplayItems(tree); + expect(result).toEqual([ + { id: 'a', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'b', visible: true }, + { id: 'c', visible: false }, + ], + }, + ]); + }); +}); + +describe('getFilteredTree', () => { + it('returns full tree when filter is empty', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }], + }, + ]; + expect(getFilteredTree(tree, '')).toEqual(tree); + expect(getFilteredTree(tree, ' ')).toEqual(tree); + }); + + it('filters leaf nodes by label', () => { + const tree = [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }, + ]; + const result = getFilteredTree(tree, 'alp'); + expect(result).toEqual([{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }]); + }); + + it('keeps groups with matching descendants', () => { + const tree = [ + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [ + { type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }, + { type: 'leaf' as const, id: 'b', label: 'Beta', visible: true }, + ], + }, + ]; + const result = getFilteredTree(tree, 'alpha'); + expect(result).toEqual([ + { + type: 'group', + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf', id: 'a', label: 'Alpha', visible: true }], + }, + ]); + }); + + it('removes groups with no matching descendants', () => { + const tree = [ + { + type: 'group' as const, + id: 'g', + label: 'Group', + visible: true, + children: [{ type: 'leaf' as const, id: 'a', label: 'Alpha', visible: true }], + }, + ]; + const result = getFilteredTree(tree, 'xyz'); + expect(result).toHaveLength(0); + }); +}); + +describe('getFilteredOptions', () => { + it('returns all options when filter is empty', () => { + const options = [ + { id: 'a', label: 'Alpha', visible: true }, + { id: 'b', label: 'Beta', visible: true }, + ]; + expect(getFilteredOptions(options, '')).toEqual(options); + }); + + it('filters by label', () => { + const options = [ + { id: 'a', label: 'Alpha', visible: true }, + { id: 'b', label: 'Beta', visible: true }, + ]; + const result = getFilteredOptions(options, 'bet'); + expect(result).toEqual([{ id: 'b', label: 'Beta', visible: true }]); + }); +}); + +describe('collectVisibleIds', () => { + it('collects visible leaf ids from grouped content display', () => { + const items = [ + { id: 'id1', visible: true }, + { + type: 'group' as const, + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + { type: 'group' as const, id: 'g2', visible: true, children: [{ id: 'id4', visible: true }] }, + ]; + expect(collectVisibleIds(items, true)).toEqual(['id1', 'id2', 'id4']); + }); + + it('excludes children of non-visible groups', () => { + const items = [ + { + type: 'group' as const, + id: 'g1', + visible: false, + children: [{ id: 'id1', visible: true }], + }, + ]; + expect(collectVisibleIds(items, true)).toEqual([]); + }); +}); diff --git a/src/collection-preferences/content-display/content-display-list.scss b/src/collection-preferences/content-display/content-display-list.scss index 7ad93c0c62..aa35430898 100644 --- a/src/collection-preferences/content-display/content-display-list.scss +++ b/src/collection-preferences/content-display/content-display-list.scss @@ -32,3 +32,13 @@ padding-block: 0; padding-inline: 0; } + +$group-children-indentation: 28px; + +// Text-to-text indentation between group header and child items. +// The drag handle (~20px) is rendered before the content, so we subtract it. +.content-display-group-children { + padding-inline-start: calc( + #{$group-children-indentation} - #{awsui.$size-icon-normal} - 2 * #{awsui.$space-scaled-xxxs} + ); +} diff --git a/src/collection-preferences/content-display/content-display-option.scss b/src/collection-preferences/content-display/content-display-option.scss index 8105fb1b93..e13a28831c 100644 --- a/src/collection-preferences/content-display/content-display-option.scss +++ b/src/collection-preferences/content-display/content-display-option.scss @@ -27,3 +27,15 @@ @include styles.text-wrapping; padding-inline-end: awsui.$space-l; } + +.content-display-group-header { + @include styles.styles-reset; + display: flex; + align-items: flex-start; + padding-block: awsui.$space-scaled-xs; + padding-inline-end: awsui.$space-xs; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; +} diff --git a/src/collection-preferences/content-display/content-display-option.tsx b/src/collection-preferences/content-display/content-display-option.tsx index ab4faa1782..0db9417de6 100644 --- a/src/collection-preferences/content-display/content-display-option.tsx +++ b/src/collection-preferences/content-display/content-display-option.tsx @@ -22,7 +22,7 @@ const ContentDisplayOption = forwardRef( const idPrefix = useUniqueId(componentPrefix); const controlId = `${idPrefix}-control-${option.id}`; return ( -
+
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index b0465188a9..e59aa35ef7 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -7,6 +7,7 @@ import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import InternalBox from '../../box/internal'; import InternalButton from '../../button/internal'; import { useInternalI18n } from '../../i18n/context'; +import { SortableAreaProps } from '../../internal/components/sortable-area/interfaces'; import { formatDndItemCommitted, formatDndItemReordered, @@ -18,7 +19,15 @@ import InternalTextFilter from '../../text-filter/internal'; import { getAnalyticsInnerContextAttribute } from '../analytics-metadata/utils'; import { CollectionPreferencesProps } from '../interfaces'; import ContentDisplayOption from './content-display-option'; -import { getFilteredOptions, getSortedOptions, OptionWithVisibility } from './utils'; +import { + buildOptionTree, + getFilteredOptions, + getFilteredTree, + getSortedOptions, + OptionGroupNode, + OptionTreeNode, + toContentDisplayItems, +} from './utils'; import styles from '../styles.css.js'; @@ -30,6 +39,153 @@ interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.Conte onChange: (value: ReadonlyArray) => void; value?: ReadonlyArray; } +function getDndI18nStrings( + i18n: ReturnType>, + props: Pick< + ContentDisplayPreferenceProps, + | 'liveAnnouncementDndStarted' + | 'liveAnnouncementDndItemReordered' + | 'liveAnnouncementDndItemCommitted' + | 'liveAnnouncementDndDiscarded' + | 'dragHandleAriaLabel' + | 'dragHandleAriaDescription' + > +) { + return { + liveAnnouncementDndStarted: i18n( + 'contentDisplayPreference.liveAnnouncementDndStarted', + props.liveAnnouncementDndStarted, + formatDndStarted + ), + liveAnnouncementDndItemReordered: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemReordered', + props.liveAnnouncementDndItemReordered, + formatDndItemReordered + ), + liveAnnouncementDndItemCommitted: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemCommitted', + props.liveAnnouncementDndItemCommitted, + formatDndItemCommitted + ), + liveAnnouncementDndDiscarded: i18n( + 'contentDisplayPreference.liveAnnouncementDndDiscarded', + props.liveAnnouncementDndDiscarded + ), + dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', props.dragHandleAriaLabel), + dragHandleAriaDescription: i18n( + 'contentDisplayPreference.dragHandleAriaDescription', + props.dragHandleAriaDescription + ), + }; +} + +interface HierarchicalContentDisplayProps { + tree: OptionTreeNode[]; + onToggle: (id: string) => void; + onTreeChange: (newTree: OptionTreeNode[]) => void; + ariaLabel?: string; + ariaLabelledby?: string; + ariaDescribedby?: string; + i18nStrings: SortableAreaProps.DndAreaI18nStrings; + sortDisabled: boolean; + parentGroupLabel?: string; + groupLabelFormatter: (label: string, count: number) => string; +} + +function GroupItem({ + node, + onToggle, + onChildrenChange, + i18nStrings, + sortDisabled, + groupLabelFormatter, +}: { + node: OptionGroupNode; + onToggle: (id: string) => void; + onChildrenChange: (children: OptionTreeNode[]) => void; + i18nStrings: SortableAreaProps.DndAreaI18nStrings; + sortDisabled: boolean; + groupLabelFormatter: (label: string, count: number) => string; +}) { + return ( +
+ +
+ + {node.label} + +
+ {node.children.length > 0 && ( +
+ +
+ )} +
+
+ ); +} + +function HierarchicalContentDisplay({ + tree, + onToggle, + onTreeChange, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + i18nStrings, + sortDisabled, + parentGroupLabel, + groupLabelFormatter, +}: HierarchicalContentDisplayProps) { + return ( + onTreeChange([...items])} + renderItem={node => ({ + id: node.id, + announcementLabel: + node.type === 'group' + ? groupLabelFormatter(node.label, node.children.length) + : parentGroupLabel + ? `${node.label}, ${parentGroupLabel}` + : node.label, + content: + node.type === 'group' ? ( + + onTreeChange( + tree.map(n => (n.id === node.id && n.type === 'group' ? { ...n, children: newChildren } : n)) + ) + } + i18nStrings={i18nStrings} + sortDisabled={sortDisabled} + groupLabelFormatter={groupLabelFormatter} + /> + ) : ( + onToggle(node.id)} /> + ), + })} + /> + ); +} export default function ContentDisplayPreference({ title, @@ -39,15 +195,11 @@ export default function ContentDisplayPreference({ id, visible: true, })), + groups, onChange, - liveAnnouncementDndStarted, - liveAnnouncementDndItemReordered, - liveAnnouncementDndItemCommitted, - liveAnnouncementDndDiscarded, - dragHandleAriaDescription, - dragHandleAriaLabel, enableColumnFiltering = false, i18nStrings, + ...dndProps }: ContentDisplayPreferenceProps) { const idPrefix = useUniqueId(componentPrefix); const i18n = useInternalI18n('collection-preferences'); @@ -56,18 +208,49 @@ export default function ContentDisplayPreference({ const titleId = `${idPrefix}-title`; const descriptionId = `${idPrefix}-description`; - const [sortedOptions, sortedAndFilteredOptions] = useMemo(() => { - const sorted = getSortedOptions({ options, contentDisplay: value }); - const filtered = getFilteredOptions(sorted, columnFilteringText); - return [sorted, filtered]; - }, [columnFilteringText, options, value]); + const listI18nStrings = getDndI18nStrings(i18n, dndProps); + const groupLabelFormatter = (label: string, count: number) => + i18n( + 'contentDisplayPreference.liveAnnouncementDndGroupLabel', + dndProps.liveAnnouncementDndGroupLabel?.(label, count) ?? `${label}, ${count} ${count === 1 ? 'item' : 'items'}`, + format => format({ label, count }) + ); + const hasGroups = !!groups && groups.length > 0; + const isFiltering = columnFilteringText.trim().length > 0; - const onToggle = (option: OptionWithVisibility) => { - // We use sortedOptions as base and not value because there might be options that - // are not in the value yet, so they're added as non-visible after the known ones. - onChange(sortedOptions.map(({ id, visible }) => ({ id, visible: id === option.id ? !option.visible : visible }))); - }; + const sortedOptions = useMemo(() => getSortedOptions({ options, contentDisplay: value }), [options, value]); + const filteredOptions = useMemo( + () => getFilteredOptions(sortedOptions, columnFilteringText), + [sortedOptions, columnFilteringText] + ); + const optionTree = useMemo( + () => (hasGroups ? buildOptionTree(options, groups, value) : null), + [hasGroups, groups, options, value] + ); + const filteredTree = useMemo( + () => (optionTree ? getFilteredTree(optionTree, columnFilteringText) : null), + [optionTree, columnFilteringText] + ); + const handleToggle = (id: string) => { + // For flat (non-grouped) mode, rebuild from sortedOptions to handle items not in value + if (!hasGroups) { + onChange(sortedOptions.map(opt => ({ id: opt.id, visible: opt.id === id ? !opt.visible : opt.visible }))); + return; + } + // For grouped mode, walk the tree and flip the matching item + const toggle = ( + items: ReadonlyArray + ): CollectionPreferencesProps.ContentDisplayItem[] => + items.map(item => { + if (item.type === 'group') { + return { ...item, children: toggle(item.children) }; + } + return item.id === id ? { ...item, visible: !item.visible } : item; + }); + onChange(toggle(value)); + }; + const noResults = filteredTree ? filteredTree.length === 0 : filteredOptions.length === 0; return (
setColumnFilteringText(detail.filteringText)} countText={i18n( 'contentDisplayPreference.i18nStrings.columnFilteringCountText', - i18nStrings?.columnFilteringCountText - ? i18nStrings?.columnFilteringCountText(sortedAndFilteredOptions.length) - : undefined, - format => format({ count: sortedAndFilteredOptions.length }) + i18nStrings?.columnFilteringCountText?.(filteredOptions.length), + format => format({ count: filteredOptions.length }) )} />
)} - {/* No match */} - {sortedAndFilteredOptions.length === 0 && ( + {noResults && (
@@ -132,48 +312,36 @@ export default function ContentDisplayPreference({
)} - ({ - id: item.id, - content: , - announcementLabel: item.label, - })} - disableItemPaddings={true} - sortable={true} - sortDisabled={columnFilteringText.trim().length > 0} - onSortingChange={({ detail: { items } }) => { - onChange(items); - }} - ariaDescribedby={descriptionId} - ariaLabelledby={titleId} - i18nStrings={{ - liveAnnouncementDndStarted: i18n( - 'contentDisplayPreference.liveAnnouncementDndStarted', - liveAnnouncementDndStarted, - formatDndStarted - ), - liveAnnouncementDndItemReordered: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemReordered', - liveAnnouncementDndItemReordered, - formatDndItemReordered - ), - liveAnnouncementDndItemCommitted: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemCommitted', - liveAnnouncementDndItemCommitted, - formatDndItemCommitted - ), - liveAnnouncementDndDiscarded: i18n( - 'contentDisplayPreference.liveAnnouncementDndDiscarded', - liveAnnouncementDndDiscarded - ), - dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel), - dragHandleAriaDescription: i18n( - 'contentDisplayPreference.dragHandleAriaDescription', - dragHandleAriaDescription - ), - }} - /> +
+ {optionTree && filteredTree ? ( + onChange(toContentDisplayItems(newTree))} + ariaLabelledby={titleId} + ariaDescribedby={descriptionId} + i18nStrings={listI18nStrings} + sortDisabled={isFiltering} + groupLabelFormatter={groupLabelFormatter} + /> + ) : ( + onChange(items.map(({ id, visible }) => ({ id, visible })))} + renderItem={item => ({ + id: item.id, + announcementLabel: item.label, + content: handleToggle(item.id)} />, + })} + /> + )} +
); } diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts index 9877ce3ed6..f313523cb3 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -2,10 +2,47 @@ // SPDX-License-Identifier: Apache-2.0 import { CollectionPreferencesProps } from '../interfaces'; -export interface OptionWithVisibility extends CollectionPreferencesProps.ContentDisplayOption { +type ContentDisplayItem = CollectionPreferencesProps.ContentDisplayItem; +type ContentDisplayOption = CollectionPreferencesProps.ContentDisplayOption; +type ContentDisplayOptionGroup = CollectionPreferencesProps.ContentDisplayOptionGroup; + +export interface OptionWithVisibility extends ContentDisplayOption { + visible: boolean; +} + +export interface OptionGroupNode { + type: 'group'; + id: string; + label: string; visible: boolean; + children: OptionTreeNode[]; +} + +export interface OptionLeafNode extends OptionWithVisibility { + type: 'leaf'; +} + +export type OptionTreeNode = OptionGroupNode | OptionLeafNode; + +/** + * Extracts a flat ordered list of leaf items from the contentDisplay tree (depth-first). + */ +export function walkLeaves(items: ReadonlyArray): { id: string; visible: boolean }[] { + const result: { id: string; visible: boolean }[] = []; + for (const item of items) { + if (item.type === 'group') { + result.push(...walkLeaves(item.children)); + } else { + result.push({ id: item.id, visible: item.visible }); + } + } + return result; } +/** + * Returns options ordered by contentDisplay, with visibility applied. + * Options not in contentDisplay are appended as non-visible. + */ export function getSortedOptions({ options, contentDisplay, @@ -13,27 +50,111 @@ export function getSortedOptions({ options: ReadonlyArray; contentDisplay: ReadonlyArray; }): ReadonlyArray { - // By using a Map, we are guaranteed to preserve insertion order on future iteration. - const optionsById = new Map(); - // We insert contentDisplay first so we respect the currently selected order - for (const { id, visible } of contentDisplay) { - // If an option is provided in contentDisplay and not options, we default the label to the id - optionsById.set(id, { id, label: id, visible }); + const optionMap = new Map(options.map(o => [o.id, o])); + const result = new Map(); + + for (const { id, visible } of walkLeaves(contentDisplay)) { + const option = optionMap.get(id); + if (option) { + result.set(id, { ...option, visible }); + } } - // We merge options data, and insert any that were not in contentDisplay as non-visible + for (const option of options) { - const existing = optionsById.get(option.id); - optionsById.set(option.id, { ...option, visible: !!existing?.visible }); + if (!result.has(option.id)) { + result.set(option.id, { ...option, visible: false }); + } } - return Array.from(optionsById.values()); + + return Array.from(result.values()); } -export function getFilteredOptions(options: ReadonlyArray, filterText: string) { - filterText = filterText.trim().toLowerCase(); +/** + * Converts contentDisplay tree into an internal OptionTreeNode tree, + * resolving labels from options/groups definitions. + */ +export function buildOptionTree( + options: ReadonlyArray, + groups: ReadonlyArray, + contentDisplay: ReadonlyArray +): OptionTreeNode[] { + if (!groups.length) { + const sorted = getSortedOptions({ options, contentDisplay }); + return sorted.map(opt => ({ ...opt, type: 'leaf' as const })); + } - if (!filterText) { - return options; + const optionMap = new Map(options.map(o => [o.id, o])); + const groupMap = new Map(groups.map(g => [g.id, g])); + + const convert = (items: ReadonlyArray): OptionTreeNode[] => { + const result: OptionTreeNode[] = []; + for (const item of items) { + if (item.type === 'group') { + const group = groupMap.get(item.id); + result.push({ + type: 'group', + id: item.id, + label: group?.label ?? item.id, + visible: item.visible, + children: convert(item.children), + }); + } else { + const option = optionMap.get(item.id); + if (option) { + result.push({ type: 'leaf', ...option, visible: item.visible }); + } + } + } + return result; + }; + + return convert(contentDisplay); +} + +/** + * Converts OptionTreeNode[] back to ContentDisplayItem[]. + */ +export function toContentDisplayItems(tree: OptionTreeNode[]): ContentDisplayItem[] { + return tree.map(node => { + if (node.type === 'group') { + return { + type: 'group' as const, + id: node.id, + visible: node.visible, + children: toContentDisplayItems(node.children), + }; + } + return { id: node.id, visible: node.visible }; + }); +} + +/** + * Filters tree, keeping leaves matching filterText and groups with matching descendants. + */ +export function getFilteredTree(tree: OptionTreeNode[], filterText: string): OptionTreeNode[] { + const text = filterText.trim().toLowerCase(); + if (!text) { + return tree; + } + + const result: OptionTreeNode[] = []; + for (const node of tree) { + if (node.type === 'group') { + const children = getFilteredTree(node.children, text); + if (children.length > 0) { + result.push({ ...node, children }); + } + } else if (node.label.toLowerCase().includes(text)) { + result.push(node); + } } + return result; +} - return options.filter(option => option.label.toLowerCase().trim().includes(filterText)); +export function getFilteredOptions(options: ReadonlyArray, filterText: string) { + const text = filterText.trim().toLowerCase(); + if (!text) { + return options; + } + return options.filter(option => option.label.toLowerCase().includes(text)); } diff --git a/src/collection-preferences/index.tsx b/src/collection-preferences/index.tsx index 6e9bdecea5..ec882f66c1 100644 --- a/src/collection-preferences/index.tsx +++ b/src/collection-preferences/index.tsx @@ -24,6 +24,7 @@ import { getComponentAnalyticsMetadata } from './analytics-metadata/utils'; import ContentDisplayPreference from './content-display'; import { CollectionPreferencesProps } from './interfaces'; import { + collectVisibleIds, ContentDensityPreference, copyPreferences, CustomPreference, @@ -138,9 +139,10 @@ export default function CollectionPreferences({ // When both are used contentDisplayPreference takes preference and so we always prefer to use this as our visible columns if available if (preferences?.contentDisplay) { - tableComponentContext.preferencesRef.current.visibleColumns = preferences?.contentDisplay - .filter(column => column.visible) - .map(column => column.id); + tableComponentContext.preferencesRef.current.visibleColumns = collectVisibleIds( + preferences.contentDisplay, + true + ); } else if (preferences?.visibleContent) { tableComponentContext.preferencesRef.current.visibleColumns = [...preferences.visibleContent]; } diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts index 5768238b0a..9e8c8045fb 100644 --- a/src/collection-preferences/interfaces.ts +++ b/src/collection-preferences/interfaces.ts @@ -109,6 +109,9 @@ export interface CollectionPreferencesProps extends * - `title` (string) - Specifies the text displayed at the top of the preference. * - `description` (string) - Specifies the description displayed below the title. * - `options` - Specifies an array of options for reordering and visible content selection. + * - `groups` - (Optional) Specifies an array of column group definitions for multi-level content display. Each group contains: + * - `id` (string) - A unique identifier for the group. + * - `label` (string) - The text displayed as the group label. * - `enableColumnFiltering` (boolean) - Adds a columns filter. * - `liveAnnouncementDndStarted` ((position: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when an option is picked. * - `liveAnnouncementDndDiscarded` (string) - (Optional) Adds a message to be announced by screen readers when a reordering action is canceled. @@ -116,6 +119,7 @@ export interface CollectionPreferencesProps extends * - `liveAnnouncementDndItemCommitted` ((initialPosition: number, finalPosition: number, total: number) => string) - (Optional) Adds a message to be announced by screen readers when a reordering action is committed. * - `dragHandleAriaDescription` (string) - (Optional) Adds an ARIA description for the drag handle. * - `dragHandleAriaLabel` (string) - (Optional) Adds an ARIA label for the drag handle. + * - `liveAnnouncementDndGroupLabel` ((label: string, count: number) => string) - (Optional) Adds a label for a group item to be announced by screen readers during drag and drop operations. * * Each option contains the following: * - `id` (string) - Corresponds to a table column `id`. @@ -123,6 +127,16 @@ export interface CollectionPreferencesProps extends * - `alwaysVisible` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to `false` by default. * * You must provide an ordered list of the items to display in the `preferences.contentDisplay` property. + * Each content display item is one of the following: + * - `ContentDisplayColumn` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. + * - `visible` (boolean) - Whether the column is visible. + * - `ContentDisplayGroup` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. * @i18n */ contentDisplayPreference?: CollectionPreferencesProps.ContentDisplayPreference; @@ -229,19 +243,36 @@ export namespace CollectionPreferencesProps { title?: string; description?: string; options: ReadonlyArray; + groups?: ReadonlyArray; enableColumnFiltering?: boolean; i18nStrings?: ContentDisplayPreferenceI18nStrings; + liveAnnouncementDndGroupLabel?: (label: string, count: number) => string; } + export interface ContentDisplayColumn { + type?: 'column'; + id: string; + visible: boolean; + } + + export interface ContentDisplayGroup { + type: 'group'; + id: string; + visible: boolean; + children: ReadonlyArray; + } + + export type ContentDisplayItem = ContentDisplayColumn | ContentDisplayGroup; + export interface ContentDisplayOption { id: string; label: string; alwaysVisible?: boolean; } - export interface ContentDisplayItem { + export interface ContentDisplayOptionGroup { id: string; - visible: boolean; + label: string; } export interface VisibleContentPreference { diff --git a/src/collection-preferences/utils.tsx b/src/collection-preferences/utils.tsx index f02981cab2..aaffcd8239 100644 --- a/src/collection-preferences/utils.tsx +++ b/src/collection-preferences/utils.tsx @@ -230,6 +230,23 @@ export const StickyColumnsPreference = ({ ); }; +export const collectVisibleIds = ( + items: ReadonlyArray, + ancestorVisible: boolean +): string[] => { + const result: string[] = []; + for (const item of items) { + if (item.type === 'group') { + if (ancestorVisible && item.visible) { + result.push(...collectVisibleIds(item.children, true)); + } + } else if (ancestorVisible && item.visible) { + result.push(item.id); + } + } + return result; +}; + interface CustomPreferenceProps extends Pick, 'customPreference'> { onChange: (value: T) => void; value: T; diff --git a/src/expandable-section/expandable-section-header.tsx b/src/expandable-section/expandable-section-header.tsx index 357620cce9..44143e286f 100644 --- a/src/expandable-section/expandable-section-header.tsx +++ b/src/expandable-section/expandable-section-header.tsx @@ -3,7 +3,7 @@ import React, { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react'; import clsx from 'clsx'; -import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import InternalHeader, { Description as HeaderDescription } from '../header/internal'; @@ -108,7 +108,15 @@ const ExpandableDeprecatedHeader = ({ aria-expanded={expanded} {...getExpandActionAnalyticsMetadataAttribute(expanded)} > -
{icon}
+
+ {icon} +
{children}
); @@ -128,7 +136,11 @@ const ExpandableNavigationHeader = ({ return (
diff --git a/src/internal/components/button-trigger/styles.scss b/src/internal/components/button-trigger/styles.scss index a68a7967f4..1fea8b2ee7 100644 --- a/src/internal/components/button-trigger/styles.scss +++ b/src/internal/components/button-trigger/styles.scss @@ -12,6 +12,7 @@ $padding-inline-inner-filtering-token: styles.$control-padding-horizontal; $padding-block-inner-filtering-token: 0px; +$icon-offset: awsui.$space-xxxs; .button-trigger { @include styles.styles-reset; @@ -69,6 +70,9 @@ $padding-block-inner-filtering-token: 0px; inset-inline-end: styles.$control-icon-horizontal-offset; inset-block-start: styles.$control-icon-vertical-offset; color: awsui.$color-text-button-inline-icon-default; + &.one-theme { + inset-block-start: calc(styles.$control-icon-vertical-offset + $icon-offset); + } } &:hover { @@ -80,6 +84,9 @@ $padding-block-inner-filtering-token: 0px; &.pressed { > .arrow { transform: rotate(-180deg); + &.one-theme { + inset-block-start: calc(styles.$control-icon-vertical-offset - $icon-offset); + } } } diff --git a/src/internal/components/expand-toggle-button/index.tsx b/src/internal/components/expand-toggle-button/index.tsx index 2a435d90c5..6aa2953247 100644 --- a/src/internal/components/expand-toggle-button/index.tsx +++ b/src/internal/components/expand-toggle-button/index.tsx @@ -4,7 +4,7 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; -import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { isThemeActive, Theme, useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; import InternalIcon from '../../../icon/internal'; @@ -42,8 +42,8 @@ export function ExpandToggleButton({ > {customIcon ?? ( )} diff --git a/src/pagination/__tests__/pagination.test.tsx b/src/pagination/__tests__/pagination.test.tsx index f4ee7f7910..692a764b39 100644 --- a/src/pagination/__tests__/pagination.test.tsx +++ b/src/pagination/__tests__/pagination.test.tsx @@ -47,24 +47,24 @@ test('should re-render component correctly after current page state change', () test('should have both arrows disabled when there is only one page', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); expect(wrapper.findPageNumberByIndex(1)!.getElement()).toHaveTextContent('1'); expect(getItemsContent(wrapper)).toEqual(['1']); }); test('should have both arrows disabled when there are no pages', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); expect(wrapper.findPageNumberByIndex(1)!.getElement()).toHaveTextContent('1'); expect(getItemsContent(wrapper)).toEqual(['1']); }); test('should show all buttons when middle page selected', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeEnabled(); - expect(wrapper.findNextPageButton().getElement()).toBeEnabled(); + expect(wrapper.findPreviousPageButton().getElement()).not.toHaveAttribute('aria-disabled'); + expect(wrapper.findNextPageButton().getElement()).not.toHaveAttribute('aria-disabled'); expect(wrapper.findCurrentPage().getElement()).toHaveTextContent('5'); expect(getItemsContent(wrapper)).toEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']); }); @@ -86,8 +86,8 @@ test('should not fire nextPageClick event when clicking next page with the last test('should disable `previous` button when first page selected', () => { const { wrapper } = renderPagination(); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeEnabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).not.toHaveAttribute('aria-disabled'); expect(wrapper.findCurrentPage().getElement()).toHaveTextContent('1'); }); @@ -189,8 +189,8 @@ test('should not fire nextPageClick event when clicking next page with the last ); expect(wrapper.isDisabled()).toBe(true); - expect(wrapper.findPreviousPageButton().getElement()).toBeDisabled(); - expect(wrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(wrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); + expect(wrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); wrapper.findPreviousPageButton().click(); expect(onChange).not.toHaveBeenCalled(); diff --git a/src/pagination/internal.tsx b/src/pagination/internal.tsx index 62f07af47c..c7e0ad2274 100644 --- a/src/pagination/internal.tsx +++ b/src/pagination/internal.tsx @@ -47,6 +47,9 @@ function PageButton({ ...rest }: PageButtonProps) { function handleClick(event: React.MouseEvent) { + if (disabled) { + return; + } event.preventDefault(); onClick(pageIndex); } @@ -61,7 +64,8 @@ function PageButton({ )} type="button" aria-label={ariaLabel} - disabled={disabled} + aria-disabled={disabled ? true : undefined} + tabIndex={disabled ? -1 : 0} onClick={handleClick} aria-current={isCurrent} {...(disabled diff --git a/src/status-indicator/internal.tsx b/src/status-indicator/internal.tsx index db189302bd..eec4a36370 100644 --- a/src/status-indicator/internal.tsx +++ b/src/status-indicator/internal.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; + import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; @@ -63,7 +65,11 @@ export function InternalStatusIcon({ }: InternalStatusIconProps) { return ( @@ -82,7 +88,7 @@ export default function StatusIndicator({ nativeAttributes, __animate = false, __internalRootRef, - __size = 'normal', + __size = isThemeActive(Theme.OneTheme) ? 'x-small' : 'normal', __display = 'inline-block', ...rest }: InternalStatusIndicatorProps) { @@ -107,6 +113,7 @@ export default function StatusIndicator({ className={clsx( styles.container, styles[`display-${__display}`], + isThemeActive(Theme.OneTheme) && styles['one-theme'], wrapText === false && styles['overflow-ellipsis'], __animate && styles['container-fade-in'] )} diff --git a/src/status-indicator/styles.scss b/src/status-indicator/styles.scss index e2039c69fb..ba6b1872ca 100644 --- a/src/status-indicator/styles.scss +++ b/src/status-indicator/styles.scss @@ -41,6 +41,14 @@ $_status-backgrounds: ( 'not-started': awsui.$color-background-status-indicator-neutral, ); +$_background-overrides: ( + 'red': awsui.$color-background-status-indicator-error, + 'grey': awsui.$color-background-status-indicator-neutral, + 'blue': awsui.$color-background-status-indicator-info, + 'green': awsui.$color-background-status-indicator-success, + 'yellow': awsui.$color-background-status-indicator-warning, +); + .root { @include styles.default-text-style; @each $status in map.keys($_status-colors) { @@ -58,6 +66,11 @@ $_status-backgrounds: ( background: #{map.get($_status-backgrounds, $status)}; } } + @each $color in map.keys($_background-overrides) { + &.color-override-#{$color} > .container { + background: #{map.get($_background-overrides, $color)}; + } + } } .container { @@ -72,6 +85,9 @@ $_status-backgrounds: ( > .icon { white-space: nowrap; + &.one-theme { + vertical-align: middle; + } } } @@ -79,9 +95,19 @@ $_status-backgrounds: ( display: inline-block; word-wrap: break-word; word-break: break-all; + &.one-theme { + display: inline-flex; + align-items: flex-start; + } > .icon { padding-inline-end: awsui.$space-xxs; + + &.one-theme { + display: inline-flex; + align-items: center; + margin-block-start: awsui.$space-xxxs; + } } } } @@ -92,4 +118,14 @@ $_status-backgrounds: ( text-overflow: ellipsis; white-space: nowrap; vertical-align: text-bottom; + + &.one-theme { + text-overflow: unset; + + > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } } diff --git a/src/table/__tests__/column-groups.test.tsx b/src/table/__tests__/column-groups.test.tsx new file mode 100644 index 0000000000..f2c35c920b --- /dev/null +++ b/src/table/__tests__/column-groups.test.tsx @@ -0,0 +1,1024 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { PointerEventMock } from '../../../lib/components/internal/utils/pointer-events-mock'; +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +beforeAll(() => { + (window as any).PointerEvent ??= PointerEventMock; +}); + +interface Item { + id: string; + name: string; + type: string; + az: string; + cpu: number; + memory: number; +} + +const items: Item[] = [ + { id: 'i-1', name: 'web', type: 't3.medium', az: 'us-east-1a', cpu: 45, memory: 62 }, + { id: 'i-2', name: 'api', type: 't3.large', az: 'us-east-1b', cpu: 78, memory: 81 }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id }, + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'az', header: 'AZ', cell: item => item.az }, + { id: 'cpu', header: 'CPU', cell: item => `${item.cpu}%` }, + { id: 'memory', header: 'Memory', cell: item => `${item.memory}%` }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'perf', header: 'Performance' }, +]; + +const singleLevelDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { + type: 'group', + id: 'perf', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, +]; + +function renderTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; +} + +describe('Column grouping rendering', () => { + test('renders two header rows for single-level grouping', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('renders group header cells with correct colspan', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const groupCells = firstRow.findAll('th[scope="colgroup"]'); + + expect(groupCells).toHaveLength(2); + expect(groupCells[0].getElement().getAttribute('colspan')).toBe('2'); + expect(groupCells[1].getElement().getAttribute('colspan')).toBe('2'); + }); + + test('renders group header labels', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const groupCells = firstRow.findAll('th[scope="colgroup"]'); + + expect(groupCells[0].getElement().textContent).toContain('Configuration'); + expect(groupCells[1].getElement().textContent).toContain('Performance'); + }); + + test('ungrouped columns get rowspan=2 in first row', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const columnCells = firstRow.findAll('th[scope="col"]'); + + // id and name are ungrouped, should span both rows + const idCell = columnCells.find(el => el.getElement().textContent?.includes('ID')); + const nameCell = columnCells.find(el => el.getElement().textContent?.includes('Name')); + expect(idCell!.getElement().getAttribute('rowspan')).toBe('2'); + expect(nameCell!.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('columns under groups appear in second row', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const secondRow = thead.findAll('tr')[1]; + const cells = secondRow.findAll('th[scope="col"]'); + + const labels = cells.map(c => c.getElement().textContent?.trim()); + expect(labels).toEqual(expect.arrayContaining(['Type', 'AZ', 'CPU', 'Memory'])); + expect(cells).toHaveLength(4); + }); + + test('columns under groups have data-column-group-id', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + + const configColumns = thead.findAll('th[data-column-group-id="config"]'); + const perfColumns = thead.findAll('th[data-column-group-id="perf"]'); + + expect(configColumns).toHaveLength(2); // type, az + expect(perfColumns).toHaveLength(2); // cpu, memory + }); + + test('columns have data-column-index', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[data-column-index]'); + + // All 6 columns should have data-column-index + expect(columnCells).toHaveLength(6); + expect(columnCells[0].getElement().getAttribute('data-column-index')).toBe('1'); + expect(columnCells[5].getElement().getAttribute('data-column-index')).toBe('6'); + }); + + test('group header cells do not have data-column-index', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.getElement().hasAttribute('data-column-index')).toBe(false); + }); + }); + + test('findColumnHeaders returns only columns by default', () => { + const wrapper = renderTable(); + const headers = wrapper.findColumnHeaders(); + + expect(headers.length).toBeGreaterThanOrEqual(6); + const texts = headers.map(h => h.getElement().textContent); + expect(texts).toContain('ID'); + expect(texts.find(t => t?.includes('Memory'))).toBeDefined(); + }); + + test('findColumnHeaders with groupId returns only that group columns', () => { + const wrapper = renderTable(); + const configHeaders = wrapper.findColumnHeaders({ groupId: 'config' }); + const perfHeaders = wrapper.findColumnHeaders({ groupId: 'perf' }); + + expect(configHeaders).toHaveLength(2); + expect(configHeaders[0].getElement().textContent).toContain('Type'); + expect(configHeaders[1].getElement().textContent).toContain('AZ'); + expect(perfHeaders).toHaveLength(2); + }); + + test('renders single row when no groupDefinitions provided', () => { + const wrapper = renderTable({ groupDefinitions: undefined, columnDisplay: undefined }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(1); + }); + + test('renders resizers on group header cells when resizableColumns is true', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.find('[class*="resizer"]')).not.toBeNull(); + }); + }); + + test('renders dividers on non-rightmost cells when resizableColumns is false', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + + // All non-rightmost column cells should have a divider + const columnCells = thead.findAll('th[scope="col"]'); + const nonRightmost = columnCells.filter(c => !c.getElement().hasAttribute('data-rightmost')); + nonRightmost.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + + // Rightmost cell should not have a divider (CSS hides it via data-rightmost) + const rightmost = columnCells.find(c => c.getElement().hasAttribute('data-rightmost')); + expect(rightmost).toBeDefined(); + }); + + test('selection cell spans all header rows', () => { + const wrapper = renderTable({ selectionType: 'multi' }); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const selectionCell = firstRow.findAll('th')[0]; + + expect(selectionCell.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('hidden columns are excluded from rendering', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: false }, + ], + }, + ]; + const wrapper = renderTable({ columnDisplay: display }); + const headers = wrapper.findColumnHeaders(); + + const labels = headers.map(h => h.getElement().textContent?.trim()); + expect(labels).toContain('ID'); + expect(labels).toContain('Type'); + expect(labels).not.toContain('AZ'); + }); + + test('group is omitted when all children are hidden', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: false }, + { id: 'az', visible: false }, + ], + }, + { type: 'group', id: 'perf', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const wrapper = renderTable({ columnDisplay: display }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Only perf group should render + expect(groupCells).toHaveLength(1); + expect(groupCells[0].getElement().textContent).toContain('Performance'); + }); +}); + +describe('Column grouping with sticky columns', () => { + test('renders correctly with stickyColumns first', () => { + const wrapper = renderTable({ stickyColumns: { first: 1 } }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + + // First column (id) should have sticky styles + const firstCol = thead.find('th[data-column-index="1"]')!; + expect(firstCol.getElement().style.position || firstCol.getElement().className).toBeDefined(); + }); + + test('renders correctly with stickyColumns last', () => { + const wrapper = renderTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + expect(columnCells.length).toBe(6); + }); + + test('group spanning sticky-first boundary renders split cells', () => { + // stickyColumns.first = 3 means columns at index 0,1,2 are sticky (id, name, type) + // 'config' group has type(colIndex=2), az(colIndex=3) — straddles boundary + const wrapper = renderTable({ stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // config group split into 2 halves + perf group = 3 group cells + expect(groupCells.length).toBe(3); + + // The split config halves: first half has colspan=1 (type), second has colspan=1 (az) + const configCells = groupCells.filter(c => c.getElement().textContent?.includes('Configuration')); + expect(configCells).toHaveLength(2); + expect(configCells[0].getElement().getAttribute('colspan')).toBe('1'); + expect(configCells[1].getElement().getAttribute('colspan')).toBe('1'); + }); + + test('group spanning sticky-last boundary renders split cells', () => { + // stickyColumns.last = 1 means last column (memory, colIndex=5) is sticky + // 'perf' group has cpu(colIndex=4), memory(colIndex=5) — straddles boundary + const wrapper = renderTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // perf group split into 2 halves + config group = 3 group cells + expect(groupCells.length).toBe(3); + + const perfCells = groupCells.filter(c => c.getElement().textContent?.includes('Performance')); + expect(perfCells).toHaveLength(2); + }); + + test('fully sticky group (all children within boundary) is not split', () => { + // stickyColumns.first = 4 means id, name, type, az are sticky + // 'config' group has type(2), az(3) — both within boundary, no split + const wrapper = renderTable({ stickyColumns: { first: 4 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + const configCells = groupCells.filter(c => c.getElement().textContent?.includes('Configuration')); + expect(configCells).toHaveLength(1); + expect(configCells[0].getElement().getAttribute('colspan')).toBe('2'); + }); + + test('group entirely outside sticky boundary is not split', () => { + // stickyColumns.first = 1 means only id is sticky + // Both groups are entirely outside the boundary + const wrapper = renderTable({ stickyColumns: { first: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + expect(groupCells).toHaveLength(2); + expect(groupCells[0].getElement().getAttribute('colspan')).toBe('2'); + expect(groupCells[1].getElement().getAttribute('colspan')).toBe('2'); + }); +}); + +describe('Column grouping with resizable columns', () => { + test('group header cells have resizers', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + const resizer = cell.find('button[class*="resizer"]'); + expect(resizer).not.toBeNull(); + }); + }); + + test('column cells have resizers', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + + columnCells.forEach(cell => { + const resizer = cell.find('button[class*="resizer"]'); + expect(resizer).not.toBeNull(); + }); + }); + + test('findColumnResizer works with grouped columns', () => { + const wrapper = renderTable({ resizableColumns: true }); + // Column index 3 = 'type' (first child of config group) + const resizer = wrapper.findColumnResizer(3); + expect(resizer).not.toBeNull(); + }); + + test('group resizer has aria-labelledby pointing to group header', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const headerId = groupCell.find('[id^="table-group-header"]')!.getElement().id; + const resizer = groupCell.find('button[class*="resizer"]')!; + + expect(resizer.getElement().getAttribute('aria-labelledby')).toBe(headerId); + }); + + test('renders resizable grouped table with onColumnWidthsChange callback', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderTable({ resizableColumns: true, onColumnWidthsChange }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('button[class*="resizer"]').length).toBeGreaterThanOrEqual(2); + }); + + test('columns have width styles when resizable', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const wrapper = renderTable({ resizableColumns: true, columnDefinitions: colDefs }); + const columnCells = wrapper.findColumnHeaders(); + + // At least some cells should have width set + const hasWidth = columnCells.some(cell => cell.getElement().style.width !== ''); + expect(hasWidth).toBe(true); + }); +}); + +describe('Column grouping with sticky header', () => { + test('renders with stickyHeader enabled', () => { + const wrapper = renderTable({ stickyHeader: true }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('sticky header renders group cells', () => { + const wrapper = renderTable({ stickyHeader: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('sticky header with resizable columns renders correctly', () => { + const wrapper = renderTable({ stickyHeader: true, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + + groupCells.forEach(cell => { + expect(cell.find('button[class*="resizer"]')).not.toBeNull(); + }); + }); + + test('sticky header with sticky columns and groups', () => { + const wrapper = renderTable({ stickyHeader: true, stickyColumns: { first: 2 } }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); + +describe('Column grouping with selection', () => { + test('multi selection with groups renders correctly', () => { + const wrapper = renderTable({ selectionType: 'multi' }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + + // Selection cell in first row spans all header rows + const firstRowCells = rows[0].findAll('th'); + const selectionCell = firstRowCells[0]; + expect(selectionCell.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('single selection with groups renders correctly', () => { + const wrapper = renderTable({ selectionType: 'single' }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('renders colgroup with selection col for grouped table', () => { + const wrapper = renderTable({ selectionType: 'multi', resizableColumns: true }); + const cols = wrapper.getElement().querySelectorAll('colgroup col'); + // 6 data columns + 1 selection col + expect(cols.length).toBe(7); + }); +}); + +describe('Column grouping with other features', () => { + test('renders with wrapLines enabled', () => { + const wrapper = renderTable({ wrapLines: true }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with stripedRows enabled', () => { + const wrapper = renderTable({ stripedRows: true }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with contentDensity compact', () => { + const wrapper = renderTable({ contentDensity: 'compact' }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with sortingDisabled', () => { + const wrapper = renderTable({ sortingDisabled: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('renders in loading state', () => { + const wrapper = renderTable({ loading: true, loadingText: 'Loading...' }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + }); + + test('renders with empty items', () => { + const wrapper = renderTable({ items: [] }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('renders with variant full-page', () => { + const wrapper = renderTable({ variant: 'full-page' }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); + + test('renders with variant borderless', () => { + const wrapper = renderTable({ variant: 'borderless' }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with enableKeyboardNavigation', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); + +describe('Column grouping sorting', () => { + test('findColumnSortingArea works with grouped columns', () => { + const sortableColumns: TableProps.ColumnDefinition[] = columnDefinitions.map(col => ({ + ...col, + sortingField: col.id, + })); + const { container } = render( +
+ ); + const tableWrapper = createWrapper(container).findTable()!; + const sortArea = tableWrapper.findColumnSortingArea(3); + expect(sortArea).not.toBeNull(); + }); + + test('sorting area is not present on group header cells', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.find('[role="button"]')).toBeNull(); + }); + }); +}); + +describe('Column grouping divider positioning', () => { + test('group header cells render dividers', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Non-rightmost groups should have dividers + const nonRightmostGroups = groupCells.filter(c => !c.getElement().hasAttribute('data-rightmost')); + nonRightmostGroups.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + }); + + test('column cells under groups render dividers', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + const groupedLeaves = thead.findAll('th[data-column-group-id]'); + + groupedLeaves.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + }); + + test('rightmost cell has data-rightmost attribute', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rightmostCells = thead.findAll('th[data-rightmost]'); + expect(rightmostCells.length).toBeGreaterThanOrEqual(1); + }); + + test('non-rightmost cells do not have data-rightmost', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const typeCell = thead.find('th[data-column-index="3"]')!; + expect(typeCell.getElement().hasAttribute('data-rightmost')).toBe(false); + }); +}); + +describe('Column grouping with keyboard navigation', () => { + test('renders with enableKeyboardNavigation', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('group cells have correct aria-colindex', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // config group starts at colIndex 2 (0-based), rendered as aria-colindex 3 (1-based) + const configGroup = groupCells.find(c => c.getElement().textContent?.includes('Configuration')); + const perfGroup = groupCells.find(c => c.getElement().textContent?.includes('Performance')); + + expect(configGroup!.getElement().getAttribute('aria-colindex')).toBeDefined(); + expect(perfGroup!.getElement().getAttribute('aria-colindex')).toBeDefined(); + }); + + test('column cells have correct aria-colindex', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + + // type is at colIndex 2 (0-based), aria-colindex should be 3 (1-based) + const typeCell = thead.find('th[data-column-index="3"]')!; + expect(typeCell.getElement().getAttribute('aria-colindex')).toBe('3'); + }); +}); + +describe('Column grouping aria attributes', () => { + test('group cells have scope=colgroup', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('column cells have scope=col', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + expect(columnCells).toHaveLength(6); + }); + + test('header rows have aria-rowindex', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + + expect(rows[0].getElement().getAttribute('aria-rowindex')).toBe('1'); + expect(rows[1].getElement().getAttribute('aria-rowindex')).toBe('2'); + }); +}); + +describe('Column grouping sticky split rendering', () => { + test('split group renders two group cells with updateGroupWidth callbacks', () => { + // stickyColumns.first = 3: id(0), name(1), type(2) are sticky + // config group has type(2), az(3) — straddles boundary + const wrapper = renderTable({ stickyColumns: { first: 3 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + // config split into 2 + perf = 3 + expect(groupCells.length).toBe(3); + }); + + test('split group with stickyColumns.last renders correctly', () => { + // stickyColumns.last = 1: memory(5) is sticky + // perf group has cpu(4), memory(5) — straddles boundary + const wrapper = renderTable({ stickyColumns: { last: 1 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells.length).toBe(3); + }); + + test('fully sticky group gets stickyColumnId from first child', () => { + // stickyColumns.first = 4: id, name, type, az are sticky + // config group (type, az) is fully within sticky boundary + const wrapper = renderTable({ stickyColumns: { first: 4 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + const configGroup = groupCells.find(c => c.getElement().textContent?.includes('Configuration')); + expect(configGroup).toBeDefined(); + }); + + test('fully sticky last group gets stickyColumnId from last child', () => { + // stickyColumns.last = 2: cpu(4), memory(5) are sticky + // perf group (cpu, memory) is fully within sticky-last boundary + const wrapper = renderTable({ stickyColumns: { last: 2 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + const perfGroup = groupCells.find(c => c.getElement().textContent?.includes('Performance')); + expect(perfGroup).toBeDefined(); + }); +}); + +describe('Column grouping focus handling', () => { + test('onFocusedComponentChange is called on header focus', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + + // Focus a header cell — verify it has focus tracking wired up + const th = firstRow.findAll('th')[0]; + fireEvent.focus(th.getElement()); + expect(th.getElement().getAttribute('data-focus-id')).toBeTruthy(); + }); + + test('onBlur resets focused component', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + + const th = firstRow.findAll('th')[0]; + fireEvent.focus(th.getElement()); + fireEvent.blur(th.getElement()); + // After blur, the focus indicator should be removed + expect(th.getElement().classList.toString()).not.toContain('fake-focus'); + }); +}); + +describe('Column grouping with non-resizable columns', () => { + test('grouped column cells get inline styles when not resizable', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150, minWidth: 100 })); + const wrapper = renderTable({ resizableColumns: false, columnDefinitions: colDefs }); + const thead = wrapper.find('thead')!; + const columnCells = thead.findAll('th[scope="col"]'); + // Cells should have width styles applied directly + expect(columnCells.length).toBe(6); + }); + + test('sorting fires onSortingChange for grouped columns', () => { + const onSortingChange = jest.fn(); + const sortableColumns = columnDefinitions.map(col => ({ ...col, sortingField: col.id })); + const { container } = render( +
onSortingChange(event.detail)} + /> + ); + const wrapper = createWrapper(container).findTable()!; + const sortArea = wrapper.findColumnSortingArea(3); + sortArea!.click(); + expect(onSortingChange).toHaveBeenCalledWith( + expect.objectContaining({ sortingColumn: expect.objectContaining({ id: 'type' }) }) + ); + }); +}); + +describe('Column grouping resize interactions', () => { + test('grouped resizable table renders with colgroup and col elements', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render( +
+ ); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).not.toBeNull(); + const cols = colgroup!.querySelectorAll('col'); + // 6 columns + expect(cols.length).toBe(6); + }); + + test('col elements have data-column-id attributes', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render( +
+ ); + const cols = container.querySelectorAll('col[data-column-id]'); + expect(cols.length).toBe(6); + expect(cols[0].getAttribute('data-column-id')).toBe('id'); + expect(cols[5].getAttribute('data-column-id')).toBe('memory'); + }); + + test('non-grouped resizable table does not render colgroup', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render(
); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).toBeNull(); + }); +}); + +describe('Column grouping keyboard navigation', () => { + test('handles arrow key events across grouped header rows', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + const firstTh = thead.querySelector('th')!; + + // Focus the first header cell + firstTh.focus(); + + // Press arrow down — should move to first body cell + fireEvent.keyDown(table, { key: 'ArrowDown', keyCode: 40 }); + expect(container.querySelector('tbody td')).toBeTruthy(); + }); + + test('handles keyboard events on cells with colspan', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + + // Focus a group header cell (has colspan) + const groupTh = thead.querySelector('th[scope="colgroup"]') as HTMLElement; + groupTh.focus(); + + // Navigate down from group header to column row + fireEvent.keyDown(table, { key: 'ArrowDown', keyCode: 40 }); + + // Column cells exist in the second header row for navigation targets + const secondRow = thead.querySelectorAll('tr')[1]; + expect(secondRow.querySelector('th')).toBeTruthy(); + }); +}); + +describe('Column grouping vertical navigation with rowspan', () => { + test('handles arrow up from body with rowspan header cells', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + const tbody = container.querySelector('tbody')!; + const firstBodyCell = tbody.querySelector('td') as HTMLElement; + + // Focus a body cell + firstBodyCell.focus(); + + // Navigate up — should go to the header cell in the same column + fireEvent.keyDown(table, { key: 'ArrowUp', keyCode: 38 }); + expect(thead.querySelector('th')).toBeTruthy(); + }); + + test('handles arrow up from column header row', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + + // Focus a column cell in the second header row + const secondRow = thead.querySelectorAll('tr')[1]; + const columnTh = secondRow?.querySelector('th') as HTMLElement; + if (columnTh) { + columnTh.focus(); + // Navigate up — should go to the group header in the first row + fireEvent.keyDown(table, { key: 'ArrowUp', keyCode: 38 }); + expect(thead.querySelector('th[scope="colgroup"]')).toBeTruthy(); + } + }); +}); + +describe('Column grouping with sticky header scrolling', () => { + test('renders with stickyHeader and grouped columns without error', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + expect(wrapper.find('thead')).not.toBeNull(); + // Sticky header with grouped columns renders both header rows + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr').length).toBe(2); + }); +}); +describe('Column grouping group resize callbacks', () => { + const resizableColDefs = columnDefinitions.map(col => ({ ...col, width: 200, minWidth: 100 })); + + function renderResizableGroupedTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; + } + + test('group header can be resized with pointer drag', () => { + const wrapper = renderResizableGroupedTable(); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const resizerBtn = groupCell.find('button')!; + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // No error — updateGroup was called + expect(thead.findAll('th[scope="colgroup"]').length).toBe(2); + }); + + test('group resize completes full pointer lifecycle without errors', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderResizableGroupedTable({ onColumnWidthsChange }); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const resizerBtn = groupCell.find('button')!; + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after resize lifecycle + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('split group resize works with stickyColumns.first', () => { + const wrapper = renderResizableGroupedTable({ stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Split group should have resizers + expect(groupCells.length).toBe(3); + const splitGroupCell = groupCells[0]; + const resizerBtn = splitGroupCell.find('button')!; + expect(resizerBtn).not.toBeNull(); + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after split resize + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(3); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('split group resize works with stickyColumns.last', () => { + const wrapper = renderResizableGroupedTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + expect(groupCells.length).toBe(3); + const lastSplitCell = groupCells[groupCells.length - 1]; + const resizerBtn = lastSplitCell.find('button')!; + expect(resizerBtn).not.toBeNull(); + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after split resize + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(3); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('column resize completes pointer lifecycle in grouped table', () => { + const wrapper = renderResizableGroupedTable(); + const resizer = wrapper.findColumnResizer(3); + expect(resizer).not.toBeNull(); + + resizer!.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Columns and group structure remain intact after resize + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 3dfb4ab564..9ec3a24158 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -6,7 +6,19 @@ import { render } from '@testing-library/react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import Table, { TableProps } from '../../../lib/components/table'; +import { ColumnWidthsProvider, useColumnWidths } from '../../../lib/components/table/use-column-widths'; import createWrapper, { ElementWrapper } from '../../../lib/components/test-utils/dom'; +import { fakeBoundingClientRect, firePointerdown, firePointermove, firePointerup } from './utils/resize-actions'; + +jest.mock('../../../lib/components/internal/utils/scrollable-containers', () => ({ + ...jest.requireActual('../../../lib/components/internal/utils/scrollable-containers'), + getOverflowParents: jest.fn(() => { + const overflowParent = document.createElement('div'); + overflowParent.style.width = '1000px'; + overflowParent.getBoundingClientRect = fakeBoundingClientRect; + return [overflowParent]; + }), +})); jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), @@ -37,6 +49,57 @@ interface Item { const defaultItems = [{ id: 0, text: 'test' }]; +test('useColumnWidths returns safe defaults without provider', () => { + function Bare() { + const ctx = useColumnWidths(); + expect(ctx.getColumnStyles(false, 'x')).toEqual({}); + expect(ctx.columnWidths).toEqual(new Map()); + expect(() => ctx.updateColumn('x', 100)).not.toThrow(); + expect(() => ctx.updateGroup('x', 100)).not.toThrow(); + expect(() => ctx.setCell(false, 'x', null)).not.toThrow(); + expect(() => ctx.setCol('x', null)).not.toThrow(); + return null; + } + render(); +}); + +test('updateGroup does not crash without groupColumnMap', () => { + let updateGroup: (groupId: PropertyKey, width: number) => void; + function Consumer() { + ({ updateGroup } = useColumnWidths()); + return null; + } + const containerRef = { current: document.createElement('div') } as React.RefObject; + render( + + + + ); + // No groupColumnMap passed → guard returns early + expect(() => updateGroup!('any', 200)).not.toThrow(); +}); + +test('updateGroup does not crash for unknown groupId', () => { + let updateGroup: (groupId: PropertyKey, width: number) => void; + function Consumer() { + ({ updateGroup } = useColumnWidths()); + return null; + } + const containerRef = { current: document.createElement('div') } as React.RefObject; + render( + + + + ); + // groupColumnMap exists but 'unknown' not in it → columnIds=[], rightmostColumn undefined → guard returns + expect(() => updateGroup!('unknown', 200)).not.toThrow(); +}); + test('assigns width configuration to columns', () => { const columns: TableProps.ColumnDefinition[] = [ { header: 'id', cell: item => item.id, minWidth: '30%', width: '50%', maxWidth: '80%' }, @@ -271,3 +334,112 @@ describe('with stickyHeader=true', () => { ]); }); }); + +describe('with grouped columns', () => { + const groupedColumns: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id, width: 150 }, + { id: 'name', header: 'Name', cell: item => item.text, width: 150 }, + { id: 'type', header: 'Type', cell: () => '-', width: 200 }, + { id: 'az', header: 'AZ', cell: () => '-', width: 200 }, + ]; + const groupDefinitions: TableProps.GroupDefinition[] = [{ id: 'config', header: 'Configuration' }]; + const columnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + ]; + + function renderGroupedTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; + } + + test('renders colgroup with col elements for grouped resizable table', () => { + const wrapper = renderGroupedTable(); + const cols = wrapper.getElement().querySelectorAll('colgroup col'); + expect(cols.length).toBe(4); + }); + + test('assigns widths to columns in grouped table', () => { + const wrapper = renderGroupedTable(); + const columnCells = wrapper.findAll('thead th[scope="col"]'); + expect(columnCells[0].getElement().style.width).toBe('150px'); + expect(columnCells[1].getElement().style.width).toBe('150px'); + expect(columnCells[2].getElement().style.width).toBe('200px'); + expect(columnCells[3].getElement().style.width).toBe('200px'); + }); + + test('resizing a group applies the width delta to the last column in the group', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderGroupedTable({ onColumnWidthsChange }); + const groupCell = wrapper.find('thead th[scope="colgroup"]')!; + const resizerBtn = new ElementWrapper(groupCell.find('button')!.getElement()); + + firePointerdown(resizerBtn); + firePointermove(500); + firePointerup(500); + + expect(onColumnWidthsChange).toHaveBeenCalledTimes(1); + // Group total was 400 (200+200), resized to 500 → delta 100 applied to last column 'az' + expect(onColumnWidthsChange.mock.calls[0][0].detail).toEqual({ widths: [150, 150, 200, 300] }); + }); + + test('shrinking a group only reduces the last column in the group', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderGroupedTable({ onColumnWidthsChange }); + const groupCell = wrapper.find('thead th[scope="colgroup"]')!; + const resizerBtn = new ElementWrapper(groupCell.find('button')!.getElement()); + + firePointerdown(resizerBtn); + firePointermove(350); + firePointerup(350); + + // Group shrunk from 400 to 350 → delta -50 applied to last column 'az' (200→150) + expect(onColumnWidthsChange.mock.calls[0][0].detail).toEqual({ widths: [150, 150, 200, 150] }); + }); + + test('resizing a split group applies delta to the last column of the split half', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderGroupedTable({ onColumnWidthsChange, stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + // With stickyColumns.first=3: id(0), name(1), type(2) are sticky. + // config group (type, az) straddles boundary → split into left (type) and right (az). + const leftSplitCell = groupCells[0]; + const leftResizerBtn = new ElementWrapper(leftSplitCell.find('button')!.getElement()); + + firePointerdown(leftResizerBtn); + firePointermove(250); + firePointerup(250); + + expect(onColumnWidthsChange).toHaveBeenCalledTimes(1); + + // Also resize the right half of the split group + onColumnWidthsChange.mockClear(); + const rightSplitCell = groupCells[1]; + const rightResizerBtn = rightSplitCell.find('button'); + if (rightResizerBtn) { + firePointerdown(new ElementWrapper(rightResizerBtn.getElement())); + firePointermove(300); + firePointerup(300); + expect(onColumnWidthsChange).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/table/__tests__/hooks-integration.test.tsx b/src/table/__tests__/hooks-integration.test.tsx index 0c53fad866..adadb08839 100644 --- a/src/table/__tests__/hooks-integration.test.tsx +++ b/src/table/__tests__/hooks-integration.test.tsx @@ -82,10 +82,10 @@ test('should apply filtering and display no-match state', () => { test('should navigate through pagination', () => { const { tableWrapper, paginationWrapper } = renderDemo(); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('1'); - expect(paginationWrapper.findPreviousPageButton().getElement()).toBeDisabled(); + expect(paginationWrapper.findPreviousPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); paginationWrapper.findNextPageButton().click(); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('11'); - expect(paginationWrapper.findPreviousPageButton().getElement()).toBeEnabled(); + expect(paginationWrapper.findPreviousPageButton().getElement()).not.toHaveAttribute('aria-disabled'); paginationWrapper.findPageNumberByIndex(4)!.click(); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('31'); }); @@ -95,7 +95,7 @@ test('pagination should work when filtering is applied', () => { expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('1'); expect(paginationWrapper.findPageNumbers()).toHaveLength(2); paginationWrapper.findNextPageButton().click(); - expect(paginationWrapper.findNextPageButton().getElement()).toBeDisabled(); + expect(paginationWrapper.findNextPageButton().getElement()).toHaveAttribute('aria-disabled', 'true'); expect(tableWrapper.findBodyCell(1, 1)!.getElement().textContent).toEqual('19'); }); diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx new file mode 100644 index 0000000000..331fb55115 --- /dev/null +++ b/src/table/__tests__/skeleton.test.tsx @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; +} + +const defaultColumns: TableProps.ColumnDefinition[] = [ + { header: 'id', cell: item => item.id }, + { header: 'name', cell: item => item.name }, +]; + +const defaultItems: Item[] = [ + { id: 1, name: 'Apples' }, + { id: 2, name: 'Oranges' }, + { id: 3, name: 'Bananas' }, +]; + +function renderTable(props?: Partial) { + const { container } = render(
); + const wrapper = createWrapper(container); + return wrapper; +} + +describe('Table skeleton loading', () => { + describe('initial load (no items)', () => { + test('renders skeleton rows with skeleton components when loading', () => { + const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 5 } }); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(5); + expect(wrapper.findAllSkeletons()).toHaveLength(10); // 2 columns × 5 rows + }); + + test('does not render skeleton rows when skeleton prop is not provided', () => { + const wrapper = renderTable({ items: [], loading: true }); + const rows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(rows).toHaveLength(0); + }); + + test('does not render skeleton rows when not loading', () => { + const wrapper = renderTable({ items: [], loading: false, skeleton: { totalRows: 5 } }); + const rows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(rows).toHaveLength(0); + }); + + test('renders a screen-reader-only loading announcement', () => { + const wrapper = renderTable({ + items: [], + loading: true, + loadingText: 'Loading resources', + skeleton: { totalRows: 3 }, + }); + expect(wrapper.getElement().textContent).toContain('Loading resources'); + }); + }); + + describe('progressive loading (partial items)', () => { + test('renders data rows and skeleton rows for remaining items', () => { + const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 6 } }); + const table = wrapper.findTable()!; + const allRows = table.findRows(); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + // 3 data + 3 skeleton = 6 rows with .row class + expect(allRows).toHaveLength(6); + expect(skeletonRows).toHaveLength(3); + expect(wrapper.findAllSkeletons()).toHaveLength(6); // 2 columns × 3 rows + expect(table.findBodyCell(1, 2)!.getElement().textContent).toBe('Apples'); + }); + + test('does not render skeleton rows when items fill totalRows', () => { + const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 3 } }); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(0); + }); + + test('does not render skeleton rows when loading is false', () => { + const wrapper = renderTable({ items: defaultItems, loading: false, skeleton: { totalRows: 6 } }); + const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(skeletonRows).toHaveLength(0); + }); + + test('renders a screen-reader-only loading announcement', () => { + const wrapper = renderTable({ + items: defaultItems, + loading: true, + loadingText: 'Loading more', + skeleton: { totalRows: 6 }, + }); + expect(wrapper.getElement().textContent).toContain('Loading more'); + }); + }); +}); diff --git a/src/table/column-groups/__tests__/fixtures.ts b/src/table/column-groups/__tests__/fixtures.ts new file mode 100644 index 0000000000..ef69a9d552 --- /dev/null +++ b/src/table/column-groups/__tests__/fixtures.ts @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; + +export const COLUMN_DEFS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + { id: 'networkIn', header: 'Network In', cell: () => 'networkIn' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cost', header: 'Cost', cell: () => 'cost' }, +]; + +export const ALL_IDS = COLUMN_DEFS.map(c => c.id!); + +export const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + { id: 'pricing', header: 'Pricing' }, +]; + +export const FLAT_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'networkIn', visible: true }, + ], + }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { type: 'group', id: 'pricing', visible: true, children: [{ id: 'cost', visible: true }] }, +]; + +export const NESTED_GROUPS: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, +]; + +export const NESTED_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + ], + }, +]; diff --git a/src/table/column-groups/__tests__/split-utils.test.ts b/src/table/column-groups/__tests__/split-utils.test.ts new file mode 100644 index 0000000000..dbeba6f99c --- /dev/null +++ b/src/table/column-groups/__tests__/split-utils.test.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; +import { getGroupColumnIds, getGroupSplit } from '../split-utils'; +import { calculateHierarchyTree } from '../utils'; + +const COLUMN_DEFS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, +]; + +const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'perf', header: 'Performance' }, +]; + +const DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { + type: 'group', + id: 'perf', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, +]; + +const ALL_IDS = COLUMN_DEFS.map(c => c.id!); + +function buildStructure() { + return calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, DISPLAY); +} + +describe('getGroupColumnIds', () => { + test('returns column IDs for a group', () => { + const structure = buildStructure(); + expect(getGroupColumnIds(structure, 'config')).toEqual(['type', 'az']); + expect(getGroupColumnIds(structure, 'perf')).toEqual(['cpu', 'memory']); + }); + + test('returns empty array for unknown group', () => { + const structure = buildStructure(); + expect(getGroupColumnIds(structure, 'nonexistent')).toEqual([]); + }); +}); + +describe('getGroupSplit', () => { + test('no split when group is fully within sticky-first boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 4, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); + + test('no split when group is fully outside sticky boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 1, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); + + test('detects split by sticky-first boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 3, side: 'first', totalColumns: 6 }); + expect(split).toEqual({ stickyColspan: 1, staticColspan: 1 }); + }); + + test('detects split by sticky-last boundary', () => { + const structure = buildStructure(); + const perfGroup = structure.rows[0].columns.find(c => c.id === 'perf')!; + const split = getGroupSplit({ col: perfGroup, stickyCount: 1, side: 'last', totalColumns: 6 }); + expect(split).toEqual({ stickyColspan: 1, staticColspan: 1 }); + }); + + test('non-group cells return no split', () => { + const structure = buildStructure(); + const col = structure.rows[1].columns[0]; + const split = getGroupSplit({ col: col, stickyCount: 3, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + }); + + test('no split when stickyCount is 0', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 0, side: 'first', totalColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); +}); diff --git a/src/table/column-groups/__tests__/use-column-groups.test.tsx b/src/table/column-groups/__tests__/use-column-groups.test.tsx new file mode 100644 index 0000000000..c0886f5a1c --- /dev/null +++ b/src/table/column-groups/__tests__/use-column-groups.test.tsx @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../../__tests__/render-hook'; +import { TableProps } from '../../interfaces'; +import { useColumnGroups } from '../use-column-groups'; +import { ALL_IDS, COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './fixtures'; + +const warnOnceMock = jest.fn(); +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + warnOnce: (...args: unknown[]) => warnOnceMock(...args), +})); + +afterEach(() => warnOnceMock.mockReset()); + +describe('useColumnGroups', () => { + describe('no grouping', () => { + test('returns a single flat row when no groups are defined', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS)); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + }); + + test('treats empty groups array the same as no groups', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS, [])); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + }); + }); + + describe('grouped columns', () => { + test('creates two rows for flat grouping', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY)); + expect(result.current.maxDepth).toBe(2); + expect(result.current.rows).toHaveLength(2); + }); + + test('creates three rows for nested grouping', () => { + const cols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + const { result } = renderHook(() => useColumnGroups(cols, ['cpu', 'memory'], NESTED_GROUPS, NESTED_DISPLAY)); + expect(result.current.maxDepth).toBe(3); + expect(result.current.rows).toHaveLength(3); + expect(result.current.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + }); + + describe('visibleColumnIds filtering', () => { + test('excludes hidden columns via visibleColumnIds', () => { + const visibleIds = ['id', 'cpu']; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, visibleIds, GROUP_DEFS, display)); + const allIds = result.current.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(allIds).not.toContain('type'); + }); + + test('hides a group entirely when all its children are outside visibleColumnIds', () => { + const visibleIds = ['id', 'name']; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, visibleIds, GROUP_DEFS, display)); + const groupIds = result.current.rows.flatMap(r => r.columns.filter(c => c.isGroup).map(c => c.id)); + expect(groupIds).not.toContain('performance'); + }); + }); + + describe('edge cases', () => { + test('handles columns without IDs gracefully', () => { + const cols: TableProps.ColumnDefinition[] = [{ header: 'No ID', cell: () => 'x' } as any]; + const { result } = renderHook(() => useColumnGroups(cols, ['0'], [])); + expect(result.current.rows).toBeDefined(); + }); + + test('warns in dev when a group referenced in columnDisplay is not in groupDefinitions', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'ghost-group', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + renderHook(() => useColumnGroups(COLUMN_DEFS, ALL_IDS, [], display)); + + expect(warnOnceMock).toHaveBeenCalledWith('[Table]', expect.stringContaining('ghost-group')); + }); + }); +}); diff --git a/src/table/column-groups/__tests__/utils.test.ts b/src/table/column-groups/__tests__/utils.test.ts new file mode 100644 index 0000000000..b65be1d3ca --- /dev/null +++ b/src/table/column-groups/__tests__/utils.test.ts @@ -0,0 +1,252 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; +import { calculateHierarchyTree, TableHeaderNode } from '../utils'; +import { ALL_IDS, COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './fixtures'; + +describe('TableHeaderNode', () => { + test('creates node with default properties', () => { + const node = new TableHeaderNode('test-id'); + expect(node.id).toBe('test-id'); + expect(node.colSpan).toBe(1); + expect(node.rowSpan).toBe(1); + expect(node.children).toEqual([]); + expect(node.rowIndex).toBe(-1); + expect(node.colIndex).toBe(-1); + expect(node.isRoot).toBe(false); + expect(node.isColumn).toBe(true); + expect(node.isGroup).toBe(false); + }); + + test('accepts constructor props and identifies node types', () => { + const colDef: TableProps.ColumnDefinition = { id: 'col', header: 'Col', cell: () => 'col' }; + const groupDef: TableProps.GroupDefinition = { id: 'grp', header: 'Grp' }; + + const colNode = new TableHeaderNode('col', { + columnDefinition: colDef, + colSpan: 2, + rowSpan: 3, + rowIndex: 1, + colIndex: 2, + }); + const groupNode = new TableHeaderNode('grp', { groupDefinition: groupDef }); + const rootNode = new TableHeaderNode('root', { isRoot: true }); + + expect(colNode.colSpan).toBe(2); + expect(colNode.rowSpan).toBe(3); + expect(colNode.columnDefinition).toBe(colDef); + expect(colNode.isGroup).toBe(false); + expect(groupNode.isGroup).toBe(true); + expect(rootNode.isRoot).toBe(true); + expect(rootNode.isColumn).toBe(false); + }); + + test('manages parent/child relationships', () => { + const parent = new TableHeaderNode('parent'); + const child1 = new TableHeaderNode('child1'); + const child2 = new TableHeaderNode('child2'); + + parent.addChild(child1); + parent.addChild(child2); + + expect(parent.children).toHaveLength(2); + expect(child1.parent).toBe(parent); + expect(child2.parent).toBe(parent); + expect(parent.isColumn).toBe(false); + expect(child1.isColumn).toBe(true); + }); +}); + +describe('calculateHierarchyTree', () => { + describe('no grouping', () => { + test('returns a single row with all visible columns', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, []); + + expect(result.maxDepth).toBe(1); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + result.rows[0].columns.forEach((col, i) => { + expect(col.rowSpan).toBe(1); + expect(col.colSpan).toBe(1); + expect(col.isGroup).toBe(false); + expect(col.colIndex).toBe(i); + }); + expect(result.columnToParentIds.size).toBe(0); + }); + }); + + describe('flat grouping', () => { + test('creates two rows with correct structure', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.maxDepth).toBe(2); + expect(result.rows).toHaveLength(2); + + // Row 0: ungrouped columns (rowSpan=2) + group headers + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['id', 'name', 'performance', 'config', 'pricing']); + expect(row0.find(c => c.id === 'id')).toMatchObject({ rowSpan: 2 }); + expect(row0.find(c => c.id === 'name')).toMatchObject({ rowSpan: 2 }); + expect(row0.find(c => c.id === 'performance')).toMatchObject({ isGroup: true, colSpan: 3, rowSpan: 1 }); + expect(row0.find(c => c.id === 'config')).toMatchObject({ isGroup: true, colSpan: 2 }); + expect(row0.find(c => c.id === 'pricing')).toMatchObject({ isGroup: true, colSpan: 1 }); + + // Row 1: columns under groups + const row1 = result.rows[1].columns; + expect(row1.map(c => c.id)).toEqual(['cpu', 'memory', 'networkIn', 'type', 'az', 'cost']); + expect(row1.every(c => !c.isGroup && c.rowSpan === 1 && c.colSpan === 1)).toBe(true); + }); + + test('tracks parent IDs and colIndex correctly', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.columnToParentIds.get('cpu')).toEqual(['performance']); + expect(result.columnToParentIds.get('type')).toEqual(['config']); + expect(result.columnToParentIds.has('id')).toBe(false); + + const row0 = result.rows[0].columns; + expect(row0.find(c => c.id === 'performance')?.colIndex).toBe(2); + expect(row0.find(c => c.id === 'config')?.colIndex).toBe(5); + }); + }); + + describe('nested grouping', () => { + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + + test('creates three rows for nested groups', () => { + const result = calculateHierarchyTree(nestedCols, ['cpu', 'memory'], NESTED_GROUPS, NESTED_DISPLAY); + + expect(result.maxDepth).toBe(3); + expect(result.rows).toHaveLength(3); + expect(result.rows[0].columns[0]).toMatchObject({ id: 'metrics', colSpan: 2 }); + expect(result.rows[1].columns[0]).toMatchObject({ id: 'performance', colSpan: 2 }); + expect(result.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'memory']); + expect(result.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + + test('handles 3-level nesting', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'l1', header: 'L1' }, + { id: 'l2', header: 'L2' }, + { id: 'l3', header: 'L3' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'l1', + visible: true, + children: [ + { + type: 'group', + id: 'l2', + visible: true, + children: [{ type: 'group', id: 'l3', visible: true, children: [{ id: 'cpu', visible: true }] }], + }, + ], + }, + ]; + const result = calculateHierarchyTree(nestedCols, ['cpu'], groups, display); + expect(result.maxDepth).toBe(4); + expect(result.columnToParentIds.get('cpu')).toEqual(['l1', 'l2', 'l3']); + }); + + test('handles mixed nested and flat groups', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [{ type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }], + }, + { type: 'group', id: 'config', visible: true, children: [{ id: 'memory', visible: true }] }, + ]; + const result = calculateHierarchyTree(nestedCols, ['cpu', 'memory'], groups, display); + + expect(result.maxDepth).toBe(3); + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['metrics', 'config']); + expect(row0.find(c => c.id === 'config')).toMatchObject({ rowSpan: 2 }); + expect(result.rows[1].columns.map(c => c.id)).toEqual(['performance']); + }); + }); + + describe('visibility filtering', () => { + test('includes only visible columns and adjusts group colSpan', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'g', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: false }, + ], + }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['id', 'cpu'], groups, display); + + const allIds = result.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(result.rows[0].columns.find(c => c.id === 'g')?.colSpan).toBe(1); + }); + + test('omits a group entirely when all its children are hidden', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['id'], groups, display); + expect(result.rows[0].columns.map(c => c.id)).not.toContain('g'); + }); + }); + + describe('edge cases', () => { + test('returns empty structure for empty column list', () => { + const result = calculateHierarchyTree([], [], []); + expect(result.rows).toHaveLength(0); + expect(result.maxDepth).toBe(0); + }); + + test('skips columns without id', () => { + const cols: TableProps.ColumnDefinition[] = [ + { header: 'No ID', cell: () => 'x' } as any, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + ]; + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const result = calculateHierarchyTree(cols, ['cpu'], groups, display); + expect(result.rows[1].columns[0].id).toBe('cpu'); + }); + + test('skips subtree when group id is not in groupDefinitions', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'nonexistent', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['cpu'], [], display); + expect(result.rows).toHaveLength(0); + }); + + test('treats a group with no visible children as absent', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, [], groups, display); + expect(result.rows).toHaveLength(0); + }); + }); +}); diff --git a/src/table/column-groups/col-group.tsx b/src/table/column-groups/col-group.tsx new file mode 100644 index 0000000000..002e8cbffc --- /dev/null +++ b/src/table/column-groups/col-group.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { TableProps } from '../interfaces'; +import { useColumnWidths } from '../use-column-widths'; +import { getColumnKey } from '../utils'; + +/* + * Renders a with elements for each column. + * With table-layout:fixed, widths control actual column widths, + * which makes colspan headers automatically span the correct width. + * Must be rendered inside ColumnWidthsProvider. + */ +export function TableColGroup({ + visibleColumnDefinitions, + hasSelection, + sticky = false, + selectionColumnId, +}: { + visibleColumnDefinitions: ReadonlyArray>; + hasSelection: boolean; + sticky?: boolean; + selectionColumnId?: PropertyKey; +}) { + const { getColumnStyles, setCol } = useColumnWidths(); + return ( + + {hasSelection && ( + + )} + {visibleColumnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + if (sticky) { + return ; + } + return setCol(columnId, node)} />; + })} + + ); +} diff --git a/src/table/column-groups/split-utils.ts b/src/table/column-groups/split-utils.ts new file mode 100644 index 0000000000..61e53cc88b --- /dev/null +++ b/src/table/column-groups/split-utils.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColumnGroupsLayout, HeaderRowColumn } from './utils'; + +/** + * Describes how a group header is split by a single sticky column boundary. + * `stickyColspan` is the number of columns on the sticky side. + * `staticColspan` is the number of columns on the scrollable side. + * When both are 0, the group is not affected by this boundary. + */ +export interface StickyGroupSplit { + stickyColspan: number; + staticColspan: number; +} + +/** Returns all column IDs that are descendants of the given group (including nested subgroups). */ +export function getGroupColumnIds(columnGroupsLayout: ColumnGroupsLayout, groupId: string): string[] { + const columnsRow = columnGroupsLayout.rows[columnGroupsLayout.rows.length - 1]; + const childIds: string[] = []; + for (const col of columnsRow.columns) { + if (!col.isGroup && col.parentGroupIds.includes(groupId)) { + childIds.push(col.id); + } + } + return childIds; +} + +/** + * Computes how a group header cell is split by a sticky boundary. + * Call once for sticky-first and once for sticky-last. + * + * @param stickyCount - number of sticky columns from that side (first or last) + * @param side - which boundary to check + */ +export function getGroupSplit({ + col, + stickyCount, + side, + totalColumns, +}: { + col: HeaderRowColumn; + stickyCount: number; + side: 'first' | 'last'; + totalColumns: number; +}): StickyGroupSplit { + if (!col.isGroup || stickyCount === 0) { + return { stickyColspan: 0, staticColspan: 0 }; + } + + const groupStart = col.colIndex; + const groupEnd = col.colIndex + col.colSpan - 1; + + if (side === 'first') { + const lastStickyFirst = stickyCount - 1; + if (groupStart <= lastStickyFirst && groupEnd > lastStickyFirst) { + const stickyColspan = lastStickyFirst - groupStart + 1; + return { stickyColspan, staticColspan: col.colSpan - stickyColspan }; + } + } else { + const firstStickyLast = totalColumns - stickyCount; + if (groupStart < firstStickyLast && groupEnd >= firstStickyLast) { + const staticColspan = firstStickyLast - groupStart; + return { stickyColspan: col.colSpan - staticColspan, staticColspan }; + } + } + + return { stickyColspan: 0, staticColspan: 0 }; +} diff --git a/src/table/column-groups/use-column-groups.tsx b/src/table/column-groups/use-column-groups.tsx new file mode 100644 index 0000000000..1b4a84e32f --- /dev/null +++ b/src/table/column-groups/use-column-groups.tsx @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TableProps } from '../interfaces'; +import { calculateHierarchyTree } from './utils'; + +export function useColumnGroups( + columnDefinitions: ReadonlyArray>, + visibleColumns: string[], + groupDefinitions?: ReadonlyArray, + columnDisplay?: ReadonlyArray +) { + const layout = calculateHierarchyTree(columnDefinitions, visibleColumns, groupDefinitions ?? [], columnDisplay); + + let groupColumnMap: Map | undefined; + if (layout.rows.length > 1) { + groupColumnMap = new Map(); + const columnsRow = layout.rows[layout.rows.length - 1]; + for (const row of layout.rows) { + for (const col of row.columns) { + if (col.isGroup) { + const childColumnIds = columnsRow.columns + .filter(l => !l.isGroup && l.parentGroupIds.includes(col.id)) + .map(l => l.id); + groupColumnMap.set(col.id, childColumnIds); + } + } + } + } + + return { ...layout, groupColumnMap }; +} diff --git a/src/table/column-groups/utils.ts b/src/table/column-groups/utils.ts new file mode 100644 index 0000000000..e0258fa3d4 --- /dev/null +++ b/src/table/column-groups/utils.ts @@ -0,0 +1,294 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { TableProps } from '../interfaces'; +import { getVisibleColumnDefinitions } from '../utils'; + +export interface HeaderRowColumn { + id: string; + header?: React.ReactNode; + colSpan: number; + rowSpan: number; + isGroup: boolean; + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + parentGroupIds: string[]; + colIndex: number; +} + +export interface HeaderRow { + columns: HeaderRowColumn[]; +} + +export interface ColumnGroupsLayout { + rows: HeaderRow[]; + maxDepth: number; + columnToParentIds: Map; +} + +export interface TableHeaderNodeProps { + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + isRoot?: boolean; + colSpan?: number; + rowSpan?: number; + rowIndex?: number; + colIndex?: number; +} + +/** + * A node in the table header tree. + * - Column nodes map to column definitions. + * - Internal nodes map to group definitions. + * - The root is a virtual container (never rendered). + */ +export class TableHeaderNode { + public readonly id: string; + public readonly isRoot: boolean; + public readonly columnDefinition?: TableProps.ColumnDefinition; + public readonly groupDefinition?: TableProps.GroupDefinition; + + public colSpan: number; + public rowSpan: number; + public rowIndex: number; + public colIndex: number; + public subTreeHeight: number = 1; + + public children: TableHeaderNode[] = []; + public parent?: TableHeaderNode; + + constructor(id: string, props: TableHeaderNodeProps = {}) { + this.id = id; + this.isRoot = props.isRoot ?? false; + this.columnDefinition = props.columnDefinition; + this.groupDefinition = props.groupDefinition; + this.colSpan = props.colSpan ?? 1; + this.rowSpan = props.rowSpan ?? 1; + this.rowIndex = props.rowIndex ?? -1; + this.colIndex = props.colIndex ?? -1; + } + + get isGroup(): boolean { + return !!this.groupDefinition; + } + + get isColumn(): boolean { + return !this.isRoot && this.children.length === 0; + } + + addChild(child: TableHeaderNode): void { + this.children.push(child); + child.parent = this; + } +} + +/** + * Builds the tree from the nested columnDisplay structure. + * Groups are only attached if they contain at least one visible descendant. + */ +function buildTreeFromColumnDisplay( + displayItems: ReadonlyArray, + nodeMap: Map>, + parent: TableHeaderNode +): void { + for (const item of displayItems) { + if (item.type === 'group') { + const groupNode = nodeMap.get(item.id); + if (!groupNode) { + warnOnce('[Table]', `Group "${item.id}" referenced in columnDisplay not found in groupDefinitions. Skipping.`); + continue; + } + buildTreeFromColumnDisplay(item.children, nodeMap, groupNode); + // Only attach group if it has visible descendants. The recursive call above + // only adds children that are either visible columns or nested groups with + // their own visible descendants, so this check handles all nesting levels. + if (groupNode.children.length > 0) { + parent.addChild(groupNode); + } + } else { + if (!item.visible) { + continue; + } + const colNode = nodeMap.get(item.id); + if (colNode) { + parent.addChild(colNode); + } + } + } +} + +/** + * Fallback when no columnDisplay is provided: all visible columns attach directly to root. + */ +function buildTreeFromVisibleColumns( + visibleColumns: Readonly[]>, + nodeMap: Map>, + root: TableHeaderNode +): void { + for (const col of visibleColumns) { + // Columns without IDs cannot participate in grouping, they have no key + // to match against columnDisplay entries or groupDefinitions. + if (!col.id) { + continue; + } + const node = nodeMap.get(col.id); + if (node) { + root.addChild(node); + } + } +} + +function computeSubTreeHeights(node: TableHeaderNode): number { + if (node.isColumn || node.children.length === 0) { + node.subTreeHeight = 1; + return 1; + } + const maxChildHeight = Math.max(...node.children.map(child => computeSubTreeHeights(child))); + node.subTreeHeight = maxChildHeight + 1; + return node.subTreeHeight; +} + +function computeRowSpansAndIndices(node: TableHeaderNode, treeHeight: number, ancestorRows: number = 0): void { + const maxChildHeight = Math.max(...node.children.map(c => c.subTreeHeight), 0); + node.rowSpan = treeHeight - ancestorRows - maxChildHeight; + + if (node.parent) { + node.rowIndex = node.parent.rowIndex + node.parent.rowSpan; + } + + for (const child of node.children) { + computeRowSpansAndIndices(child, treeHeight, ancestorRows + node.rowSpan); + } +} + +function computeColSpansAndIndices(node: TableHeaderNode, startCol: number = 0): number { + node.colIndex = startCol; + + if (node.isColumn) { + node.colSpan = 1; + return startCol + 1; + } + + let nextCol = startCol; + for (const child of node.children) { + nextCol = computeColSpansAndIndices(child, nextCol); + } + + node.colSpan = nextCol - startCol; + return nextCol; +} + +export function calculateHierarchyTree( + columnDefinitions: ReadonlyArray>, + visibleColumnIds: readonly string[], + groupDefinitions: ReadonlyArray, + columnDisplay?: ReadonlyArray +): ColumnGroupsLayout { + const visibleColumns = getVisibleColumnDefinitions({ + columnDisplay, + visibleColumns: visibleColumnIds, + columnDefinitions, + }); + + const nodeMap = new Map>(); + + for (const col of visibleColumns) { + if (col.id) { + nodeMap.set(col.id, new TableHeaderNode(col.id, { columnDefinition: col })); + } + } + + for (const group of groupDefinitions) { + nodeMap.set(group.id, new TableHeaderNode(group.id, { groupDefinition: group })); + } + + const root = new TableHeaderNode('*', { isRoot: true }); + + if (columnDisplay && columnDisplay.length > 0) { + buildTreeFromColumnDisplay(columnDisplay, nodeMap, root); + } else { + buildTreeFromVisibleColumns(visibleColumns, nodeMap, root); + } + + computeSubTreeHeights(root); + + const treeHeight = root.subTreeHeight - 1; + root.rowIndex = -1; + root.rowSpan = 1; + root.colSpan = visibleColumns.length; + + for (const child of root.children) { + computeRowSpansAndIndices(child, treeHeight); + } + + computeColSpansAndIndices(root); + + return buildOutput(root, treeHeight); +} + +function getParentChain(node: TableHeaderNode): string[] { + const chain: string[] = []; + let current = node.parent; + while (current && !current.isRoot) { + chain.unshift(current.id); + current = current.parent; + } + return chain; +} + +function buildOutput(root: TableHeaderNode, maxDepth: number): ColumnGroupsLayout { + const rowsMap = new Map[]>(); + const columnToParentIds = new Map(); + + const queue: TableHeaderNode[] = [...root.children]; + + while (queue.length > 0) { + const node = queue.shift()!; + const parentChain = getParentChain(node); + + const entry: HeaderRowColumn = { + id: node.id, + header: node.groupDefinition?.header ?? node.columnDefinition?.header, + colSpan: node.colSpan, + rowSpan: node.rowSpan, + isGroup: node.isGroup, + columnDefinition: node.columnDefinition, + groupDefinition: node.groupDefinition, + parentGroupIds: parentChain, + colIndex: node.colIndex, + }; + + if (!rowsMap.has(node.rowIndex)) { + rowsMap.set(node.rowIndex, []); + } + rowsMap.get(node.rowIndex)!.push(entry); + + if (node.isColumn && node.columnDefinition && parentChain.length > 0) { + columnToParentIds.set(node.id, parentChain); + } + + queue.push(...node.children); + } + + // Sort row indices to ensure rows are ordered top-to-bottom, + // then sort columns within each row by their horizontal position. + const rows: HeaderRow[] = Array.from(rowsMap.keys()) + .sort((a, b) => a - b) + .map(key => ({ columns: rowsMap.get(key)!.sort((a, b) => a.colIndex - b.colIndex) })); + + return { rows, maxDepth, columnToParentIds }; +} + +export function getColumnGroupsDepth(columnDisplay?: ReadonlyArray): number { + if (!columnDisplay) { + return 0; + } + let maxDepth = 0; + for (const item of columnDisplay) { + if (item.type === 'group') { + maxDepth = Math.max(maxDepth, 1 + getColumnGroupsDepth(item.children)); + } + } + return maxDepth; +} diff --git a/src/table/header-cell/common-props.ts b/src/table/header-cell/common-props.ts new file mode 100644 index 0000000000..a5eecee09d --- /dev/null +++ b/src/table/header-cell/common-props.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColumnWidthStyle } from '../column-widths-utils'; +import { TableProps } from '../interfaces'; +import { StickyColumnsModel } from '../sticky-columns'; +import { TableRole } from '../table-role'; + +export interface BaseHeaderCellProps { + tabIndex: number; + colIndex: number; + focusedComponent?: null | string; + resizableColumns?: boolean; + resizableStyle?: ColumnWidthStyle; + onResizeFinish: () => void; + sticky?: boolean; + hidden?: boolean; + stripedRows?: boolean; + stickyState: StickyColumnsModel; + cellRef: React.RefCallback; + tableRole: TableRole; + resizerRoleDescription?: string; + resizerTooltipText?: string; + variant: TableProps.Variant; + tableVariant?: TableProps.Variant; + wrapLines?: boolean; +} diff --git a/src/table/header-cell/group-header-cell.tsx b/src/table/header-cell/group-header-cell.tsx new file mode 100644 index 0000000000..af049ceead --- /dev/null +++ b/src/table/header-cell/group-header-cell.tsx @@ -0,0 +1,145 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef } from 'react'; +import clsx from 'clsx'; + +import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { TableProps } from '../interfaces'; +import { Divider, Resizer } from '../resizer'; +import { useStickyCellStyles } from '../sticky-columns'; +import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from '../use-column-widths'; +import { getStickyClassNames } from '../utils'; +import { BaseHeaderCellProps } from './common-props'; +import { TableThElement } from './th-element'; + +import styles from './styles.css.js'; + +export interface TableGroupHeaderCellProps extends BaseHeaderCellProps { + group: TableProps.GroupDefinition; + colspan: number; + rowspan: number; + groupId: string; + updateGroupWidth: (groupId: PropertyKey, newWidth: number) => void; + childColumnIds: PropertyKey[]; + firstChildColumnId?: PropertyKey; + lastChildColumnId?: PropertyKey; + columnGroupId?: string; + stickyColumnId?: PropertyKey; + stickyBoundaryColumnId?: PropertyKey; + isLast?: boolean; +} + +export function TableGroupHeaderCell({ + group, + colspan, + rowspan, + colIndex, + groupId, + resizableColumns, + resizableStyle, + onResizeFinish, + updateGroupWidth, + childColumnIds, + focusedComponent, + tabIndex, + sticky, + hidden, + stripedRows, + stickyState, + cellRef, + tableRole, + resizerRoleDescription, + resizerTooltipText, + variant, + tableVariant, + columnGroupId, + stickyColumnId, + stickyBoundaryColumnId, + isLast, + wrapLines, +}: TableGroupHeaderCellProps) { + const headerId = useUniqueId('table-group-header-'); + const { columnWidths } = useColumnWidths(); + + // Effective min = sum of non-rightmost children's current widths (fixed) + rightmost child's minWidth + const lastChild = childColumnIds[childColumnIds.length - 1]; + const groupMinWidth = childColumnIds.reduce((sum, id) => { + if (id === lastChild) { + return sum + DEFAULT_COLUMN_WIDTH; + } + return sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH); + }, 0); + const clickableHeaderRef = useRef(null); + const { tabIndex: clickableHeaderTabIndex } = useSingleTabStopNavigation(clickableHeaderRef, { tabIndex }); + + // Subscribe to the boundary column's sticky state to inherit shadow/clip-path classes. + // The offset/position comes from stickyColumnId (first child); this only adds boundary classes. + const boundaryStyles = useStickyCellStyles({ + stickyColumns: stickyState, + columnId: stickyBoundaryColumnId ?? stickyColumnId ?? groupId, + getClassName: props => getStickyClassNames(styles, props), + }); + + // boundaryStyles.className is populated by scroll/intersection observers in the browser. + // In JSDOM these observers don't fire, so this branch is only exercised in integration tests. + const boundaryClassName = stickyBoundaryColumnId && boundaryStyles.className ? boundaryStyles.className : undefined; + + return ( + + ); +} diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index ff8e92ed41..b30b8604cc 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -11,46 +11,32 @@ import { useInternalI18n } from '../../i18n/context'; import InternalIcon from '../../icon/internal'; import { KeyCode } from '../../internal/keycode'; import { GeneratedAnalyticsMetadataTableSort } from '../analytics-metadata/interfaces'; -import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { Divider, Resizer } from '../resizer'; -import { StickyColumnsModel } from '../sticky-columns'; -import { TableRole } from '../table-role'; +import { BaseHeaderCellProps } from './common-props'; import { TableThElement } from './th-element'; import { getSortingIconName, getSortingStatus, isSorted } from './utils'; import analyticsSelectors from '../analytics-metadata/styles.css.js'; import styles from './styles.css.js'; -export interface TableHeaderCellProps { - tabIndex: number; +export interface TableHeaderCellProps extends BaseHeaderCellProps { column: TableProps.ColumnDefinition; activeSortingColumn?: TableProps.SortingColumn; sortingDescending?: boolean; sortingDisabled?: boolean; - wrapLines?: boolean; stuck?: boolean; - sticky?: boolean; - hidden?: boolean; - stripedRows?: boolean; onClick(detail: TableProps.SortingState): void; - onResizeFinish: () => void; - colIndex: number; updateColumn: (columnId: PropertyKey, newWidth: number) => void; - resizableColumns?: boolean; - resizableStyle?: ColumnWidthStyle; isEditable?: boolean; columnId: PropertyKey; - stickyState: StickyColumnsModel; - cellRef: React.RefCallback; - focusedComponent?: null | string; - tableRole: TableRole; - resizerRoleDescription?: string; - resizerTooltipText?: string; isExpandable?: boolean; hasDynamicContent?: boolean; - variant: TableProps.Variant; - tableVariant?: TableProps.Variant; + colSpan?: number; + rowSpan?: number; + columnGroupId?: string; + isLastChildOfGroup?: boolean; + isLast?: boolean; } export function TableHeaderCell({ @@ -81,6 +67,11 @@ export function TableHeaderCell({ isExpandable, hasDynamicContent, variant, + colSpan, + rowSpan, + columnGroupId, + isLastChildOfGroup, + isLast, tableVariant, }: TableHeaderCellProps) { const i18n = useInternalI18n('table'); @@ -139,6 +130,10 @@ export function TableHeaderCell({ tableRole={tableRole} variant={variant} tableVariant={tableVariant} + colSpan={colSpan} + rowSpan={rowSpan} + columnGroupId={columnGroupId} + isLast={isLast} {...(sortingDisabled ? {} : getAnalyticsMetadataAttribute({ @@ -214,9 +209,11 @@ export function TableHeaderCell({ // tooltipText={i18n('ariaLabels.resizerTooltipText', resizerTooltipText)} tooltipText={resizerTooltipText} isBorderless={variant === 'full-page' || variant === 'embedded' || variant === 'borderless'} + isLast={isLast} + dividerPosition={isLastChildOfGroup ? 'top' : undefined} /> ) : ( - + )} ); diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index d004215c5e..efaeb5caba 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -53,7 +53,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; position: relative; text-align: start; box-sizing: border-box; - border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; background: awsui.$color-background-table-header; color: awsui.$color-text-column-header; font-weight: awsui.$font-weight-heading-s; @@ -63,6 +63,13 @@ $cell-horizontal-padding: awsui.$space-scaled-l; @include header-cell-focus-outline(awsui.$space-scaled-xxs); + &.header-cell-group, + &.header-cell-grouped, + &.header-cell-spans-rows { + padding-block: awsui.$space-xxxs; + padding-inline: awsui.$space-scaled-xs; + } + &-sticky { border-block-end: awsui.$border-table-sticky-width solid awsui.$color-border-divider-default; } @@ -80,6 +87,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; background: none; } &:last-child, + &[data-rightmost], &.header-cell-sortable { padding-inline-end: awsui.$space-xs; } @@ -143,6 +151,12 @@ $cell-horizontal-padding: awsui.$space-scaled-l; padding-inline-end: awsui.$space-s; @include cell-offset(awsui.$space-s); + .header-cell-group > &, + .header-cell-grouped > &, + .header-cell-spans-rows > & { + padding-block: awsui.$space-xxxs; + } + .header-cell-sortable > & { padding-inline-end: calc(#{awsui.$space-xl} + #{awsui.$space-xxs}); } @@ -160,6 +174,26 @@ $cell-horizontal-padding: awsui.$space-scaled-l; } } +.header-cell-spans-rows { + block-size: 100%; + vertical-align: bottom; + + > .header-cell-content { + block-size: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-end; + + // stylelint-disable-next-line no-descending-specificity + > .sorting-icon { + inset-block-start: auto; + inset-block-end: awsui.$space-scaled-xxs; + transform: none; + } + } +} + .header-cell-sortable:not(.header-cell-disabled) { & > .header-cell-content { cursor: pointer; @@ -206,12 +240,14 @@ settings icon in the pagination slot. &:first-child { @include header-cell-focus-outline-first(awsui.$space-scaled-xxs); } - &:first-child > .header-cell-content { + &:first-child:not(.header-cell-grouped):not(.header-cell-group) > .header-cell-content { @include cell-offset(0px); @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); } - &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start) { + &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + .header-cell-grouped + ) { @include cell-offset(awsui.$space-xxxs); } @@ -220,11 +256,12 @@ settings icon in the pagination slot. shaded background makes the child content appear too close to the table edge. */ - &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start) { + &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not(.header-cell-grouped) { @include cell-offset(awsui.$space-xxs); } - &:last-child.header-cell-sortable:not(.header-cell-resizable) { + &:last-child.header-cell-sortable:not(.header-cell-resizable), + &[data-rightmost].header-cell-sortable:not(.header-cell-resizable) { padding-inline-end: awsui.$space-xxxs; } diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 55e5739e02..36434de61b 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -38,6 +38,15 @@ export interface TableThElementProps { variant: TableProps.Variant; tableVariant?: TableProps.Variant; ariaLabel?: string; + colSpan?: number; + rowSpan?: number; + scope?: 'col' | 'colgroup'; + columnGroupId?: string; + isLast?: boolean; + /** Additional className to merge (e.g. boundary shadow classes from a secondary sticky subscription). */ + className?: string; + /** Additional ref for boundary sticky subscription (imperatively updates shadow classes). */ + boundaryRef?: React.RefCallback; } export function TableThElement({ @@ -60,6 +69,13 @@ export function TableThElement({ variant, ariaLabel, tableVariant, + colSpan, + rowSpan, + scope, + columnGroupId, + isLast, + className, + boundaryRef, ...props }: TableThElementProps) { const isVisualRefresh = useVisualRefresh(); @@ -71,7 +87,7 @@ export function TableThElement({ }); const cellRefObject = useRef(null); - const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject); + const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject, boundaryRef); const { tabIndex: cellTabIndex } = useSingleTabStopNavigation(cellRefObject); return ( @@ -87,6 +103,7 @@ export function TableThElement({ isVisualRefresh && styles['is-visual-refresh'], isSelection && clsx(tableStyles['selection-control'], tableStyles['selection-control-header']), tableVariant && styles[`table-variant-${tableVariant}`], + scope === 'colgroup' && styles['header-cell-group'], { [styles['header-cell-fake-focus']]: focusedComponent === `header-${String(columnId)}`, [styles['header-cell-sortable']]: sortingStatus, @@ -95,15 +112,24 @@ export function TableThElement({ [styles['header-cell-ascending']]: sortingStatus === 'ascending', [styles['header-cell-descending']]: sortingStatus === 'descending', [styles['header-cell-hidden']]: hidden, + [styles['header-cell-spans-rows']]: (rowSpan ?? 1) > 1, + [styles['header-cell-grouped']]: !!columnGroupId, }, - stickyStyles.className + stickyStyles.className, + className )} + colSpan={colSpan} + rowSpan={rowSpan} style={{ ...resizableStyle, ...stickyStyles.style }} ref={mergedRef} {...getTableColHeaderRoleProps({ tableRole, sortingStatus, colIndex })} + scope={scope ?? 'col'} tabIndex={cellTabIndex === -1 ? undefined : cellTabIndex} {...copyAnalyticsMetadataAttribute(props)} {...(ariaLabel ? { 'aria-label': ariaLabel } : {})} + {...(isLast ? { 'data-rightmost': true } : {})} + {...(scope !== 'colgroup' ? { 'data-column-index': colIndex + 1 } : {})} + {...(columnGroupId ? { 'data-column-group-id': columnGroupId } : {})} > {children} diff --git a/src/table/index.tsx b/src/table/index.tsx index 5bb3cd03a6..418e8ec73b 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -11,6 +11,7 @@ import { CollectionPreferencesMetadata } from '../internal/context/collection-pr import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; +import { getColumnGroupsDepth } from './column-groups/utils'; import { getSortingColumnId } from './header-cell/utils'; import { TableForwardRefType, TableProps } from './interfaces'; import InternalTable, { InternalTableAsSubstep } from './internal'; @@ -32,7 +33,7 @@ const Table = React.forwardRef( const analyticsMetadata = getAnalyticsMetadataProps(props as BasePropsWithAnalyticsMetadata); const hasHiddenColumns = (props.visibleColumns && props.visibleColumns.length < props.columnDefinitions.length) || - props.columnDisplay?.some(col => !col.visible); + props.columnDisplay?.some(col => col.type !== 'group' && !col.visible); const hasStickyColumns = !!props.stickyColumns?.first || !!props.stickyColumns?.last; const baseComponentProps = useBaseComponent( 'Table', @@ -54,6 +55,8 @@ const Table = React.forwardRef( expandableRows: !!props.expandableRows, progressiveLoading: !!props.getLoadingStatus, groupSelection: !!props.expandableRows?.groupSelection, + columnGroups: !!props.groupDefinitions?.length, + columnGroupsDepth: getColumnGroupsDepth(props.columnDisplay), cellCounters: props.columnDefinitions.filter(dev => !!dev.counter).length, loaderCounters: !!props.renderLoaderCounter, inlineEdit: props.columnDefinitions.some(def => !!def.editConfig), diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 1628cf5492..74ea81b4bc 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -58,9 +58,18 @@ export interface TableProps extends BaseComponentProps { /** * Specifies the text that's displayed when the table is in a loading state. + * In skeleton-loading mode this will be used as a label for screenreaders. */ loadingText?: string; + /** + * Renders skeleton placeholder rows to fill the table while data is loading. Accepts: + * - `totalRows` (number) - The total number of rows that should be rendered. If `items` + * are also provided, those items will be rendered first, and `totalRows - items.length` + * additional skeleton rows rendered after. + */ + skeleton?: TableProps.SkeletonConfig; + /** * Specifies a property that uniquely identifies an individual item. * When it's set, it's used to provide [keys for React](https://reactjs.org/docs/lists-and-keys.html#keys) @@ -252,9 +261,33 @@ export interface TableProps extends BaseComponentProps { * If not set, all columns are displayed and the order is dictated by the `columnDefinitions` property. * * Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component. + * + * Each entry is one of the following: + * - `ColumnDisplay` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. Must match a column `id` from `columnDefinitions`. + * - `visible` (boolean) - Whether the column is visible. + * - `GroupDisplay` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. Must match a group `id` from `groupDefinitions`. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. */ columnDisplay?: ReadonlyArray; + /** + * Defines the column groups. Each group has an `id` and `header` used to label the group header cell. + * + * When using grouped columns, you must also provide the `columnDisplay` property with `{ type: 'group', id, children }` entries + * to assign columns to their respective groups and define the display hierarchy. + * + * Each group definition contains the following: + * - `id` (string) - A unique identifier for the group. + * - `header` (ReactNode) - The content displayed in the group header cell. + * - `ariaLabel` ((LabelData) => string) - (Optional) A function that provides an `aria-label` for the group header. + */ + groupDefinitions?: ReadonlyArray>; + /** * Specifies an array containing the `id`s of visible columns. If not set, all columns are displayed. * @@ -507,6 +540,13 @@ export namespace TableProps { cell(item: T): React.ReactNode; } & SortingColumn; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface GroupDefinition { + id: string; + header: React.ReactNode; + ariaLabel?: (data: LabelData) => string; + } + export interface ItemCounterData { item: T; itemsCount?: number; @@ -602,11 +642,21 @@ export namespace TableProps { newValue: ValueType ) => Promise | void; - export interface ColumnDisplayProperties { + export interface ColumnDisplay { + type?: 'column'; + id: string; + visible: boolean; + } + + export interface GroupDisplay { + type: 'group'; id: string; visible: boolean; + children: ReadonlyArray; } + export type ColumnDisplayProperties = ColumnDisplay | GroupDisplay; + export interface ExpandableRows { getItemChildren: (item: T) => readonly T[]; isItemExpandable: (item: T) => boolean; @@ -655,6 +705,10 @@ export namespace TableProps { export interface RenderLoaderEmptyDetail { item: T; } + + export interface SkeletonConfig { + totalRows: number; + } } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf2e4db610..800214b57e 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -37,6 +37,8 @@ import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; +import { TableColGroup } from './column-groups/col-group'; +import { useColumnGroups } from './column-groups/use-column-groups'; import { checkColumnWidths } from './column-widths-utils'; import { useExpandableTableProps } from './expandable-rows/expandable-rows-utils'; import { TableForwardRefType, TableProps, TableRow } from './interfaces'; @@ -48,6 +50,7 @@ import { ResizeTracker } from './resizer'; import { focusMarkers, useSelection, useSelectionFocusMove } from './selection'; import { TableBodySelectionCell } from './selection/selection-cell'; import { useGroupSelection } from './selection/use-group-selection'; +import { SkeletonRows } from './skeleton-rows'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; @@ -107,9 +110,11 @@ const InternalTable = React.forwardRef( preferences, items, columnDefinitions, + groupDefinitions, trackBy, loading, loadingText, + skeleton, selectionType: externalSelectionType, selectedItems, isItemDisabled, @@ -300,6 +305,15 @@ const InternalTable = React.forwardRef( visibleColumns, }); + const visibleColumnIds = visibleColumnDefinitions.map((col, idx) => getColumnKey(col, idx).toString()); + + const { groupColumnMap, ...columnGroupsLayout } = useColumnGroups( + columnDefinitions, + visibleColumnIds, + groupDefinitions, + columnDisplay + ); + const selectionProps = { items: allItems, rootItems: items, @@ -394,6 +408,8 @@ const InternalTable = React.forwardRef( selectionType, getSelectAllProps: selection.getSelectAllProps, columnDefinitions: visibleColumnDefinitions, + groupDefinitions, + columnGroupsLayout, variant: computedVariant, tableVariant: computedVariant, wrapLines, @@ -418,6 +434,8 @@ const InternalTable = React.forwardRef( resizerTooltipText: ariaLabels?.resizerTooltipText, stripedRows, stickyState, + stickyColumnsFirst: stickyColumns?.first ?? 0, + stickyColumnsLast: stickyColumns?.last ?? 0, selectionColumnId, tableRole, isExpandable, @@ -452,6 +470,7 @@ const InternalTable = React.forwardRef( const colIndexOffset = selectionType ? 1 : 0; const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; + const headerRowCount = columnGroupsLayout?.rows.length || 1; return ( @@ -460,6 +479,7 @@ const InternalTable = React.forwardRef( visibleColumns={visibleColumnWidthsWithSelection} resizableColumns={resizableColumns} containerRef={wrapperMeasureRefObject} + groupColumnMap={groupColumnMap} > 1} + columnDefinitions={visibleColumnDefinitions} + hasSelection={hasSelection} /> )} @@ -560,16 +583,21 @@ const InternalTable = React.forwardRef( className={clsx( styles.table, resizableColumns && styles['table-layout-fixed'], + columnGroupsLayout && columnGroupsLayout.rows.length > 1 && styles['has-grouped-header'], contentDensity === 'compact' && getVisualContextClassname('compact-table') )} {...getTableRoleProps({ tableRole, totalItemsCount, totalColumnsCount: totalColumnsCount, + headerRowCount, ariaLabel: ariaLabels?.tableLabel, ariaLabelledby, })} > + {resizableColumns && columnGroupsLayout && columnGroupsLayout.rows.length > 1 && ( + + )} - {loading || allItems.length === 0 ? ( + {skeleton && allItems.length === 0 && loading ? ( + + ) : !skeleton && (loading || allItems.length === 0) ? ( { const isFirstRow = rowIndex === 0; - const isLastRow = rowIndex === allRows.length - 1; + const hasSkeletonBelow = + loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0; + const isLastDataRow = rowIndex === allRows.length - 1; + const isLastRow = isLastDataRow && !hasSkeletonBelow; const rowExpandableProps = row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined; const rowRoleProps = getTableRowRoleProps({ tableRole, firstIndex, rowIndex, + headerRowCount, level: row.type === 'loader' ? row.level : undefined, ...rowExpandableProps, }); @@ -608,7 +658,7 @@ const InternalTable = React.forwardRef( isLastRow, isSelected: hasSelection && isRowSelected(row), isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]), - isNextSelected: hasSelection && !isLastRow && isRowSelected(allRows[rowIndex + 1]), + isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]), isEvenRow: rowIndex % 2 === 0, stripedRows, hasSelection, @@ -770,6 +820,25 @@ const InternalTable = React.forwardRef( ); }) )} + {loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0 && ( + + )}
diff --git a/src/table/resizer/index.tsx b/src/table/resizer/index.tsx index 30b67278a4..ccdee85ae7 100644 --- a/src/table/resizer/index.tsx +++ b/src/table/resizer/index.tsx @@ -30,6 +30,8 @@ interface ResizerProps { roleDescription?: string; tooltipText?: string; isBorderless: boolean; + isLast?: boolean; + dividerPosition?: DividerPosition; } const RESIZE_THROTTLE = 25; @@ -37,8 +39,18 @@ const AUTO_GROW_START_TIME = 10; const AUTO_GROW_INTERVAL = 10; const AUTO_GROW_INCREMENT = 5; -export function Divider({ className }: { className?: string }) { - return ; +export type DividerPosition = 'default' | 'top' | 'bottom' | 'full'; + +export function Divider({ className, position }: { className?: string; position?: DividerPosition }) { + return ( + + ); } export function Resizer({ @@ -52,6 +64,8 @@ export function Resizer({ roleDescription, tooltipText, isBorderless, + isLast, + dividerPosition, }: ResizerProps) { onWidthUpdate = useStableCallback(onWidthUpdate); onWidthUpdateCommit = useStableCallback(onWidthUpdateCommit); @@ -330,7 +344,8 @@ export function Resizer({ className={clsx( styles['resizer-wrapper'], isVisualRefresh && styles['visual-refresh'], - (!isVisualRefresh || isBorderless) && styles['is-borderless'] + (!isVisualRefresh || isBorderless) && styles['is-borderless'], + isLast && styles['is-last'] )} ref={positioningWrapperRef} > @@ -411,7 +426,11 @@ export function Resizer({ data-focus-id={focusId} /> .divider, +th:not([data-rightmost]) > .divider, .divider-interactive { position: absolute; outline: none; @@ -46,25 +46,41 @@ th:not(:last-child) > .divider, max-block-size: calc(100% - #{$block-gap}); margin-block: auto; margin-inline: auto; - border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-interactive-default; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; box-sizing: border-box; -} -th:not(:last-child) > .divider-disabled { - border-inline-start-color: awsui.$color-border-divider-default; + // Position variants for grouped column headers. + // All Column dividers maintain the same bottom gap ($block-gap / 2) as the default. + &.divider-position-top { + // Leaf column under a group: extends upward, same bottom gap as default. + margin-block-start: 0; + margin-block-end: auto; + max-block-size: calc(100% - #{$block-gap} / 2); + } + &.divider-position-bottom { + // Group header: extends downward to meet the horizontal border below. + margin-block-start: auto; + margin-block-end: 0; + max-block-size: calc(100% - #{$block-gap} / 2); + } + &.divider-position-full { + margin-block: 0; + max-block-size: 100%; + } } .divider-interactive { inset-inline-end: calc(#{$handle-width} / 2); } -// stylelint-disable-next-line selector-combinator-disallowed-list -th:last-child > .resizer-wrapper.visual-refresh.is-borderless .divider-interactive { - inset-inline-end: 0; +.divider-active { + /* used in test-utils */ } -.divider-active { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; +.resizer-wrapper.visual-refresh.is-borderless.is-last { + > .divider-interactive { + inset-inline-end: 0; + } } .resizer { @@ -84,9 +100,6 @@ th:last-child > .resizer-wrapper.visual-refresh.is-borderless .divider-interacti .resize-active & { pointer-events: none; } - &:hover + .divider { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; - } &.has-focus { @include focus-visible.when-visible-unfocused { @include styles.focus-highlight(calc(#{awsui.$space-table-header-focus-outline-gutter} - 2px)); diff --git a/src/table/skeleton-rows.tsx b/src/table/skeleton-rows.tsx new file mode 100644 index 0000000000..e342dfe9de --- /dev/null +++ b/src/table/skeleton-rows.tsx @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import ScreenreaderOnly from '../internal/components/screenreader-only'; +import InternalSkeleton from '../skeleton/internal'; +import { TableBodyCell } from './body-cell'; +import { TableProps } from './interfaces'; +import { StickyColumnsModel } from './sticky-columns'; +import { TableRole } from './table-role'; +import { getColumnKey } from './utils'; + +import styles from './styles.css.js'; + +const noop = () => {}; + +interface SkeletonRowsProps { + count: number; + hasDataRows: boolean; + totalColumnsCount: number; + loadingText: string | undefined; + hasSelection: boolean; + hasFooter: boolean; + stickyState: StickyColumnsModel; + tableRole: TableRole; + ariaLabels: TableProps['ariaLabels']; + cellVerticalAlign: TableProps.VerticalAlign | undefined; + computedVariant: string; + visibleColumnDefinitions: readonly TableProps.ColumnDefinition[]; + wrapLines: boolean | undefined; + resizableColumns: boolean | undefined; + colIndexOffset: number; +} + +export function SkeletonRows({ + count, + hasDataRows, + totalColumnsCount, + loadingText, + hasSelection, + hasFooter, + stickyState, + tableRole, + ariaLabels, + cellVerticalAlign, + computedVariant, + visibleColumnDefinitions, + wrapLines, + resizableColumns, + colIndexOffset, +}: SkeletonRowsProps) { + return ( + <> + + + {loadingText} + + + {Array.from({ length: count }, (_, i) => { + const isFirstRow = !hasDataRows && i === 0; + const isLastRow = i === count - 1; + return ( + + {hasSelection && } + {visibleColumnDefinitions.map((column: any, colIndex: number) => ( + , + }} + item={{}} + wrapLines={wrapLines} + isEditable={false} + isEditing={false} + isRowHeader={column.isRowHeader} + resizableColumns={resizableColumns} + onEditStart={noop} + onEditEnd={noop} + columnId={column.id ?? colIndex} + colIndex={colIndex + colIndexOffset} + verticalAlign={column.verticalAlign ?? cellVerticalAlign} + tableVariant={computedVariant} + /> + ))} + + ); + })} + + ); +} diff --git a/src/table/sticky-header.tsx b/src/table/sticky-header.tsx index ae5ddf4fcb..c7b2616a9e 100644 --- a/src/table/sticky-header.tsx +++ b/src/table/sticky-header.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { StickyHeaderContext } from '../container/use-sticky-header'; import { getVisualContextClassname } from '../internal/components/visual-context'; +import { TableColGroup } from './column-groups/col-group'; import { TableProps } from './interfaces'; import { getTableRoleProps, TableRole } from './table-role'; import Thead, { TheadProps } from './thead'; @@ -29,6 +30,9 @@ interface StickyHeaderProps { contentDensity?: 'comfortable' | 'compact'; tableHasHeader?: boolean; tableRole: TableRole; + hasGroupedColumns?: boolean; + columnDefinitions?: ReadonlyArray>; + hasSelection?: boolean; } export default forwardRef(StickyHeader); @@ -40,11 +44,14 @@ function StickyHeader( wrapperRef, theadRef, secondaryWrapperRef, - onScroll, tableRef, + onScroll, tableHasHeader, contentDensity, tableRole, + hasGroupedColumns, + columnDefinitions, + hasSelection, }: StickyHeaderProps, ref: React.Ref ) { @@ -67,6 +74,10 @@ function StickyHeader( setFocus: setFocusedComponent, })); + // For grouped columns, the secondary table needs a to define column + // widths. Without it, table-layout:fixed uses the first row (which has colspan group + // headers) to determine widths — giving wrong results. + return (
+ {hasGroupedColumns && columnDefinitions && ( + + )} rows, stickyRef points to the first . + // Use the full bottom so we account for all header rows. + /* istanbul ignore next: requires DOM scroll measurements */ + const stickyEl = stickyRef.current.closest('thead') ?? stickyRef.current; + const stickyBottom = getLogicalBoundingClientRect(stickyEl).insetBlockEnd; const scrollingOffset = stickyBottom - getLogicalBoundingClientRect(item).insetBlockStart; if (scrollingOffset > 0) { scrollUpBy(scrollingOffset, containerRef.current); diff --git a/src/table/styles.scss b/src/table/styles.scss index 186090c9bd..2585b019ab 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -142,6 +142,15 @@ filter search icon. padding-inline: awsui.$space-scaled-l; border-inline-start: awsui.$border-width-item-selected solid transparent; } + + // When the selection cell spans multiple header rows, use flex to push the + // checkbox to the bottom of the cell, matching bottom-aligned column headers. + &-content-spans-rows { + display: flex; + flex-direction: column; + justify-content: flex-end; + block-size: 100%; + } } .header-secondary { @@ -223,3 +232,9 @@ filter search icon. .row-selected { /* used in test-utils */ } + +.skeleton-loading-cell { + padding-block: 0; + padding-inline: 0; + border-block-end: none; +} diff --git a/src/table/table-role/__tests__/utils.test.ts b/src/table/table-role/__tests__/utils.test.ts new file mode 100644 index 0000000000..268dcbe475 --- /dev/null +++ b/src/table/table-role/__tests__/utils.test.ts @@ -0,0 +1,150 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + findClosestCellByAriaColIndex, + findNextCell, + findTableRowCellByAriaColIndex, + getAllCellsInRow, +} from '../utils'; + +function createCell(colIndex: number, colspan = 1, rowspan = 1): HTMLTableCellElement { + const cell = document.createElement('td'); + cell.setAttribute('aria-colindex', String(colIndex)); + cell.colSpan = colspan; + cell.rowSpan = rowspan; + return cell; +} + +function createRow(ariaRowIndex: number, cells: HTMLTableCellElement[]): HTMLTableRowElement { + const row = document.createElement('tr'); + row.setAttribute('aria-rowindex', String(ariaRowIndex)); + cells.forEach(c => row.appendChild(c)); + return row; +} + +function createTable(rows: HTMLTableRowElement[]): HTMLTableElement { + const table = document.createElement('table'); + rows.forEach(r => table.appendChild(r)); + return table; +} + +describe('findTableRowCellByAriaColIndex', () => { + test('finds cell by exact colindex', () => { + const row = createRow(1, [createCell(1), createCell(2), createCell(3)]); + expect(findTableRowCellByAriaColIndex(row, 2, 1)).toBe(row.children[1]); + }); +}); + +describe('findClosestCellByAriaColIndex', () => { + test('finds cell covered by colspan', () => { + const cells = [createCell(1, 3), createCell(4)]; + // Target 2 is within colspan of cell at colindex=1 (covers 1,2,3) + expect(findClosestCellByAriaColIndex(cells, 2, 1)).toBe(cells[0]); + }); + + test('finds closest cell when target is beyond last cell (forward)', () => { + const cells = [createCell(1), createCell(2), createCell(3)]; + // Target 5 doesn't exist — closest in forward direction is cell 3 + expect(findClosestCellByAriaColIndex(cells, 5, 1)).toBe(cells[2]); + }); + + test('breaks when columnIndex exceeds target in forward direction', () => { + // Cells at 1, 3, 5 — target is 4, delta >= 0 + // Sorted: 1, 3, 5. Loop: targetCell=1, targetCell=3, targetCell=5 (5>4 → break) + const cells = [createCell(1), createCell(3), createCell(5)]; + expect(findClosestCellByAriaColIndex(cells, 4, 1)).toBe(cells[2]); + }); + + test('breaks when columnIndex is below target in reverse direction', () => { + // Cells at 1, 3, 5 — target is 4, delta < 0 + // Sorted reversed: 5, 3, 1. Loop: targetCell=5, targetCell=3 (3<4 → break) + const cells = [createCell(1), createCell(3), createCell(5)]; + expect(findClosestCellByAriaColIndex(cells, 4, -1)).toBe(cells[1]); + }); +}); + +describe('getAllCellsInRow', () => { + test('includes cells from earlier rows that span into target row', () => { + const cell1 = createCell(1, 1, 2); // rowspan=2, spans rows 1-2 + const cell2 = createCell(2); + const row1 = createRow(1, [cell1, cell2]); + const cell3 = createCell(2); + const row2 = createRow(2, [cell3]); + const table = createTable([row1, row2]); + + const cells = getAllCellsInRow(table, 2); + // cell1 (rowspan=2 from row 1) + cell3 (row 2) + expect(cells).toContain(cell1); + expect(cells).toContain(cell3); + expect(cells).not.toContain(cell2); // cell2 only in row 1 + }); +}); + +describe('findNextCell', () => { + test('skips past current cell when vertical movement lands on same cell due to rowspan', () => { + const cell1 = createCell(1, 1, 2); // rowspan=2, spans rows 1-2 + const cell2 = createCell(2); + const row1 = createRow(1, [cell1, cell2]); + const cell3 = createCell(1); + const cell4 = createCell(2); + const row2 = createRow(2, [cell3, cell4]); + const cell5 = createCell(1); + const row3 = createRow(3, [cell5]); + const table = createTable([row1, row2, row3]); + + // Moving down from cell1 (rowspan=2, in row1) targeting row2 — lands on cell1 again + // Skips to row 1+2=3, finds cell5 + const result = findNextCell(table, row2, 1, { x: 0, y: 1 }, cell1); + expect(result).toBe(cell5); + }); + + test('returns target cell for horizontal movement', () => { + const cell1 = createCell(1); + const cell2 = createCell(2); + const row = createRow(1, [cell1, cell2]); + const table = createTable([row]); + + const result = findNextCell(table, row, 2, { x: 1, y: 0 }, cell1); + expect(result).toBe(cell2); + }); + + test('returns null when horizontal skip lands on same cell (boundary)', () => { + const cell1 = createCell(1, 3); // colspan=3, covers cols 1-3 + const row = createRow(1, [cell1]); + const table = createTable([row]); + + // Moving right from cell1 — target col 2 lands on cell1 (colspan covers it) + // Skip to col 1+3=4, but no cell there → returns null + const result = findNextCell(table, row, 2, { x: 1, y: 0 }, cell1); + expect(result).toBeNull(); + }); + + test('returns null when vertical skip cannot find a row beyond rowspan', () => { + const cell1 = createCell(1, 1, 2); // rowspan=2 + const row1 = createRow(1, [cell1]); + const table = createTable([row1]); + + // Moving down from cell1 (rowspan=2) at row 1. + // getAllCellsInRow(table, 1) finds cell1. targetCell = cell1 = currentCell. + // Skip: skipToRowIndex = 1+2 = 3. findTableRowByAriaRowIndex searches tr[aria-rowindex]. + // Only row1 exists (index=1). Loop sets targetRow=row1, no break fires, returns row1. + // To make it return null, we need no tr[aria-rowindex] for the skip search. + // Mock: temporarily remove aria-rowindex after getAllCellsInRow runs. + const origQuerySelectorAll = table.querySelectorAll.bind(table); + let callCount = 0; + jest.spyOn(table, 'querySelectorAll').mockImplementation((selector: string) => { + callCount++; + // First call is getAllCellsInRow, let it work normally. + // Second call is findTableRowByAriaRowIndex for the skip — return empty. + if (callCount > 1 && selector === 'tr[aria-rowindex]') { + return document.createElement('div').querySelectorAll('tr'); // empty NodeList + } + return origQuerySelectorAll(selector); + }); + + const result = findNextCell(table, row1, 1, { x: 0, y: 1 }, cell1); + expect(result).toBeNull(); + + jest.restoreAllMocks(); + }); +}); diff --git a/src/table/table-role/grid-navigation.tsx b/src/table/table-role/grid-navigation.tsx index f655d4aec6..e419d2e3fd 100644 --- a/src/table/table-role/grid-navigation.tsx +++ b/src/table/table-role/grid-navigation.tsx @@ -17,8 +17,8 @@ import { nodeBelongs } from '../../internal/utils/node-belongs'; import { FocusedCell, GridNavigationProps } from './interfaces'; import { defaultIsSuppressed, + findNextCell, findTableRowByAriaRowIndex, - findTableRowCellByAriaColIndex, focusNextElement, getClosestCell, isElementDisabled, @@ -330,15 +330,13 @@ export class GridNavigationProcessor { return cellFocusables[nextElementIndex]; } - // Find next cell to focus or move focus into (can be null if the left/right edge is reached). + // Find next cell to focus or move focus into. const targetAriaColIndex = from.colIndex + delta.x; - const targetCell = findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x); - if (!targetCell) { - return null; - } - // When target cell matches the current cell it means we reached the left or right boundary. - if (targetCell === cellElement && delta.x !== 0) { + const targetCell = this.table + ? findNextCell(this.table, targetRow, targetAriaColIndex, delta, cellElement as HTMLTableCellElement | null) + : null; + if (!targetCell) { return null; } diff --git a/src/table/table-role/table-role-helper.ts b/src/table/table-role/table-role-helper.ts index f28752e3fd..ac0748421b 100644 --- a/src/table/table-role/table-role-helper.ts +++ b/src/table/table-role/table-role-helper.ts @@ -22,6 +22,7 @@ export function getTableRoleProps(options: { ariaLabelledby?: string; totalItemsCount?: number; totalColumnsCount?: number; + headerRowCount?: number; }): React.TableHTMLAttributes { const nativeProps: React.TableHTMLAttributes = {}; @@ -33,8 +34,9 @@ export function getTableRoleProps(options: { nativeProps['aria-labelledby'] = options.ariaLabelledby; // Incrementing the total count by one to account for the header row. + const headerRows = options.headerRowCount ?? 1; if (typeof options.totalItemsCount === 'number' && options.totalItemsCount > 0) { - nativeProps['aria-rowcount'] = options.totalItemsCount + 1; + nativeProps['aria-rowcount'] = options.totalItemsCount + headerRows; } if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { @@ -68,12 +70,12 @@ export function getTableWrapperRoleProps(options: { return nativeProps; } -export function getTableHeaderRowRoleProps(options: { tableRole: TableRole }) { +export function getTableHeaderRowRoleProps(options: { tableRole: TableRole; rowIndex?: number }) { const nativeProps: React.HTMLAttributes = {}; // For grids headers are treated similar to data rows and are indexed accordingly. if (options.tableRole === 'grid' || options.tableRole === 'grid-default' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = 1; + nativeProps['aria-rowindex'] = (options.rowIndex ?? 0) + 1; } return nativeProps; @@ -83,6 +85,7 @@ export function getTableRowRoleProps(options: { tableRole: TableRole; rowIndex: number; firstIndex?: number; + headerRowCount?: number; level?: number; setSize?: number; posInSet?: number; @@ -90,12 +93,13 @@ export function getTableRowRoleProps(options: { const nativeProps: React.HTMLAttributes = {}; // The data cell indices are incremented by 1 to account for the header cells. + const headerRows = options.headerRowCount ?? 1; if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + 1; + nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + headerRows; } // For tables indices are only added when the first index is not 0 (not the first page/frame). else if (options.firstIndex !== undefined) { - nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + 1; + nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + headerRows; } if (options.tableRole === 'treegrid' && options.level && options.level !== 0) { nativeProps['aria-level'] = options.level; diff --git a/src/table/table-role/utils.ts b/src/table/table-role/utils.ts index c39809a50d..5902e0f04a 100644 --- a/src/table/table-role/utils.ts +++ b/src/table/table-role/utils.ts @@ -68,18 +68,72 @@ export function findTableRowCellByAriaColIndex( targetAriaColIndex: number, delta: number ) { + const cellElements = Array.from( + tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]') + ); + return findClosestCellByAriaColIndex(cellElements, targetAriaColIndex, delta); +} + +/** + * Collects all cells visually present in a row, including cells from earlier rows + * that span into this row via rowspan. This is needed because cells with rowspan > 1 + * are only in one in the DOM but visually occupy multiple rows. + */ +export function getAllCellsInRow(table: HTMLTableElement, targetAriaRowIndex: number): HTMLTableCellElement[] { + const cells: HTMLTableCellElement[] = []; + const rows = table.querySelectorAll('tr[aria-rowindex]'); + + for (const row of Array.from(rows)) { + const rowIndex = parseInt(row.getAttribute('aria-rowindex') ?? ''); + if (isNaN(rowIndex) || rowIndex > targetAriaRowIndex) { + continue; + } + + const rowCells = row.querySelectorAll('td[aria-colindex],th[aria-colindex]'); + for (const cell of Array.from(rowCells)) { + const rowspan = cell.rowSpan || 1; + // Cell is visible in target row if: rowIndex <= targetAriaRowIndex < rowIndex + rowspan + if (rowIndex + rowspan > targetAriaRowIndex) { + cells.push(cell); + } + } + } + + return cells; +} + +/** + * From a list of cell elements, find the closest one to targetAriaColIndex in the direction of delta. + * Accounts for colspan: a cell with colindex=2 and colspan=4 covers columns 2,3,4,5. + */ +export function findClosestCellByAriaColIndex( + cellElements: HTMLTableCellElement[], + targetAriaColIndex: number, + delta: number +): HTMLTableCellElement | null { + // First check if any cell's colspan range covers the target exactly. + for (const element of cellElements) { + const colIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); + const colspan = element.colSpan || 1; + if (colIndex <= targetAriaColIndex && targetAriaColIndex < colIndex + colspan) { + return element; + } + } + + // Otherwise find the closest cell in the direction of delta. let targetCell: null | HTMLTableCellElement = null; - const cellElements = Array.from(tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]')); + const sorted = [...cellElements].sort((a, b) => { + const aIdx = parseInt(a.getAttribute('aria-colindex') ?? '0'); + const bIdx = parseInt(b.getAttribute('aria-colindex') ?? '0'); + return aIdx - bIdx; + }); if (delta < 0) { - cellElements.reverse(); + sorted.reverse(); } - for (const element of cellElements) { + for (const element of sorted) { const columnIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); - targetCell = element as HTMLTableCellElement; + targetCell = element; - if (columnIndex === targetAriaColIndex) { - break; - } if (delta >= 0 && columnIndex > targetAriaColIndex) { break; } @@ -90,6 +144,50 @@ export function findTableRowCellByAriaColIndex( return targetCell; } +/** + * Finds the next cell to navigate to, handling colspan and rowspan for grouped columns. + * Skips past the current cell when movement lands on it due to span attributes. + */ +export function findNextCell( + table: HTMLTableElement, + targetRow: HTMLTableRowElement, + targetAriaColIndex: number, + delta: { x: number; y: number }, + currentCell: HTMLTableCellElement | null +): HTMLTableCellElement | null { + const targetRowAriaIndex = parseInt(targetRow.getAttribute('aria-rowindex') ?? ''); + let allVisibleCells = getAllCellsInRow(table, targetRowAriaIndex); + let targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + + // When vertical movement lands on the same cell (due to rowspan), skip past it. + if (targetCell === currentCell && delta.y !== 0 && currentCell) { + const cellRow = currentCell.closest('tr'); + const cellRowIndex = parseInt(cellRow?.getAttribute('aria-rowindex') ?? '0'); + const cellRowSpan = currentCell.rowSpan || 1; + const skipToRowIndex = delta.y > 0 ? cellRowIndex + cellRowSpan : cellRowIndex - 1; + const skipRow = findTableRowByAriaRowIndex(table, skipToRowIndex, delta.y); + if (!skipRow) { + return null; + } + const skipRowAriaIndex = parseInt(skipRow.getAttribute('aria-rowindex') ?? ''); + allVisibleCells = getAllCellsInRow(table, skipRowAriaIndex); + targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + } + + // When horizontal movement lands on the same cell (due to colspan), skip past it. + if (targetCell === currentCell && delta.x !== 0 && currentCell) { + const cellColIndex = parseInt(currentCell.getAttribute('aria-colindex') ?? '0'); + const cellColSpan = currentCell.colSpan || 1; + const skipToColIndex = delta.x > 0 ? cellColIndex + cellColSpan : cellColIndex - 1; + targetCell = findClosestCellByAriaColIndex(allVisibleCells, skipToColIndex, delta.x); + if (!targetCell || targetCell === currentCell) { + return null; + } + } + + return targetCell; +} + export function isTableCell(element: Element) { return element.tagName === 'TD' || element.tagName === 'TH'; } diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 10024c014e..d5d381d652 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,13 +6,16 @@ import clsx from 'clsx'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; +import { getGroupColumnIds, getGroupSplit } from './column-groups/split-utils'; +import { ColumnGroupsLayout } from './column-groups/utils'; import { TableHeaderCell } from './header-cell'; +import { TableGroupHeaderCell } from './header-cell/group-header-cell'; import { InternalSelectionType, TableProps } from './interfaces'; import { focusMarkers, ItemSelectionProps } from './selection'; import { TableHeaderSelectionCell } from './selection/selection-cell'; import { StickyColumnsModel } from './sticky-columns'; import { getTableHeaderRowRoleProps, TableRole } from './table-role'; -import { useColumnWidths } from './use-column-widths'; +import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from './use-column-widths'; import { getColumnKey } from './utils'; import styles from './styles.css.js'; @@ -20,6 +23,8 @@ import styles from './styles.css.js'; export interface TheadProps { selectionType: undefined | InternalSelectionType; columnDefinitions: ReadonlyArray>; + groupDefinitions?: ReadonlyArray; + columnGroupsLayout?: ColumnGroupsLayout; sortingColumn: TableProps.SortingColumn | undefined; sortingDescending: boolean | undefined; sortingDisabled: boolean | undefined; @@ -39,6 +44,8 @@ export interface TheadProps { resizerTooltipText?: string; stripedRows?: boolean; stickyState: StickyColumnsModel; + stickyColumnsFirst: number; + stickyColumnsLast: number; selectionColumnId: PropertyKey; focusedComponent?: null | string; onFocusedComponentChange?: (focusId: null | string) => void; @@ -53,6 +60,7 @@ const Thead = React.forwardRef( selectionType, getSelectAllProps, columnDefinitions, + columnGroupsLayout, sortingColumn, sortingDisabled, sortingDescending, @@ -69,6 +77,8 @@ const Thead = React.forwardRef( hidden = false, stuck = false, stickyState, + stickyColumnsFirst, + stickyColumnsLast, selectionColumnId, focusedComponent, onFocusedComponentChange, @@ -80,7 +90,20 @@ const Thead = React.forwardRef( }: TheadProps, outerRef: React.Ref ) => { - const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths(); + const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths(); + + const handleSplitGroupResize = (columnIds: string[], newWidth: number) => { + const lastColumn = columnIds[columnIds.length - 1]; + if (lastColumn) { + const currentGroupWidth = columnIds.reduce( + (sum, id) => sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH), + 0 + ); + const delta = newWidth - currentGroupWidth; + const currentColumnWidth = columnWidths.get(lastColumn) || DEFAULT_COLUMN_WIDTH; + updateColumn(lastColumn, currentColumnWidth + delta); + } + }; const commonCellProps = { stuck, @@ -91,69 +114,322 @@ const Thead = React.forwardRef( variant, tableVariant, stickyState, + wrapLines, }; + const sharedTrProps = { + onFocus: (event: React.FocusEvent) => { + const focusControlElement = findUpUntil( + event.target as HTMLElement, + element => !!element.getAttribute('data-focus-id') + ); + const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; + onFocusedComponentChange?.(focusId); + }, + onBlur: () => onFocusedComponentChange?.(null), + }; + + // No grouping - render single row + if (!columnGroupsLayout || columnGroupsLayout.rows.length <= 1) { + return ( + + + {selectionType ? ( + setCell(sticky, selectionColumnId, node)} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + /> + ) : null} + + {columnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => setCell(sticky, columnId, node)} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + isLast={colIndex === columnDefinitions.length - 1} + /> + ); + })} + + + ); + } + + // Grouped columns + const totalColumns = columnDefinitions.length; return ( - { - const focusControlElement = findUpUntil(event.target, element => !!element.getAttribute('data-focus-id')); - const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; - onFocusedComponentChange?.(focusId); - }} - onBlur={() => onFocusedComponentChange?.(null)} - > - {selectionType ? ( - - ) : null} - - {columnDefinitions.map((column, colIndex) => { - const columnId = getColumnKey(column, colIndex); - return ( - ( + + {/* Selection column — render once in the first row with rowSpan covering all header rows */} + {selectionType && rowIndex === 0 ? ( + onResizeFinish(columnWidths)} - resizableColumns={resizableColumns} - resizableStyle={getColumnStyles(sticky, columnId)} - onClick={detail => { - setLastUserAction('sorting'); - fireNonCancelableEvent(onSortingChange, detail); - }} - isEditable={!!column.editConfig} - cellRef={node => setCell(sticky, columnId, node)} - tableRole={tableRole} - resizerRoleDescription={resizerRoleDescription} - resizerTooltipText={resizerTooltipText} - // Expandable option is only applicable to the first data column of the table. - // When present, the header content receives extra padding to match the first offset in the data cells. - isExpandable={colIndex === 0 && isExpandable} - hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + columnId={selectionColumnId} + cellRef={node => setCell(sticky, selectionColumnId, node)} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + rowSpan={columnGroupsLayout.rows.length} /> - ); - })} - + ) : null} + + {row.columns.map((col, colIndexInRow) => { + // A cell is the last child of its parent group when the next rendered cell + // in the same row belongs to a different top-level parent, i.e. they don't + // share the same immediate parent group. + const nextCol = row.columns[colIndexInRow + 1]; + const thisParent = col.parentGroupIds[col.parentGroupIds.length - 1] ?? null; + const nextParent = nextCol ? (nextCol.parentGroupIds[nextCol.parentGroupIds.length - 1] ?? null) : null; + // A column is also considered last-child-of-group when the sticky boundary + // bisects its parent group just after this column — visually it's the rightmost + // column of the sticky half, so its resizer should span full-height like a + // normal last-child-of-group. + const isColumnAtStickyFirstBoundary = + !col.isGroup && + thisParent !== null && + stickyColumnsFirst > 0 && + col.colIndex === stickyColumnsFirst - 1; + const isColumnAtStickyLastBoundary = + !col.isGroup && + thisParent !== null && + stickyColumnsLast > 0 && + col.colIndex === columnDefinitions.length - stickyColumnsLast - 1; + const isLastChildOfGroup = + (thisParent !== null && thisParent !== nextParent) || + isColumnAtStickyFirstBoundary || + isColumnAtStickyLastBoundary; + + if (col.isGroup) { + // Group header cell + const groupDefinition = col.groupDefinition!; + const childIds = getGroupColumnIds(columnGroupsLayout!, col.id); + const sharedGroupCellProps = { + ...commonCellProps, + tabIndex: sticky ? -1 : 0, + focusedComponent, + group: groupDefinition, + rowspan: col.rowSpan, + resizableColumns, + onResizeFinish: () => onResizeFinish(columnWidths), + columnGroupId: + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined, + }; + const splitFirst = getGroupSplit({ + col, + stickyCount: stickyColumnsFirst, + side: 'first', + totalColumns, + }); + const splitLast = getGroupSplit({ + col, + stickyCount: stickyColumnsLast, + side: 'last', + totalColumns, + }); + const split = splitFirst.stickyColspan > 0 ? splitFirst : splitLast; + const isSplit = split.stickyColspan > 0; + + if (isSplit) { + // Group is bisected by the sticky boundary — render two elements. + // Both halves get resizers. Each resizes its own rightmost column child. + const isSplitFirst = splitFirst.stickyColspan > 0; + + // Left half is sticky for 'first', non-sticky for 'last' + const leftColspan = isSplitFirst ? split.stickyColspan : split.staticColspan; + const leftColIndex = col.colIndex; + const leftGroupId = isSplitFirst ? col.id : `${col.id}__split`; + const leftChildIds = childIds.slice(0, leftColspan); + + // Right half is non-sticky for 'first', sticky for 'last' + const rightColspan = isSplitFirst ? split.staticColspan : split.stickyColspan; + const rightColIndex = col.colIndex + leftColspan; + const rightGroupId = isSplitFirst ? `${col.id}__split` : col.id; + const rightChildIds = childIds.slice(leftColspan); + + return ( + + {/* Left half */} + { + handleSplitGroupResize(leftChildIds, newWidth); + }} + childColumnIds={leftChildIds} + firstChildColumnId={leftChildIds[0]} + lastChildColumnId={leftChildIds[leftChildIds.length - 1]} + cellRef={isSplitFirst ? node => setCell(sticky, col.id, node) : () => {}} + isLast={false} + stickyColumnId={isSplitFirst ? childIds[0] : undefined} + stickyBoundaryColumnId={isSplitFirst ? leftChildIds[leftChildIds.length - 1] : undefined} + /> + + {/* Right half */} + { + handleSplitGroupResize(rightChildIds, newWidth); + }} + childColumnIds={rightChildIds} + firstChildColumnId={rightChildIds[0]} + lastChildColumnId={rightChildIds[rightChildIds.length - 1]} + cellRef={!isSplitFirst ? node => setCell(sticky, col.id, node) : () => {}} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isLast={rightColIndex + rightColspan === totalColumns} + stickyColumnId={!isSplitFirst ? childIds[childIds.length - 1] : undefined} + /> + + ); + } + + // Determine if the entire group is sticky (all children on one side) + const isFullyStickyFirst = + stickyColumnsFirst > 0 && col.colIndex + col.colSpan - 1 < stickyColumnsFirst; + const isFullyStickyLast = + stickyColumnsLast > 0 && col.colIndex >= columnDefinitions.length - stickyColumnsLast; + const fullyStickyColumnId = isFullyStickyFirst + ? childIds[0] + : isFullyStickyLast + ? childIds[childIds.length - 1] + : undefined; + + // When the group's last child is the sticky-first boundary, the group + // needs the shadow from that child (but offset from the first child). + const isAtStickyFirstBoundary = + isFullyStickyFirst && col.colIndex + col.colSpan - 1 === stickyColumnsFirst - 1; + const isAtStickyLastBoundary = + isFullyStickyLast && col.colIndex === columnDefinitions.length - stickyColumnsLast; + const fullyStickyBoundaryColumnId = isAtStickyFirstBoundary + ? childIds[childIds.length - 1] + : isAtStickyLastBoundary + ? childIds[0] + : undefined; + + return ( + { + updateGroup(groupId, newWidth); + }} + childColumnIds={childIds} + firstChildColumnId={childIds[0]} + lastChildColumnId={childIds[childIds.length - 1]} + cellRef={node => setCell(sticky, col.id, node)} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isLast={col.colIndex + col.colSpan === totalColumns} + stickyColumnId={fullyStickyColumnId} + stickyBoundaryColumnId={fullyStickyBoundaryColumnId} + /> + ); + } else { + // Regular column cell + const column = col.columnDefinition!; + const columnId = col.id; + const colIndex = col.colIndex; + + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => { + setCell(sticky, columnId, node); + }} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + colSpan={col.colSpan} + rowSpan={col.rowSpan} + isLastChildOfGroup={isLastChildOfGroup} + isLast={col.colIndex + col.colSpan === totalColumns} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + ); + } + })} + + ))} ); } diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index 694972aa12..fbc232fd1d 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -39,7 +39,7 @@ function updateWidths( oldWidths: Map, newWidth: number, columnId: PropertyKey -) { +): Map { const column = visibleColumns.find(column => column.id === columnId); let minWidth = DEFAULT_COLUMN_WIDTH; if (typeof column?.width === 'number' && column.width < DEFAULT_COLUMN_WIDTH) { @@ -61,14 +61,18 @@ interface WidthsContext { getColumnStyles(sticky: boolean, columnId: PropertyKey): ColumnWidthStyle; columnWidths: Map; updateColumn: (columnId: PropertyKey, newWidth: number) => void; + updateGroup: (groupId: PropertyKey, newWidth: number) => void; setCell: (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => void; + setCol: (columnId: PropertyKey, node: null | HTMLElement) => void; } const WidthsContext = createContext({ getColumnStyles: () => ({}), columnWidths: new Map(), updateColumn: () => {}, + updateGroup: () => {}, setCell: () => {}, + setCol: () => {}, }); interface WidthProviderProps { @@ -76,15 +80,24 @@ interface WidthProviderProps { resizableColumns: boolean | undefined; containerRef: React.RefObject; children: React.ReactNode; + groupColumnMap?: Map; } -export function ColumnWidthsProvider({ visibleColumns, resizableColumns, containerRef, children }: WidthProviderProps) { +export function ColumnWidthsProvider({ + visibleColumns, + resizableColumns, + containerRef, + groupColumnMap, + children, +}: WidthProviderProps) { const visibleColumnsRef = useRef(null); const containerWidthRef = useRef(0); const [columnWidths, setColumnWidths] = useState>(null); const cellsRef = useRef(new Map()); const stickyCellsRef = useRef(new Map()); + const colsRef = useRef(new Map()); + const hasColElements = useRef(false); const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current.get(columnId) ?? null; const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { const ref = sticky ? stickyCellsRef : cellsRef; @@ -94,19 +107,32 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain ref.current.delete(columnId); } }; + const setCol = (columnId: PropertyKey, node: null | HTMLElement) => { + if (node) { + colsRef.current.set(columnId, node); + hasColElements.current = true; + } else { + colsRef.current.delete(columnId); + hasColElements.current = colsRef.current.size > 0; + } + }; const getColumnStyles = (sticky: boolean, columnId: PropertyKey): ColumnWidthStyle => { - const column = visibleColumns.find(column => column.id === columnId); - if (!column) { - return {}; - } + const column = visibleColumns.find(col => col.id === columnId); if (sticky) { - return { - width: - cellsRef.current.get(column.id)?.getBoundingClientRect().width || - (columnWidths?.get(column.id) ?? column.width), - }; + // For sticky headers, mirror the primary cell's width. + // Try DOM measurement first (handles columns not in visibleColumns like selection). + const measured = cellsRef.current.get(columnId)?.getBoundingClientRect().width; + /* istanbul ignore next: getBoundingClientRect returns 0 in JSDOM */ + if (measured) { + return { width: measured }; + } + return { width: columnWidths?.get(columnId) ?? column?.width }; + } + + if (!column) { + return {}; } if (resizableColumns && columnWidths) { @@ -116,11 +142,11 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain 0 ); if (isLastColumn && containerWidthRef.current > totalWidth) { - return { width: 'auto', minWidth: column?.minWidth }; - } else { - return { width: columnWidths.get(column.id), minWidth: column?.minWidth }; + return { width: 'auto', minWidth: column.minWidth }; } + return { width: columnWidths.get(column.id), minWidth: column.minWidth }; } + return { width: column.width, minWidth: column.minWidth, @@ -131,13 +157,29 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain // Imperatively sets width style for a cell avoiding React state. // This allows setting the style as soon container's size change is observed. const updateColumnWidths = useStableCallback(() => { - for (const { id } of visibleColumns) { - const element = cellsRef.current.get(id); - if (element) { - setElementWidths(element, getColumnStyles(false, id)); + // When col elements exist (grouped columns), apply widths to elements. + // With table-layout:fixed, widths control the actual column widths. + if (hasColElements.current) { + for (const { id } of visibleColumns) { + const colElement = colsRef.current.get(id); + if (colElement) { + setElementWidths(colElement, getColumnStyles(false, id)); + } + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } + } + } else { + for (const { id } of visibleColumns) { + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } } } - // Sticky column widths must be synchronized once all real column widths are assigned. + + // Sticky column widths must always be synchronized regardless of columnWidths state. for (const { id } of visibleColumns) { const element = stickyCellsRef.current.get(id); if (element) { @@ -195,8 +237,31 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? new Map(), newWidth, columnId)); } + function updateGroup(groupId: PropertyKey, newGroupWidth: number) { + if (!columnWidths || !groupColumnMap) { + return; + } + + const columnIds = groupColumnMap.get(String(groupId)) ?? []; + const rightmostColumn = columnIds[columnIds.length - 1]; + if (!rightmostColumn) { + return; + } + + let currentGroupWidth = 0; + for (const id of columnIds) { + currentGroupWidth += columnWidths.get(id) || DEFAULT_COLUMN_WIDTH; + } + + const delta = newGroupWidth - currentGroupWidth; + const currentColumnWidth = columnWidths.get(rightmostColumn) || DEFAULT_COLUMN_WIDTH; + updateColumn(rightmostColumn, currentColumnWidth + delta); + } + return ( - + {children} ); diff --git a/src/table/use-sticky-header.ts b/src/table/use-sticky-header.ts index 952ccb6b49..bbf516ece3 100644 --- a/src/table/use-sticky-header.ts +++ b/src/table/use-sticky-header.ts @@ -24,7 +24,9 @@ export const useStickyHeader = ( secondaryTableRef.current && tableWrapperRef.current ) { - tableWrapperRef.current.style.marginBlockStart = `-${theadRef.current.getBoundingClientRect().height}px`; + // Use the full thead height to account for multi-row headers (grouped columns). + const thead = theadRef.current.closest('thead') ?? theadRef.current; + tableWrapperRef.current.style.marginBlockStart = `-${thead.getBoundingClientRect().height}px`; } }, [theadRef, secondaryTheadRef, secondaryTableRef, tableWrapperRef, tableRef]); useLayoutEffect(() => { diff --git a/src/table/utils.ts b/src/table/utils.ts index 6822e581e4..6166bfac10 100644 --- a/src/table/utils.ts +++ b/src/table/utils.ts @@ -79,10 +79,8 @@ function getVisibleColumnDefinitionsFromColumnDisplay({ (accumulator, item) => (item.id === undefined ? accumulator : { ...accumulator, [item.id]: item }), {} ); - return columnDisplay - .filter(item => item.visible) - .map(item => columnDefinitionsById[item.id]) - .filter(Boolean); + const visibleIds = flattenVisibleColumnIds(columnDisplay); + return visibleIds.map(id => columnDefinitionsById[id]).filter(Boolean); } function getVisibleColumnDefinitionsFromVisibleColumns({ @@ -104,3 +102,15 @@ export function getStickyClassNames(styles: Record, props: Stick [styles['sticky-cell-last-inline-end']]: !!props?.lastInsetInlineEnd, }; } + +function flattenVisibleColumnIds(items: ReadonlyArray): string[] { + const ids: string[] = []; + for (const item of items) { + if (item.type === 'group') { + ids.push(...flattenVisibleColumnIds(item.children)); + } else if (item.visible) { + ids.push(item.id); + } + } + return ids; +} diff --git a/src/test-utils/dom/collection-preferences/content-display-preference.ts b/src/test-utils/dom/collection-preferences/content-display-preference.ts index f3a9952be4..83f4ec20af 100644 --- a/src/test-utils/dom/collection-preferences/content-display-preference.ts +++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts @@ -30,12 +30,53 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { /** * Returns the visibility toggle for the option item. + * Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle. */ findVisibilityToggle(): ToggleWrapper { return this.getListItem() .findContent() .findComponent(`.${styles['content-display-option-toggle']}`, ToggleWrapper)!; } + + /** + * Returns all child option items nested under this item when it is a group. + * Returns `null` when this item is a leaf column (has no nested children). + * + * The children are the leaf-level `ContentDisplayOptionWrapper`s inside the group's + * nested `InternalList` — i.e. they already carry a drag handle and visibility toggle. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all child items regardless of type. + */ + /* istanbul ignore next: :has() selector not supported in JSDOM */ + findChildrenOptions( + option: { + group?: boolean; + } = {} + ): Array | null { + const groupWrapper = this.getListItem().findContent().find('[data-item-type="group"]'); + if (!groupWrapper) { + return null; + } + const nestedList = groupWrapper.find(`.${ListWrapper.rootSelector}`); + if (!nestedList) { + return null; + } + const list = new ListWrapper(nestedList.getElement()); + + if (option.group === true) { + return list + .findAll(`li:has([data-item-type="group"])`) + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } + if (option.group === false) { + return list + .findAll(`li:has([data-item-type="column"])`) + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } + + return list.findItems().map(item => new ContentDisplayOptionWrapper(item.getElement())); + } } export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { @@ -70,9 +111,34 @@ export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { } /** - * Returns options that the user can reorder. + * Returns the top-level items in the preference list. + * + * For tables **without** column grouping this returns all column options. + * For tables **with** column grouping this returns the top-level entries only + * (which are group items). Use `.findChildrenOptions()` on a group item to + * access the leaf columns nested within it. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all top-level items regardless of type. + * @param option.visible When `true`, returns only visible items. When `false`, returns only hidden items. + * Note that group items have no visibility toggle and are excluded when this filter is active. */ - findOptions(): Array { + findOptions(option: { group?: boolean } = {}): Array { + /* istanbul ignore next: :has() selector not supported in JSDOM */ if (option.group === true) { + // Only group items — identified by the data-item-type="group" wrapper inside the list item + return this.getList() + .findAll(`li:has([data-item-type="group"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + /* istanbul ignore next: :has() selector not supported in JSDOM */ + if (option.group === false) { + // Only leaf column items — identified by the data-item-type="column" wrapper + return this.getList() + .findAll(`li:has([data-item-type="column"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + + // No group filter — return all top-level items return this.getList() .findItems() .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index 4858341902..268ef6ee5e 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -46,7 +46,25 @@ export default class TableWrapper extends ComponentWrapper { return this.containerWrapper.findFooter(); } - findColumnHeaders(): Array { + /** + * Returns column header cells from the table's header region. + * + * By default, returns all leaf-column headers (`scope="col"`). + * For tables without column grouping this is equivalent to the previous behavior. + * For tables with column grouping this excludes group header cells. + * + * @param option.groupId When provided, returns only leaf columns belonging to this group + * (matched via `data-column-group-id` attribute). + */ + findColumnHeaders( + option: { + groupId?: string; + } = {} + ): Array { + const { groupId } = option; + if (groupId !== undefined) { + return this.findActiveTHead().findAll(`th[data-column-group-id="${groupId}"]`); + } return this.findActiveTHead().findAll('tr > *'); } @@ -56,7 +74,9 @@ export default class TableWrapper extends ComponentWrapper { * @param columnIndex 1-based index of the column containing the resizer. */ findColumnResizer(columnIndex: number): ElementWrapper | null { - return this.findActiveTHead().find(`th:nth-child(${columnIndex}) .${resizerStyles.resizer}`); + return this.findActiveTHead().find( + `th[data-column-index="${columnIndex}"] .${resizerStyles.resizer}, tr:not([data-group-level]) > th:nth-child(${columnIndex}) .${resizerStyles.resizer}` + ); } /** @@ -105,8 +125,15 @@ export default class TableWrapper extends ComponentWrapper { return this.findByClassName(styles.loading); } + /** + * Returns the clickable sorting area of a column header. + * + * @param colIndex 1-based index of the column. + */ findColumnSortingArea(colIndex: number): ElementWrapper | null { - return this.findActiveTHead().find(`tr > *:nth-child(${colIndex}) [role=button]`); + return this.findActiveTHead().find( + `th[data-column-index="${colIndex}"] [role=button], tr:not([data-group-level]) > *:nth-child(${colIndex}) [role=button]` + ); } /** diff --git a/src/tree-view/tree-item/index.tsx b/src/tree-view/tree-item/index.tsx index 603ba5f6fe..98b3a8a7fb 100644 --- a/src/tree-view/tree-item/index.tsx +++ b/src/tree-view/tree-item/index.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; + import { useInternalI18n } from '../../i18n/context'; import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; import InternalStructuredItem from '../../internal/components/structured-item'; @@ -86,7 +88,7 @@ const InternalTreeItem = ({ data-testid={`awsui-treeitem-${id}`} data-awsui-tree-item-index={allVisibleItemsIndices[id]} > -
+
{isExpandable ? ( diff --git a/src/tree-view/tree-item/styles.scss b/src/tree-view/tree-item/styles.scss index 7d2286c7db..3328a99c12 100644 --- a/src/tree-view/tree-item/styles.scss +++ b/src/tree-view/tree-item/styles.scss @@ -49,6 +49,16 @@ $item-toggle-column-width: 28px; } } + &.one-theme { + align-items: center; + > .expand-toggle-wrapper { + > .toggle { + inset-inline-start: 0px; + inset-block-start: 3px; + } + } + } + > .structured-item-wrapper { grid-column: 2; grid-row: 1 / span 2; diff --git a/style-dictionary/one-theme/colors.ts b/style-dictionary/one-theme/colors.ts index 5b22b7f32b..1c44fc25a6 100644 --- a/style-dictionary/one-theme/colors.ts +++ b/style-dictionary/one-theme/colors.ts @@ -15,6 +15,7 @@ const tokens: StyleDictionary.ColorsDictionary = { colorBackgroundContainerHeader: { light: '{colorWhite}', dark: '{colorNeutralGrey950}' }, colorBackgroundContainerContent: { light: '{colorWhite}', dark: '{colorNeutralGrey950}' }, colorBorderDividerDefault: { light: '{colorNeutralGrey300}', dark: '{colorNeutralGrey750}' }, + colorBorderDividerInteractiveDefault: { light: '{colorNeutralGrey500}', dark: '{colorNeutralGrey600}' }, colorBorderDividerSecondary: { light: '{colorNeutralGrey200}', dark: '{colorNeutralGrey800}' }, colorBorderLayout: { light: '{colorNeutralGrey300}', dark: '{colorNeutralGrey750}' }, colorGapGlobalDrawer: { light: '{colorNeutralGrey250}', dark: '{colorNeutralGrey1000}' }, diff --git a/style-dictionary/one-theme/spacing.ts b/style-dictionary/one-theme/spacing.ts index b68c983af6..33c6dd362a 100644 --- a/style-dictionary/one-theme/spacing.ts +++ b/style-dictionary/one-theme/spacing.ts @@ -10,7 +10,7 @@ const tokens: StyleDictionary.SpacingDictionary = { spaceTabsVertical: '2px', spaceTokenVertical: '1px', spaceFieldVertical: { comfortable: '4px', compact: '2px' }, - spaceStatusIndicatorPaddingHorizontal: '4px', + spaceStatusIndicatorPaddingHorizontal: '2px', }; const expandedTokens: StyleDictionary.ExpandedDensityScopeDictionary = expandDensityDictionary(tokens); diff --git a/test/definitions/index.ts b/test/definitions/index.ts index 91e90a89f7..67970b0ab6 100644 --- a/test/definitions/index.ts +++ b/test/definitions/index.ts @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually. -// Each component has its own test definition file. -// Import them here manually to form the full test suite. import { TestSuite } from './types'; +export { TestSuite, TestDefinition, ScreenshotType, ScreenshotTestConfiguration } from './types'; import actionCard from './visual/action-card'; import alert from './visual/alert'; diff --git a/test/definitions/types.ts b/test/definitions/types.ts index 8cdca9996a..9f8a187c48 100644 --- a/test/definitions/types.ts +++ b/test/definitions/types.ts @@ -9,7 +9,7 @@ export interface ScreenshotTestConfiguration { // 'screenshotArea' — captures the .screenshot-area element on the page. // 'permutations' — captures the entire page and crops permutations out of it. -export type ScreenshotType = 'screenshotArea' | 'permutations'; +export type ScreenshotType = 'screenshotArea' | 'permutations' | 'viewport'; export interface TestDefinition { description: string; diff --git a/test/definitions/utils.ts b/test/definitions/utils.ts new file mode 100644 index 0000000000..121bae94a6 --- /dev/null +++ b/test/definitions/utils.ts @@ -0,0 +1,202 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { attachment } from 'allure-js-commons'; + +import { cropAndCompare, parsePng } from '@cloudscape-design/browser-test-tools/image-utils'; +import { ScreenshotPageObject, ScreenshotWithOffset } from '@cloudscape-design/browser-test-tools/page-objects'; + +import { TestDefinition, TestSuite } from './types'; + +const screenshotAreaSelector = '.screenshot-area'; +const defaultWindowSize = { width: 1600, height: 800 }; + +// NEW_HOST serves the PR's pages, OLD_HOST serves the baseline (main) pages. +const newHost = process.env.NEW_HOST || 'http://localhost:8080'; +const oldHost = process.env.OLD_HOST || 'http://localhost:8081'; + +interface RawCapture { + /** The raw base64-encoded PNG string from WebDriver (before decoding). */ + rawBase64: string; + /** The fully parsed screenshot with offset metadata (lazily resolved). */ + screenshot: () => Promise; +} + +function buildUrl(host: string, path: string, queryParams?: Record): string { + const params = new URLSearchParams(queryParams); + const qs = params.toString(); + return `${host}/#/${path}${qs ? `?${qs}` : ''}`; +} + +function isTestDefinition(item: TestDefinition | TestSuite): item is TestDefinition { + return (item as TestDefinition).path !== undefined; +} + +/** + * Attaches a visual comparison to the Allure report using the built-in image diff viewer. + * Uses the `application/vnd.allure.image.diff` content type which Allure renders + * as a side-by-side/overlay comparison widget. + */ +async function attachDiffImages( + result: { firstImage: Buffer; secondImage: Buffer; diffImage: Buffer | null }, + testName: string +): Promise { + const diffPayload = JSON.stringify({ + expected: `data:image/png;base64,${result.secondImage.toString('base64')}`, + actual: `data:image/png;base64,${result.firstImage.toString('base64')}`, + diff: result.diffImage ? `data:image/png;base64,${result.diffImage.toString('base64')}` : undefined, + }); + + await attachment(testName, diffPayload, { + contentType: 'application/vnd.allure.image.diff', + fileExtension: 'imagediff', + } as any); +} + +/** + * Registers all test suites with a single shared browser session per worker. + * This avoids the per-test session creation overhead. + */ +export function runTestSuites(suites: Array) { + let browser: WebdriverIO.Browser; + + beforeAll(async () => { + const { default: getBrowserCreator } = await import('@cloudscape-design/browser-test-tools/browser'); + const creator = getBrowserCreator('ChromeHeadlessIntegration', 'local', { + seleniumUrl: 'http://localhost:9515', + }); + browser = await creator.getBrowser({ width: defaultWindowSize.width, height: defaultWindowSize.height }); + }); + + afterAll(async () => { + await browser?.deleteSession(); + }); + + registerSuites(suites, () => browser); +} + +function registerSuites(suites: Array, getBrowser: () => WebdriverIO.Browser) { + for (const item of suites) { + if (isTestDefinition(item)) { + registerTest(item, getBrowser); + } else { + describe(item.description, () => { + registerSuites(item.tests, getBrowser); + }); + } + } +} + +/** + * Captures a screenshot and returns both the raw PNG base64 and the parsed result. + * Having the raw base64 allows a fast byte-equality check before expensive pixel decoding. + */ +async function captureRaw( + browser: WebdriverIO.Browser, + page: ScreenshotPageObject, + url: string, + testDef: TestDefinition, + windowSize: { width: number; height: number } | undefined +): Promise { + if (windowSize) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } + await browser.url(url); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); + } + + if (testDef.screenshotType === 'viewport') { + const { height, width } = await page.getViewportSize(); + const rawBase64 = await browser.takeScreenshot(); + return { + rawBase64, + screenshot: async () => { + const image = await parsePng(rawBase64); + return { image, offset: { top: 0, left: 0 }, height, width }; + }, + }; + } + + // screenshotArea / permutations — capture by selector with viewportOnly + const box = await page.getBoundingBox(screenshotAreaSelector); + const rawBase64 = await browser.takeScreenshot(); + return { + rawBase64, + screenshot: async () => { + const image = await parsePng(rawBase64); + return { image, offset: { top: box.top, left: box.left }, height: box.height, width: box.width }; + }, + }; +} + +function registerTest(testDef: TestDefinition, getBrowser: () => WebdriverIO.Browser) { + test(testDef.description, async () => { + const browser = getBrowser(); + const windowSize = testDef.configuration ? { ...defaultWindowSize, ...testDef.configuration } : undefined; + const page = new ScreenshotPageObject(browser); + + const newUrl = buildUrl(newHost, testDef.path, testDef.queryParams); + const oldUrl = buildUrl(oldHost, testDef.path, testDef.queryParams); + + const newCapture = await captureRaw(browser, page, newUrl, testDef, windowSize); + const oldCapture = await captureRaw(browser, page, oldUrl, testDef, windowSize); + + // Fast path: if the raw PNG bytes are identical, the images are guaranteed + // to be the same. This skips the expensive crop + pixelmatch decode path + // for the common case (no visual difference). + if (newCapture.rawBase64 === oldCapture.rawBase64) { + return; + } + + // Raw bytes differ — could be a real diff or just offset/crop differences. + // Fall through to full pixel comparison. + const result = await cropAndCompare(await newCapture.screenshot(), await oldCapture.screenshot()); + + if (result.diffPixels === 0) { + return; + } + + // For permutations pages, a screenshot-area diff might be a false positive + // caused by content extending beyond the viewport. Re-capture using the + // full capturePermutations strategy which resizes the window to fit all + // content and returns individual permutation crops for precise comparison. + if (testDef.screenshotType === 'permutations') { + if (windowSize) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } + await browser.url(newUrl); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); + } + const newPermutations = await page.capturePermutations(); + + if (windowSize) { + await browser.setWindowSize(windowSize.width, windowSize.height); + } + await browser.url(oldUrl); + await page.waitForVisible(screenshotAreaSelector); + if (testDef.setup) { + await testDef.setup(page); + } + const oldPermutations = await page.capturePermutations(); + + expect(newPermutations.length).toBe(oldPermutations.length); + for (let i = 0; i < newPermutations.length; i++) { + const permResult = await cropAndCompare(newPermutations[i], oldPermutations[i]); + if (permResult.diffPixels !== 0) { + await attachDiffImages(permResult, `${testDef.description} [permutation ${i}]`); + } + expect(permResult.diffPixels).toBe(0); + } + return; + } + + // Attach diff images to Allure report for visual inspection. + await attachDiffImages(result, testDef.description); + + // For screenshotArea and viewport types, the diff is a real failure. + expect(result.diffPixels).toBe(0); + }); +} diff --git a/test/definitions/visual/app-layout-content-paddings.ts b/test/definitions/visual/app-layout-content-paddings.ts new file mode 100644 index 0000000000..e76d055749 --- /dev/null +++ b/test/definitions/visual/app-layout-content-paddings.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Content paddings', + componentName: 'app-layout', + tests: [ + ...(['true', 'false'] as const).flatMap(toolsEnabled => + (['true', 'false'] as const).flatMap(splitPanelEnabled => + (['bottom', 'side'] as const).map(splitPanelPosition => ({ + description: `toolsEnabled=${toolsEnabled} splitPanelEnabled=${splitPanelEnabled} splitPanelPosition=${splitPanelPosition}`, + path: 'app-layout/with-split-panel', + screenshotType: 'viewport' as const, + queryParams: { toolsEnabled, splitPanelEnabled, splitPanelPosition }, + })) + ) + ), + ...[1500, 600].map(width => ({ + description: `with split panel and disabled content paddings - width=${width}`, + path: 'app-layout/disable-paddings-with-split-panel', + screenshotType: 'viewport' as const, + configuration: { width }, + queryParams: { splitPanelOpen: 'true', splitPanelPosition: 'side' }, + })), + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-drawers.ts b/test/definitions/visual/app-layout-drawers.ts new file mode 100644 index 0000000000..39c2089783 --- /dev/null +++ b/test/definitions/visual/app-layout-drawers.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { TestSuite } from '../types'; + +const wrapper = createWrapper(); + +const suite: TestSuite = { + description: 'Drawers', + componentName: 'app-layout', + tests: [ + { + description: 'with split panel', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + setup: async page => { + await page.click(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); + }, + }, + { + description: 'with tooltip on hover', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + setup: async page => { + await page.hoverElement(wrapper.findAppLayout().findDrawerTriggerById('pro-help').toSelector()); + }, + }, + { + description: 'with custom scrollable drawer content', + path: 'app-layout/with-drawers-scrollable', + screenshotType: 'viewport', + queryParams: { sideNavFill: 'false' }, + setup: async page => { + await page.click(wrapper.findAppLayout().findDrawerTriggerById('chat').toSelector()); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-flashbar.ts b/test/definitions/visual/app-layout-flashbar.ts new file mode 100644 index 0000000000..5849a5cbf5 --- /dev/null +++ b/test/definitions/visual/app-layout-flashbar.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Flashbar', + componentName: 'app-layout', + tests: [true, false].flatMap(disableContentPaddings => + [true, false].flatMap(stickyNotifications => + [true, false].flatMap(stickyTableHeader => + [true, false].map(stackNotifications => ({ + description: `disableContentPaddings: ${disableContentPaddings}, stickyNotifications: ${stickyNotifications}, stickyTableHeader: ${stickyTableHeader}, stackNotifications: ${stackNotifications}`, + path: 'app-layout/with-stacked-notifications-and-table', + screenshotType: 'screenshotArea' as const, + configuration: { width: 1280, height: 900 }, + setup: async (page: import('@cloudscape-design/browser-test-tools/page-objects').ScreenshotPageObject) => { + if (!disableContentPaddings) { + await page.click('[data-id="toggle-content-paddings"]'); + } + if (stickyNotifications) { + await page.click('[data-id="toggle-sticky-notifications"]'); + } + if (!stickyTableHeader) { + await page.click('[data-id="toggle-sticky-table-header"]'); + } + if (!stackNotifications) { + await page.click('[data-id="toggle-stack-items"]'); + } + await page.click('[data-id="add-notification"]'); + await page.click('[data-id="add-notification"]'); + }, + })) + ) + ) + ), +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-header.ts b/test/definitions/visual/app-layout-header.ts new file mode 100644 index 0000000000..3a128a266e --- /dev/null +++ b/test/definitions/visual/app-layout-header.ts @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Headers', + componentName: 'app-layout', + tests: [ + // ── Headers ─────────────────────────────────────────────────────────── + { + description: 'Headers', + tests: [600, 1280].flatMap(width => [ + { + description: `alignment with full-page table (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + { + description: `alignment with full-page table in sticky state (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'viewport' as const, + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `alignment with full-page table in sticky state with sticky notifications (${width}px)`, + path: 'app-layout/with-table', + screenshotType: 'viewport' as const, + configuration: { width }, + queryParams: { stickyNotifications: 'true' }, + setup: async page => { + await page.windowScrollTo({ top: 200 }); + }, + }, + { + description: `high contrast header variant in landing page (${width}px)`, + path: 'app-layout/landing-page', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + ]), + }, + + // ── High contrast header variant ────────────────────────────────────── + { + description: 'High contrast header variant', + tests: [ + ...[1400, 600].flatMap(width => [ + { + description: `with breadcrumbs and notifications at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true', hasNotifications: 'true', hasContainer: 'true' }, + } as TestDefinition, + { + description: `without overlap at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { disableOverlap: 'true' }, + } as TestDefinition, + { + description: `with content layout at ${width}px`, + path: 'app-layout/high-contrast-header-variant', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + queryParams: { + hasBreadcrumbs: 'true', + hasNotifications: 'true', + hasContainer: 'true', + hasContentLayout: 'true', + }, + } as TestDefinition, + ]), + ], + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-multi.ts b/test/definitions/visual/app-layout-multi.ts new file mode 100644 index 0000000000..babf9733cf --- /dev/null +++ b/test/definitions/visual/app-layout-multi.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Multiple instances', + componentName: 'app-layout', + tests: [600, 1280].flatMap(width => [ + { + description: `simple (${width}px)`, + path: 'app-layout/multi-layout-simple', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + { + description: `iframe (${width}px)`, + path: 'app-layout/multi-layout-iframe', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + ]), +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1280.ts b/test/definitions/visual/app-layout-responsive-1280.ts new file mode 100644 index 0000000000..d5fe823f54 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1280.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1280); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1400.ts b/test/definitions/visual/app-layout-responsive-1400.ts new file mode 100644 index 0000000000..1cb519005c --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1400.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1400); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-1920.ts b/test/definitions/visual/app-layout-responsive-1920.ts new file mode 100644 index 0000000000..88b8c6caf3 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-1920.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(1920); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-2540.ts b/test/definitions/visual/app-layout-responsive-2540.ts new file mode 100644 index 0000000000..9dd62c00db --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-2540.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(2540); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-600.ts b/test/definitions/visual/app-layout-responsive-600.ts new file mode 100644 index 0000000000..f6fd3665cc --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-600.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { responsiveTests } from './app-layout-responsive-tests'; + +const suite = responsiveTests(600); +export default suite; diff --git a/test/definitions/visual/app-layout-responsive-tests.ts b/test/definitions/visual/app-layout-responsive-tests.ts new file mode 100644 index 0000000000..f0ac91d121 --- /dev/null +++ b/test/definitions/visual/app-layout-responsive-tests.ts @@ -0,0 +1,159 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +/** + * Shared responsive test scenarios for app-layout. Each width gets its own + * definition file and test runner so that Jest sharding can parallelize them. + */ +export function responsiveTests(width: number): TestSuite { + return { + description: `AppLayout responsive width ${width}px`, + componentName: 'app-layout', + tests: [ + { + description: 'default', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'navigation drawer is open', + path: 'app-layout/with-wizard', + screenshotType: 'viewport', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Open navigation"]'); + }, + }, + { + description: 'wizard', + path: 'app-layout/with-wizard', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with wizard and table', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with wizard, table, and breadcrumbs', + path: 'app-layout/with-wizard-and-table', + screenshotType: 'viewport', + configuration: { width }, + queryParams: { hasBreadcrumbs: 'true' }, + }, + { + description: 'notifications', + path: 'app-layout/with-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'breadcrumbs', + path: 'app-layout/with-breadcrumbs', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'notifications and breadcrumbs', + path: 'app-layout/with-breadcrumbs-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'dashboard content type', + path: 'app-layout/dashboard-content-type', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'fixed header and footer', + path: 'app-layout/with-fixed-header-footer', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disableBodyScroll - empty', + path: 'app-layout/legacy-nav-empty', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with content', + path: 'app-layout/legacy-nav-scrollable', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disableBodyScroll - with split panel', + path: 'app-layout/legacy-nav-scrollable-with-split-panel', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disable paddings', + path: 'app-layout/disable-paddings', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'disable paddings with breadcrumbs', + path: 'app-layout/disable-paddings-breadcrumbs', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'sticky notifications', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'sticky notifications scrolled down', + path: 'app-layout/with-sticky-notifications', + screenshotType: 'viewport', + configuration: { width }, + setup: async page => { + await page.windowScrollTo({ top: 2000 }); + }, + }, + { + description: 'layout without panels', + path: 'app-layout/no-panels', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'layout without panels but with notifications', + path: 'app-layout/no-panels-with-notifications', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with drawers', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with empty drawers', + path: 'app-layout/with-drawers-empty', + screenshotType: 'viewport', + configuration: { width }, + }, + { + description: 'with open drawer', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + }, + }, + ], + }; +} diff --git a/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts new file mode 100644 index 0000000000..6a0b899686 --- /dev/null +++ b/test/definitions/visual/app-layout-sticky-table-header-split-panel.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Sticky header with split panel', + componentName: 'app-layout', + tests: [ + { + description: 'scrolling to bottom with closed split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'scrolling to bottom with closed split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (1 table row)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-1"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky with open split panel (30 table rows)', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.scrollToBottom('html'); + }, + }, + { + description: 'header stays sticky when mounting and unmounting a second table', + path: 'app-layout/with-sticky-table-and-split-panel', + screenshotType: 'viewport', + configuration: { width: 1280, height: 900 }, + setup: async page => { + await page.click('[data-testid="set-item-count-to-30"]'); + await page.click('aria/Open panel'); + await page.windowScrollTo({ top: 0 }); + await page.click('aria/Close panel'); + await page.scrollToBottom('html'); + }, + }, + // ── Max content width ───────────────────────────────────────────────── + { + description: 'maxContentWidth set to Number.MAX_VALUE', + path: 'app-layout/refresh-content-width', + screenshotType: 'viewport', + configuration: { width: 1280, height: 700 }, + setup: async page => { + await page.click('[data-test-id="button_width-number-max_value"]'); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-toolbar.ts b/test/definitions/visual/app-layout-toolbar.ts new file mode 100644 index 0000000000..fb174b0a71 --- /dev/null +++ b/test/definitions/visual/app-layout-toolbar.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Toolbar', + componentName: 'app-layout', + tests: [ + { + description: 'multiple nested instances (no breadcrumbs dedup)', + path: 'app-layout-toolbar/multi-layout-with-hidden-instances', + screenshotType: 'viewport', + }, + { + description: 'no toolbar', + path: 'app-layout-toolbar/without-toolbar', + screenshotType: 'viewport', + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout-z-index.ts b/test/definitions/visual/app-layout-z-index.ts new file mode 100644 index 0000000000..b0dacf8bc5 --- /dev/null +++ b/test/definitions/visual/app-layout-z-index.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestDefinition, TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Z-index', + componentName: 'app-layout', + tests: [ + ...[600, 1280].flatMap(width => [ + { + description: `button dropdown (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'viewport' as const, + configuration: { width }, + setup: async page => { + await page.click('button=Button dropdown'); + await page.click('[data-testid="2"]'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `select (${width}px)`, + path: 'app-layout/with-absolute-components', + screenshotType: 'viewport' as const, + configuration: { width, height: 800 }, + setup: async page => { + await page.click('[data-testid="select-demo"] button'); + await page.windowScrollTo({ top: 300 }); + }, + } as TestDefinition, + { + description: `split-panel and full-page table (${width}px)`, + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'viewport' as const, + configuration: { width }, + }, + ]), + { + description: 'split-panel and full-page with open navigation (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'viewport' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open navigation"]'); + }, + }, + { + description: 'split-panel and full-page with open tools (600px)', + path: 'app-layout/with-full-page-table-and-split-panel', + screenshotType: 'viewport' as const, + configuration: { width: 600 }, + setup: async page => { + await page.click('button[aria-label="Open tools"]'); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/app-layout.ts b/test/definitions/visual/app-layout.ts new file mode 100644 index 0000000000..683efdcc25 --- /dev/null +++ b/test/definitions/visual/app-layout.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'AppLayout', + componentName: 'app-layout', + tests: [ + { + description: 'no scrollbars at 320px', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 320 }, + }, + { + description: 'drawer buttons alignment', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 800 }, + setup: async page => { + await page.click('[aria-label="Open tools"]'); + }, + }, + { + description: 'disable paddings - navigation closed', + path: 'app-layout/disable-paddings', + screenshotType: 'viewport', + configuration: { width: 1280 }, + setup: async page => { + await page.click('[aria-label="Close navigation"]'); + }, + }, + { + description: 'panels stacking on mobile', + path: 'app-layout/all-panels-open', + screenshotType: 'viewport', + configuration: { width: 600 }, + }, + { + description: 'wrapping long words', + path: 'app-layout/text-wrap', + screenshotType: 'viewport', + }, + { + description: 'fill content area', + path: 'app-layout/fill-content-area', + screenshotType: 'viewport', + }, + { + description: 'with tools and drawers', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + queryParams: { hasTools: 'true' }, + }, + { + description: 'with open drawer and open side split panel', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width: 1400 }, + queryParams: { splitPanelPosition: 'side' }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + await page.click('[aria-label="Open panel"]'); + }, + }, + + // regression for https://github.com/cloudscape-design/components/pull/1612 + { + description: 'with open drawer and open side split panel after resize', + path: 'app-layout/with-drawers', + screenshotType: 'viewport', + configuration: { width: 1500 }, + queryParams: { splitPanelPosition: 'side' }, + setup: async page => { + await page.click('[aria-label="Security trigger button"]'); + await page.click('[aria-label="Open panel"]'); + await page.setWindowSize({ width: 1400, height: 800 }); + }, + }, + + // ── Transitions ─────────────────────────────────────────────────────── + { + description: 'transition from 400px to 1800px', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 400, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 1800, height: 400 }); + }, + }, + { + description: 'transition from 1800px to 400px', + path: 'app-layout/default', + screenshotType: 'viewport', + configuration: { width: 1800, height: 400 }, + setup: async page => { + await page.setWindowSize({ width: 400, height: 400 }); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/area-chart.ts b/test/definitions/visual/area-chart.ts new file mode 100644 index 0000000000..cfa0b5e47b --- /dev/null +++ b/test/definitions/visual/area-chart.ts @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const TEST_CHART_FILTER_TRIGGER = '#linear-latency-chart button'; +const TEST_CHART_TOOLTIP_HEADER = '#linear-latency-chart h2'; + +const suite: TestSuite = { + description: 'Area chart', + componentName: 'area-chart', + tests: [ + { + description: 'permutations', + path: 'area-chart/permutations', + screenshotType: 'permutations', + }, + { + description: 'fit-height', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + }, + { + description: 'fit-height no filter, no legend', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + queryParams: { hideFilter: 'true', hideLegend: 'true' }, + }, + { + description: 'fit-height, no legend', + path: 'area-chart/fit-height', + screenshotType: 'screenshotArea', + queryParams: { hideLegend: 'true' }, + }, + { + description: 'chart plot has a focus outline', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + }, + }, + { + description: 'can navigate along X axis highlighting all series with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight', 'ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'can navigate a specific series with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowDown']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'selects correct series when navigated back from legend', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.keys(['Tab']); + await page.keys(['Tab']); + await page.keys(['ArrowRight']); + await page.keys(['Shift', 'Tab']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + { + description: 'can pin popover for all data points at a given X coordinate with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + await page.keys(['Enter']); + await page.waitForVisible('[aria-label="Dismiss"]'); + }, + }, + { + description: 'can pin popover for a point in a specific series with keyboard', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.click(TEST_CHART_FILTER_TRIGGER); + await page.keys(['Escape']); + await page.focusNextElement(); + await page.keys(['ArrowRight']); + await page.keys(['ArrowDown']); + await page.keys(['ArrowRight']); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + await page.keys(['Enter']); + await page.waitForVisible('[aria-label="Dismiss"]'); + }, + }, + { + description: 'shows popover on hover', + path: 'area-chart/test', + screenshotType: 'viewport', + configuration: { width: 800, height: 800 }, + setup: async page => { + await page.hoverElement('[aria-label="Linear latency chart"]', 200, 50); + await page.waitForVisible(TEST_CHART_TOOLTIP_HEADER); + }, + }, + ], +}; + +export default suite; diff --git a/test/definitions/visual/attribute-editor.ts b/test/definitions/visual/attribute-editor.ts new file mode 100644 index 0000000000..33da3fad86 --- /dev/null +++ b/test/definitions/visual/attribute-editor.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Attribute Editor', + componentName: 'attribute-editor', + tests: [360, 768, 992].flatMap(width => [ + { + description: `permutations at ${width}px`, + path: 'attribute-editor/permutations', + screenshotType: 'permutations' as const, + configuration: { width }, + }, + { + description: `customizable-footer at ${width}px`, + path: 'attribute-editor/customizable-footer', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + { + description: `with long select at ${width}px`, + path: 'attribute-editor/select-with-long-value', + screenshotType: 'screenshotArea' as const, + configuration: { width }, + }, + ]), +}; + +export default suite; diff --git a/test/definitions/visual/autosuggest.ts b/test/definitions/visual/autosuggest.ts new file mode 100644 index 0000000000..92f4d46897 --- /dev/null +++ b/test/definitions/visual/autosuggest.ts @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { TestDefinition, TestSuite } from '../types'; + +const wrapper = createWrapper(); + +const suite: TestSuite = { + description: 'Autosuggest', + componentName: 'autosuggest', + tests: [ + { + description: 'permutations', + path: 'autosuggest/permutations', + screenshotType: 'permutations', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'permutations for async properties', + path: 'autosuggest/permutations-async', + screenshotType: 'permutations', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Displays options with groups correctly', + path: 'autosuggest/scenarios', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Correctly displays dropdown regions', + path: 'autosuggest/regions-scenarios', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click('input'); + }, + }, + { + description: 'Long virtual list - navigate to last item', + path: 'autosuggest/virtual-scroll', + screenshotType: 'screenshotArea', + setup: async page => { + await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); + await page.keys(['ArrowUp']); + }, + }, + ...[true, false].map( + virtualScroll => + ({ + description: `with custom renderOption (virtualScroll=${virtualScroll})`, + path: 'autosuggest/custom-render-option', + screenshotType: 'screenshotArea' as const, + queryParams: { virtualScroll: String(virtualScroll) }, + setup: async page => { + await page.click(wrapper.findAutosuggest().findNativeInput().toSelector()); + await page.keys(['ArrowDown']); + }, + }) as TestDefinition + ), + ], +}; + +export default suite; diff --git a/test/definitions/visual/badge.ts b/test/definitions/visual/badge.ts new file mode 100644 index 0000000000..7d405ed843 --- /dev/null +++ b/test/definitions/visual/badge.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TestSuite } from '../types'; + +const suite: TestSuite = { + description: 'Badge', + componentName: 'badge', + tests: [ + { + description: 'permutation page', + path: 'badge/permutations', + screenshotType: 'permutations', + }, + { + description: 'style custom page', + path: 'badge/style-custom-types', + screenshotType: 'screenshotArea', + }, + ], +}; + +export default suite; diff --git a/test/visual/action-card.test.ts b/test/visual/action-card.test.ts new file mode 100644 index 0000000000..9e62b89ebf --- /dev/null +++ b/test/visual/action-card.test.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually. +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/action-card'; + +runTestSuites([suite]); diff --git a/test/visual/alert.test.ts b/test/visual/alert.test.ts new file mode 100644 index 0000000000..95af4acc78 --- /dev/null +++ b/test/visual/alert.test.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Auto-generated by build-tools/visual/generate-tests.js — do not edit manually. +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/alert'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-content-paddings.test.ts b/test/visual/app-layout-content-paddings.test.ts new file mode 100644 index 0000000000..e566d79817 --- /dev/null +++ b/test/visual/app-layout-content-paddings.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-content-paddings'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-drawers.test.ts b/test/visual/app-layout-drawers.test.ts new file mode 100644 index 0000000000..c66454010d --- /dev/null +++ b/test/visual/app-layout-drawers.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-drawers'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-flashbar.test.ts b/test/visual/app-layout-flashbar.test.ts new file mode 100644 index 0000000000..333642e5f3 --- /dev/null +++ b/test/visual/app-layout-flashbar.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-flashbar'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-header.test.ts b/test/visual/app-layout-header.test.ts new file mode 100644 index 0000000000..682f71ffe2 --- /dev/null +++ b/test/visual/app-layout-header.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-header'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-multi.test.ts b/test/visual/app-layout-multi.test.ts new file mode 100644 index 0000000000..244019c8fb --- /dev/null +++ b/test/visual/app-layout-multi.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-multi'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1280.test.ts b/test/visual/app-layout-responsive-1280.test.ts new file mode 100644 index 0000000000..0324b2bf39 --- /dev/null +++ b/test/visual/app-layout-responsive-1280.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1280'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1400.test.ts b/test/visual/app-layout-responsive-1400.test.ts new file mode 100644 index 0000000000..745372c34e --- /dev/null +++ b/test/visual/app-layout-responsive-1400.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1400'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-1920.test.ts b/test/visual/app-layout-responsive-1920.test.ts new file mode 100644 index 0000000000..bee5fc8763 --- /dev/null +++ b/test/visual/app-layout-responsive-1920.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-1920'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-2540.test.ts b/test/visual/app-layout-responsive-2540.test.ts new file mode 100644 index 0000000000..48bc8580da --- /dev/null +++ b/test/visual/app-layout-responsive-2540.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-2540'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-responsive-600.test.ts b/test/visual/app-layout-responsive-600.test.ts new file mode 100644 index 0000000000..cd9243cb32 --- /dev/null +++ b/test/visual/app-layout-responsive-600.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-responsive-600'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-sticky-table-header-split-panel.test.ts b/test/visual/app-layout-sticky-table-header-split-panel.test.ts new file mode 100644 index 0000000000..c1ad3016a1 --- /dev/null +++ b/test/visual/app-layout-sticky-table-header-split-panel.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-sticky-table-header-split-panel'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-toolbar.test.ts b/test/visual/app-layout-toolbar.test.ts new file mode 100644 index 0000000000..398d6386f8 --- /dev/null +++ b/test/visual/app-layout-toolbar.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-toolbar'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout-z-index.test.ts b/test/visual/app-layout-z-index.test.ts new file mode 100644 index 0000000000..5f69f77b71 --- /dev/null +++ b/test/visual/app-layout-z-index.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout-z-index'; + +runTestSuites([suite]); diff --git a/test/visual/app-layout.test.ts b/test/visual/app-layout.test.ts new file mode 100644 index 0000000000..21c3a6ce25 --- /dev/null +++ b/test/visual/app-layout.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/app-layout'; + +runTestSuites([suite]); diff --git a/test/visual/area-chart.test.ts b/test/visual/area-chart.test.ts new file mode 100644 index 0000000000..4ffc4e042e --- /dev/null +++ b/test/visual/area-chart.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/area-chart'; + +runTestSuites([suite]); diff --git a/test/visual/attribute-editor.test.ts b/test/visual/attribute-editor.test.ts new file mode 100644 index 0000000000..6a57853125 --- /dev/null +++ b/test/visual/attribute-editor.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/attribute-editor'; + +runTestSuites([suite]); diff --git a/test/visual/autosuggest.test.ts b/test/visual/autosuggest.test.ts new file mode 100644 index 0000000000..d88cad7117 --- /dev/null +++ b/test/visual/autosuggest.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/autosuggest'; + +runTestSuites([suite]); diff --git a/test/visual/badge.test.ts b/test/visual/badge.test.ts new file mode 100644 index 0000000000..1163052443 --- /dev/null +++ b/test/visual/badge.test.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { runTestSuites } from '../definitions/utils'; +import suite from '../definitions/visual/badge'; + +runTestSuites([suite]); diff --git a/tsconfig.test-definitions.json b/tsconfig.test-definitions.json index 30e82b4047..3f5961a78b 100644 --- a/tsconfig.test-definitions.json +++ b/tsconfig.test-definitions.json @@ -16,5 +16,5 @@ "skipLibCheck": true }, "include": ["test/definitions", "test/types.ts"], - "exclude": [] + "exclude": ["test/definitions/utils.ts"] } diff --git a/tsconfig.visual.json b/tsconfig.visual.json new file mode 100644 index 0000000000..be61d962ef --- /dev/null +++ b/tsconfig.visual.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.integ.json", + "include": ["test/definitions/utils.ts", "test/visual/**/*.test.ts", "types"] +}