From 2637d10d80ead6473f2e7c74dd2754c938b4d788 Mon Sep 17 00:00:00 2001 From: Heloise Lui <71858203+heloiselui@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:40:20 +0100 Subject: [PATCH 001/266] fix(code-connect): update ProgressBar mappings (#22105) * fix(code-connect): update ProgressBar mappings and type prop error * Apply suggestion from @adamalston Co-authored-by: Adam Alston --------- Co-authored-by: Taylor Jones Co-authored-by: Adam Alston --- .../ProgressBar/ProgressBar.figma.tsx | 169 +++++++++--------- 1 file changed, 83 insertions(+), 86 deletions(-) diff --git a/packages/react/code-connect/ProgressBar/ProgressBar.figma.tsx b/packages/react/code-connect/ProgressBar/ProgressBar.figma.tsx index 2ced4bc099d6..57f74116db91 100644 --- a/packages/react/code-connect/ProgressBar/ProgressBar.figma.tsx +++ b/packages/react/code-connect/ProgressBar/ProgressBar.figma.tsx @@ -1,96 +1,93 @@ /** - * Copyright IBM Corp. 2016, 2024 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -// import React from 'react'; -// import { ProgressBar } from '@carbon/react'; -// import figma from '@figma/code-connect'; +import React from 'react'; +import { ProgressBar } from '@carbon/react'; +import figma from '@figma/code-connect'; -// TODO: Re-enable these components when Code Connect validation bug is fixed -// Properties exist in Figma but fail validation despite correct names: -// https://github.com/carbon-design-system/carbon/issues/20089 +const sharedProgressBarProps = { + label: figma.string('Label text'), + value: figma.enum('Progress', { + '0%': 0, + '25%': 25, + '50%': 50, + '75%': 75, + }), + type: figma.enum('Alignment', { + Inline: 'inline', + Indent: 'indented', + }), + status: figma.enum('Status', { + Active: 'active', + Success: 'finished', + Error: 'error', + }), + size: figma.enum('Size', { + Big: 'big', + Small: 'small', + }), + helperText: figma.string('Helper text'), + helperTextError: figma.string('Error text'), + helperTextSuccess: figma.string('Success text'), +}; -// const sharedProgressBarProps = { -// label: figma.string('Label text'), -// value: figma.enum('Progress', { -// '0%': 0, -// '25%': 25, -// '50%': 50, -// '75%': 75, -// }), -// type: figma.enum('Alignment', { -// Inline: 'inline', -// Indent: 'indent', -// }), -// status: figma.enum('State', { -// Active: 'active', -// Success: 'finished', -// Error: 'error', -// }), -// size: figma.enum('Size', { -// Big: 'big', -// Small: 'small', -// }), -// helperText: figma.string('Helper text'), -// helperTextError: figma.string('Error text'), -// helperTextSuccess: figma.string('Success text'), -// }; +figma.connect( + ProgressBar, + 'https://www.figma.com/design/YAnB1jKx0yCUL29j6uSLpg/(v11)-All-themes---Carbon-Design-System?node-id=9506-402924&t=j280IIQF1o3iLkV2-4', + { + variant: { Status: 'Active' }, + props: sharedProgressBarProps, + example: ({ label, value, helperText, status, type, size }) => ( + + ), + } +); -// figma.connect( -// ProgressBar, -// 'https://www.figma.com/design/YAnB1jKx0yCUL29j6uSLpg/(v11)-All-themes---Carbon-Design-System?node-id=9506-402924&t=j280IIQF1o3iLkV2-4', -// { -// props: sharedProgressBarProps, -// example: ({ label, value, helperText, status, type, size }) => ( -// -// ), -// } -// ); +figma.connect( + ProgressBar, + 'https://www.figma.com/design/YAnB1jKx0yCUL29j6uSLpg/(v11)-All-themes---Carbon-Design-System?node-id=9506-402924&t=j280IIQF1o3iLkV2-4', + { + variant: { Status: 'Error' }, + props: sharedProgressBarProps, + example: ({ label, value, status, type, size, helperTextError }) => ( + + ), + } +); -// figma.connect( -// ProgressBar, -// 'https://www.figma.com/design/YAnB1jKx0yCUL29j6uSLpg/(v11)-All-themes---Carbon-Design-System?node-id=9506-402924&t=j280IIQF1o3iLkV2-4', -// { -// variant: { State: 'Error' }, -// props: sharedProgressBarProps, -// example: ({ label, value, status, type, size, helperTextError }) => ( -// -// ), -// } -// ); - -// figma.connect( -// ProgressBar, -// 'https://www.figma.com/design/YAnB1jKx0yCUL29j6uSLpg/(v11)-All-themes---Carbon-Design-System?node-id=9506-402924&t=j280IIQF1o3iLkV2-4', -// { -// variant: { State: 'Success' }, -// props: sharedProgressBarProps, -// example: ({ label, value, status, type, size, helperTextSuccess }) => ( -// -// ), -// } -// ); +figma.connect( + ProgressBar, + 'https://www.figma.com/design/YAnB1jKx0yCUL29j6uSLpg/(v11)-All-themes---Carbon-Design-System?node-id=9506-402924&t=j280IIQF1o3iLkV2-4', + { + variant: { Status: 'Success' }, + props: sharedProgressBarProps, + example: ({ label, value, status, type, size, helperTextSuccess }) => ( + + ), + } +); From 585f1918a554d2a979a985b3efb68ca096840a42 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:50:23 +0100 Subject: [PATCH 002/266] chore(deps): update dependency stylelint to v17 (#22079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update dependency stylelint to v17 * chore: yarn dedupe * chore: migrate stylelint v17 items --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: “heloiselui” Co-authored-by: Taylor Jones --- config/stylelint-config-carbon/index.js | 10 +- config/stylelint-config-carbon/package.json | 6 +- package.json | 4 +- packages/web-components/package.json | 2 +- yarn.lock | 478 +++++++++++++------- 5 files changed, 335 insertions(+), 165 deletions(-) diff --git a/config/stylelint-config-carbon/index.js b/config/stylelint-config-carbon/index.js index 320ab61dfd5d..7c8f187c97b2 100644 --- a/config/stylelint-config-carbon/index.js +++ b/config/stylelint-config-carbon/index.js @@ -13,10 +13,10 @@ export default { 'stylelint-no-unsupported-browser-features', 'stylelint-config-idiomatic-order', 'stylelint-config-standard-scss', - './rules/possible-errors', - './rules/limit-language-features', - './rules/stylistic-issues', - './plugins/a11y', - './plugins/scss', + './rules/possible-errors.js', + './rules/limit-language-features.js', + './rules/stylistic-issues.js', + './plugins/a11y.js', + './plugins/scss.js', ], }; diff --git a/config/stylelint-config-carbon/package.json b/config/stylelint-config-carbon/package.json index 0ae8a3ef1ba9..ec3bdd5a2ce9 100644 --- a/config/stylelint-config-carbon/package.json +++ b/config/stylelint-config-carbon/package.json @@ -25,10 +25,10 @@ "provenance": true }, "peerDependencies": { - "stylelint": "^16.0.0" + "stylelint": "^17.0.0" }, "dependencies": { - "@double-great/stylelint-a11y": "^3.0.2", + "@double-great/stylelint-a11y": "^3.4.10", "stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-prettier": "^9.0.3", "stylelint-config-standard": "^36.0.0", @@ -41,6 +41,6 @@ }, "devDependencies": { "prettier": "^3.3.3", - "stylelint": "^16.0.0" + "stylelint": "^17.0.0" } } diff --git a/package.json b/package.json index ea7df82be65f..6965c08df4bb 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "rimraf": "^6.0.1", - "stylelint": "^16.0.0", + "stylelint": "^17.0.0", "typescript-eslint": "^8.34.0" }, "commitlint": { @@ -155,7 +155,7 @@ }, "stylelint": { "extends": [ - "./config/stylelint-config-carbon" + "./config/stylelint-config-carbon/index.js" ] }, "packageManager": "yarn@4.10.3" diff --git a/packages/web-components/package.json b/packages/web-components/package.json index 5cf0012af4dd..8b7fcc7026a5 100644 --- a/packages/web-components/package.json +++ b/packages/web-components/package.json @@ -113,7 +113,7 @@ "typings": "es/index.d.ts", "stylelint": { "extends": [ - "../../config/stylelint-config-carbon" + "../../config/stylelint-config-carbon/index.js" ], "overrides": [ { diff --git a/yarn.lock b/yarn.lock index f91eb654895e..a1e6aa595bd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1636,6 +1636,28 @@ __metadata: languageName: node linkType: hard +"@cacheable/memory@npm:^2.0.8": + version: 2.0.8 + resolution: "@cacheable/memory@npm:2.0.8" + dependencies: + "@cacheable/utils": "npm:^2.4.0" + "@keyv/bigmap": "npm:^1.3.1" + hookified: "npm:^1.15.1" + keyv: "npm:^5.6.0" + checksum: 10/686bb460c1e6b5a1be6ecd54b5f0c204d82210b083aa177d467166b83b5deab5f79e85f5dd2681b4da76b2249b52a80a75daaf679558be2cefb72f8e25adecb2 + languageName: node + linkType: hard + +"@cacheable/utils@npm:^2.4.0": + version: 2.4.1 + resolution: "@cacheable/utils@npm:2.4.1" + dependencies: + hashery: "npm:^1.5.1" + keyv: "npm:^5.6.0" + checksum: 10/5fbea553fd53c8ffe4f3a2e746ce6ecf76f3d90f599b49f2c677c3922e4f643a40f3e90a13727b6e866b7ab3c8e267368d98b54d88d08181050a8d066997cfe7 + languageName: node + linkType: hard + "@carbon/actions-add-review-labels@workspace:actions/add-review-labels": version: 0.0.0-use.local resolution: "@carbon/actions-add-review-labels@workspace:actions/add-review-labels" @@ -2393,6 +2415,16 @@ __metadata: languageName: node linkType: hard +"@csstools/css-calc@npm:^3.1.1": + version: 3.2.0 + resolution: "@csstools/css-calc@npm:3.2.0" + peerDependencies: + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10/7eec51a21945a74aa6a407d1e6290d0f4c5d01829a42c01a56ce2055216398540cc3120837b15a0db38601bcb40cf97f1d991fefb3ee9d00d9cec03d67beba4c + languageName: node + linkType: hard + "@csstools/css-color-parser@npm:^3.0.9": version: 3.0.10 resolution: "@csstools/css-color-parser@npm:3.0.10" @@ -2406,7 +2438,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-parser-algorithms@npm:^3.0.1, @csstools/css-parser-algorithms@npm:^3.0.4": +"@csstools/css-parser-algorithms@npm:^3.0.4": version: 3.0.5 resolution: "@csstools/css-parser-algorithms@npm:3.0.5" peerDependencies: @@ -2415,47 +2447,77 @@ __metadata: languageName: node linkType: hard -"@csstools/css-tokenizer@npm:^3.0.1, @csstools/css-tokenizer@npm:^3.0.3": +"@csstools/css-parser-algorithms@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-parser-algorithms@npm:4.0.0" + peerDependencies: + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10/000f3ba55f440d9fbece50714e88f9d4479e2bde9e0568333492663f2c6034dc31d0b9ef5d66d196c76be58eea145ca6920aa8bdfdcc6361894806c21b5402d0 + languageName: node + linkType: hard + +"@csstools/css-syntax-patches-for-csstree@npm:^1.1.2": + version: 1.1.3 + resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.3" + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + checksum: 10/1c91dc03b64ca913eed5064ca0e434da1c0be8def6ce20f932d1db10f9b478ac3830c99a033b0edf75954cf9164c7c267b220ed9faffbc3342bf320870c3bb4b + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^3.0.3": version: 3.0.4 resolution: "@csstools/css-tokenizer@npm:3.0.4" checksum: 10/eb6c84c086312f6bb8758dfe2c85addd7475b0927333c5e39a4d59fb210b9810f8c346972046f95e60a721329cffe98895abe451e51de753ad1ca7a8c24ec65f languageName: node linkType: hard -"@csstools/media-query-list-parser@npm:^3.0.1": - version: 3.0.1 - resolution: "@csstools/media-query-list-parser@npm:3.0.1" +"@csstools/css-tokenizer@npm:^4.0.0": + version: 4.0.0 + resolution: "@csstools/css-tokenizer@npm:4.0.0" + checksum: 10/074ade1a7fc3410b813c8982cf07a56814a55af509c533c2dc80d5689f34d2ba38219f8fa78fa36ea2adc6c5db506ea3c3a667388dda1b59b1281fdd2a2d1e28 + languageName: node + linkType: hard + +"@csstools/media-query-list-parser@npm:^5.0.0": + version: 5.0.0 + resolution: "@csstools/media-query-list-parser@npm:5.0.0" peerDependencies: - "@csstools/css-parser-algorithms": ^3.0.1 - "@csstools/css-tokenizer": ^3.0.1 - checksum: 10/794344c67b126ad93d516ab3f01254d44cfa794c3401e34e8cc62ddc7fc13c9ab6c76cb517b643dbda47b57f2eb578c6a11c4a9a4b516d88e260a4016b64ce7f + "@csstools/css-parser-algorithms": ^4.0.0 + "@csstools/css-tokenizer": ^4.0.0 + checksum: 10/16c51af8032b806abb9b636dd9c7eeac825f7e53da045e405f5c3c8c046ec68098d5e0d1a488b3941a5e0627e7f9a9afe1e08ebfcad74473c08825a64915deab languageName: node linkType: hard -"@csstools/selector-specificity@npm:^4.0.0": +"@csstools/selector-resolve-nested@npm:^4.0.0": version: 4.0.0 - resolution: "@csstools/selector-specificity@npm:4.0.0" + resolution: "@csstools/selector-resolve-nested@npm:4.0.0" peerDependencies: - postcss-selector-parser: ^6.1.0 - checksum: 10/7076c1d8af0fba94f06718f87fba5bfea583f39089efa906ae38b5ecd6912d3d5865f7047a871ac524b1057e4c970622b2ade456b90d69fb9393902250057994 + postcss-selector-parser: ^7.1.1 + checksum: 10/eebdec8f3a8dbb81afa5c76b0568bb1a0b6dfa7c2c9434dbc4cd86cc2244db1cca2392127fca0d54653050688eb1da18eb7bfecedf5c1f9689957c4c44617f32 languageName: node linkType: hard -"@double-great/stylelint-a11y@npm:^3.0.2": - version: 3.0.2 - resolution: "@double-great/stylelint-a11y@npm:3.0.2" - dependencies: - postcss: "npm:^8.4.33" +"@csstools/selector-specificity@npm:^6.0.0": + version: 6.0.0 + resolution: "@csstools/selector-specificity@npm:6.0.0" peerDependencies: - stylelint: ">=16.0.0" - checksum: 10/5abfe4f251e67eb9ef9d8e96a1fe9ae9c620e3f8279d5aab79269bd4d2ce86c578185072822346dd118e9cd915b3a11ada0980e26b6b6c4547111be2152f7fdb + postcss-selector-parser: ^7.1.1 + checksum: 10/56790acc910b516df87bf6fd43389a88da1922ffb004de4d6099559ece48c88a8185e29439c570d1102a414bbecbb34e33f1aeace76a63e9f72e0794517cf7d7 languageName: node linkType: hard -"@dual-bundle/import-meta-resolve@npm:^4.1.0": - version: 4.1.0 - resolution: "@dual-bundle/import-meta-resolve@npm:4.1.0" - checksum: 10/a69d804a8e8e93732ac5525f85b9366ae78ec60fa02f0d5b4f2d625e18b355ba02502cdaef616ab1eac4450b966d2a398b59577a17483e4f8a350d062357bdf4 +"@double-great/stylelint-a11y@npm:^3.4.10": + version: 3.4.10 + resolution: "@double-great/stylelint-a11y@npm:3.4.10" + dependencies: + postcss: "npm:^8.5.10" + peerDependencies: + stylelint: ">=16.0.0" + checksum: 10/ac0635a0980d1b0737c510a52e6ca5466e814964c9ad5a83a6568b3c41bab1d8187377bccefab9e78ccbd1145133f2909694aea63fc69a9abd0993f79c27bb56 languageName: node linkType: hard @@ -4386,6 +4448,25 @@ __metadata: languageName: node linkType: hard +"@keyv/bigmap@npm:^1.3.1": + version: 1.3.1 + resolution: "@keyv/bigmap@npm:1.3.1" + dependencies: + hashery: "npm:^1.4.0" + hookified: "npm:^1.15.0" + peerDependencies: + keyv: ^5.6.0 + checksum: 10/9745786a94729117460b5399a964ec151e94a89db0e5ed573419c4c620f0595f05d7ad87d596bb7e212a4f3ad2335271694fddcfaa8400013f7b64e11669a884 + languageName: node + linkType: hard + +"@keyv/serialize@npm:^1.1.1": + version: 1.1.1 + resolution: "@keyv/serialize@npm:1.1.1" + checksum: 10/e3b2cb1377863342acedd5ff785af3e69269bee9b44707c617d1c8bc14eeb5ac763159d6455903ffe92f143c2238e1e783c4f113f9c8910eacccf172894472da + languageName: node + linkType: hard + "@lerna/create@npm:9.0.1": version: 9.0.1 resolution: "@lerna/create@npm:9.0.1" @@ -8187,10 +8268,10 @@ __metadata: languageName: node linkType: hard -"ansi-regex@npm:^6.0.1": - version: 6.0.1 - resolution: "ansi-regex@npm:6.0.1" - checksum: 10/1ff8b7667cded1de4fa2c9ae283e979fc87036864317da86a2e546725f96406746411d0d85e87a2d12fa5abd715d90006de7fa4fa0477c92321ad3b4c7d4e169 +"ansi-regex@npm:^6.2.2": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 10/9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f languageName: node linkType: hard @@ -8863,13 +8944,6 @@ __metadata: languageName: node linkType: hard -"balanced-match@npm:^2.0.0": - version: 2.0.0 - resolution: "balanced-match@npm:2.0.0" - checksum: 10/9a5caad6a292c5df164cc6d0c38e0eedf9a1413f42e5fece733640949d74d0052cfa9587c1a1681f772147fb79be495121325a649526957fd75b3a216d1fbc68 - languageName: node - linkType: hard - "bare-events@npm:^2.0.0, bare-events@npm:^2.2.0": version: 2.5.4 resolution: "bare-events@npm:2.5.4" @@ -9270,6 +9344,19 @@ __metadata: languageName: node linkType: hard +"cacheable@npm:^2.3.4": + version: 2.3.4 + resolution: "cacheable@npm:2.3.4" + dependencies: + "@cacheable/memory": "npm:^2.0.8" + "@cacheable/utils": "npm:^2.4.0" + hookified: "npm:^1.15.0" + keyv: "npm:^5.6.0" + qified: "npm:^0.9.0" + checksum: 10/b711e7fd4d485f77966f1b34a9f08da496db6de65ceb0542c9963109e3866b030ed9da10eb8a133cef0122384137d6fb7ab5311a408ec12d02de19030f390dfa + languageName: node + linkType: hard + "call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" @@ -9448,7 +9535,7 @@ __metadata: react: "npm:^19.2.3" react-dom: "npm:^19.2.3" rimraf: "npm:^6.0.1" - stylelint: "npm:^16.0.0" + stylelint: "npm:^17.0.0" typescript-eslint: "npm:^8.34.0" languageName: unknown linkType: soft @@ -10470,7 +10557,7 @@ __metadata: languageName: node linkType: hard -"cosmiconfig@npm:9.0.0, cosmiconfig@npm:^9.0.0": +"cosmiconfig@npm:9.0.0": version: 9.0.0 resolution: "cosmiconfig@npm:9.0.0" dependencies: @@ -10487,6 +10574,23 @@ __metadata: languageName: node linkType: hard +"cosmiconfig@npm:^9.0.0, cosmiconfig@npm:^9.0.1": + version: 9.0.1 + resolution: "cosmiconfig@npm:9.0.1" + dependencies: + env-paths: "npm:^2.2.1" + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/89fcac84d062f0710091bb2d6a6175bcde22f5448877db9c43429694408191d3d4e215193b3ac4d54f7f89ef188d55cd481c7a2295b0dc572e65b528bf6fec01 + languageName: node + linkType: hard + "crc-32@npm:^1.2.0": version: 1.2.2 resolution: "crc-32@npm:1.2.2" @@ -10559,10 +10663,10 @@ __metadata: languageName: node linkType: hard -"css-functions-list@npm:^3.2.3": - version: 3.2.3 - resolution: "css-functions-list@npm:3.2.3" - checksum: 10/25f12fb0ef1384b1cf45a6e7e0afd596a19bee90b90316d9e50f7820888f4a8f265be7a6a96b10a5c81e403bd7a5ff8010fa936144f84959d9d91c9350cda0d4 +"css-functions-list@npm:^3.3.3": + version: 3.3.3 + resolution: "css-functions-list@npm:3.3.3" + checksum: 10/867751049941ca5d56f91695a0ba7f45947000e9cd6b80432521779922d705838cb1d6621a53135de4b639d12c8c9b5128da72ccc956a0df8eac54e89601014a languageName: node linkType: hard @@ -10589,13 +10693,13 @@ __metadata: languageName: node linkType: hard -"css-tree@npm:^3.0.0, css-tree@npm:^3.0.1": - version: 3.1.0 - resolution: "css-tree@npm:3.1.0" +"css-tree@npm:^3.0.0, css-tree@npm:^3.0.1, css-tree@npm:^3.2.1": + version: 3.2.1 + resolution: "css-tree@npm:3.2.1" dependencies: - mdn-data: "npm:2.12.2" - source-map-js: "npm:^1.0.1" - checksum: 10/e8c5c8e98e3aa4a620fda0b813ce57ccf99281652bf9d23e5cdfc9961c9a93a6769941f9a92e31e65d90f446f42fa83879ab0185206dc7a178d9f656d0913e14 + mdn-data: "npm:2.27.1" + source-map-js: "npm:^1.2.1" + checksum: 10/9945b387bdec756738c34d64b8287f05ca6645f51d1c8abaaa5822ec3e74533604103aaad164b8100afd8495e92120be7c1c6afbe5be89f867acc5b456ddd79c languageName: node linkType: hard @@ -10838,7 +10942,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.1, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -13042,6 +13146,15 @@ __metadata: languageName: node linkType: hard +"file-entry-cache@npm:^11.1.2": + version: 11.1.2 + resolution: "file-entry-cache@npm:11.1.2" + dependencies: + flat-cache: "npm:^6.1.20" + checksum: 10/608a988db343f3ddcbf96ec93697daae2bbde2976763960b979c55de64d5ae3779c014d5308dbea5bfb42386c3e492b117d8715824b74079efcc1f5d27b8836c + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -13051,15 +13164,6 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^9.1.0": - version: 9.1.0 - resolution: "file-entry-cache@npm:9.1.0" - dependencies: - flat-cache: "npm:^5.0.0" - checksum: 10/fd67a9552f272ac4a1731c545e1350bd135e208659144cc5311baac6b8bbf55da7c8c3a0bf25c71ed78eff2bdd26d2a3a8f9ba3d8bec968fe8d1eeba6ab14a96 - languageName: node - linkType: hard - "filelist@npm:^1.0.1": version: 1.0.2 resolution: "filelist@npm:1.0.2" @@ -13157,13 +13261,14 @@ __metadata: languageName: node linkType: hard -"flat-cache@npm:^5.0.0": - version: 5.0.0 - resolution: "flat-cache@npm:5.0.0" +"flat-cache@npm:^6.1.20": + version: 6.1.22 + resolution: "flat-cache@npm:6.1.22" dependencies: - flatted: "npm:^3.3.1" - keyv: "npm:^4.5.4" - checksum: 10/42570762052b17a1dec221d73a1e417d0ba07137de6debaabb51389cac265a12a027a895dc84e1725bc5cdde04fe8b706ad836860b05488e9a04bda9301d2529 + cacheable: "npm:^2.3.4" + flatted: "npm:^3.4.2" + hookified: "npm:^1.15.0" + checksum: 10/1133b75077c87260324b5b768af742242eacdbcb78e7260e365caef74dd4f31625e95661d25dce8c823b889d657a030f6a8a0b7ef3719223de267cbb1c647143 languageName: node linkType: hard @@ -13190,10 +13295,10 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.2.9, flatted@npm:^3.3.1": - version: 3.3.1 - resolution: "flatted@npm:3.3.1" - checksum: 10/7b8376061d5be6e0d3658bbab8bde587647f68797cf6bfeae9dea0e5137d9f27547ab92aaff3512dd9d1299086a6d61be98e9d48a56d17531b634f77faadbc49 +"flatted@npm:^3.2.9, flatted@npm:^3.4.2": + version: 3.4.2 + resolution: "flatted@npm:3.4.2" + checksum: 10/a9e78fe5c2c1fcd98209a015ccee3a6caa953e01729778e83c1fe92e68601a63e1e69cd4e573010ca99eaf585a581b80ccf1018b99283e6cbc2117bcba1e030f languageName: node linkType: hard @@ -13452,10 +13557,10 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0": - version: 1.3.0 - resolution: "get-east-asian-width@npm:1.3.0" - checksum: 10/8e8e779eb28701db7fdb1c8cab879e39e6ae23f52dadd89c8aed05869671cee611a65d4f8557b83e981428623247d8bc5d0c7a4ef3ea7a41d826e73600112ad8 +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.5.0": + version: 1.5.0 + resolution: "get-east-asian-width@npm:1.5.0" + checksum: 10/60bc34cd1e975055ab99f0f177e31bed3e516ff7cee9c536474383954a976abaa6b94a51d99ad158ef1e372790fa096cab7d07f166bb0778f6587954c0fbe946 languageName: node linkType: hard @@ -13841,7 +13946,7 @@ __metadata: languageName: node linkType: hard -"globby@npm:^16.0.0": +"globby@npm:^16.0.0, globby@npm:^16.2.0": version: 16.2.0 resolution: "globby@npm:16.2.0" dependencies: @@ -13991,6 +14096,13 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^5.0.1": + version: 5.0.1 + resolution: "has-flag@npm:5.0.1" + checksum: 10/e0a151db8e43b528258c4269c23224c691b42c1f5168f6d88b61c3f9398ef16d44226a78a0596642da55851cf306a8afe57d6936d4d69a24b66fd10de1373da8 + languageName: node + linkType: hard + "has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.2": version: 1.0.2 resolution: "has-property-descriptors@npm:1.0.2" @@ -14050,6 +14162,15 @@ __metadata: languageName: node linkType: hard +"hashery@npm:^1.4.0, hashery@npm:^1.5.1": + version: 1.5.1 + resolution: "hashery@npm:1.5.1" + dependencies: + hookified: "npm:^1.15.0" + checksum: 10/34eebad3e4d4041ae5c2457f129169b7be8d067fce4717ec6b903017e82b420263808a4d89f15cc77fa21822ae36d16841dc06d945392b6833b49082d3653e82 + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -14075,6 +14196,20 @@ __metadata: languageName: node linkType: hard +"hookified@npm:^1.15.0, hookified@npm:^1.15.1": + version: 1.15.1 + resolution: "hookified@npm:1.15.1" + checksum: 10/ecaee63506d9a213e8b78dfdb92346227f951d3bcd8ef60d14105c5590f61d466f130a908274e1ec87ee3b3668bd87d9250cad64547cf15cfa69932bcdbbea8f + languageName: node + linkType: hard + +"hookified@npm:^2.1.1": + version: 2.1.1 + resolution: "hookified@npm:2.1.1" + checksum: 10/36e61d173b2426528c026f6ec36bcf4d4f4478ca589da0b1eb432d154ad9634ddd93ba232eee22d418da639a582bdbb5560a5ce6cd4b9be2ebbd34d1606a694f + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.5 resolution: "hosted-git-info@npm:2.8.5" @@ -14125,10 +14260,10 @@ __metadata: languageName: node linkType: hard -"html-tags@npm:^3.3.1": - version: 3.3.1 - resolution: "html-tags@npm:3.3.1" - checksum: 10/d0e808544b92d8b999cbcc86d539577255a2f0f2f4f73110d10749d1d36e6fe6ad706a0355a8477afb6e000ecdc93d8455b3602951f9a2b694ac9e28f1b52878 +"html-tags@npm:^5.1.0": + version: 5.1.0 + resolution: "html-tags@npm:5.1.0" + checksum: 10/73d0448496ad9ac4a905427717908a3e72b8676d514e077437aaa9310deb43fd9f3eda58ac6b01b651db7431f3ddcc914e1f8e24052375dfe11d3ddfe4a20de7 languageName: node linkType: hard @@ -14363,13 +14498,6 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^6.0.2": - version: 6.0.2 - resolution: "ignore@npm:6.0.2" - checksum: 10/af39e49996cd989763920e445eff897d0ae1e36b5f27b0e09e14a4fd2df89b362f92e720ecf06ef729056842366527db8561d310e904718810b92ffbcd23056d - languageName: node - linkType: hard - "ignore@npm:^7.0.0, ignore@npm:^7.0.5": version: 7.0.5 resolution: "ignore@npm:7.0.5" @@ -14425,7 +14553,7 @@ __metadata: languageName: node linkType: hard -"import-meta-resolve@npm:^4.0.0": +"import-meta-resolve@npm:^4.0.0, import-meta-resolve@npm:^4.2.0": version: 4.2.0 resolution: "import-meta-resolve@npm:4.2.0" checksum: 10/3499ee8b7eddb79be77067b368bcdf39e6f144306dea4686d08071ae7e65a2e3bdca3f98f2a0f4babdcd4ba9d9e7d379ae7e27c4b9bf8b08c1e812a28c674bf3 @@ -16336,6 +16464,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^5.6.0": + version: 5.6.0 + resolution: "keyv@npm:5.6.0" + dependencies: + "@keyv/serialize": "npm:^1.1.1" + checksum: 10/f1de999fdf635d703d091981a0a3844fb20081e01d45c6743ada7d01f3a6570dc47d1882e10d0a98d31075b342db8e62b4ebe4f5bf473dcd0903064d718709c1 + languageName: node + linkType: hard + "kind-of@npm:^3.0.2": version: 3.2.2 resolution: "kind-of@npm:3.2.2" @@ -17247,10 +17384,10 @@ __metadata: languageName: node linkType: hard -"mathml-tag-names@npm:^2.1.3": - version: 2.1.3 - resolution: "mathml-tag-names@npm:2.1.3" - checksum: 10/1201a25a137d6b9e328facd67912058b8b45b19a6c4cc62641c9476195da28a275ca6e0eca070af5378b905c2b11abc1114676ba703411db0b9ce007de921ad0 +"mathml-tag-names@npm:^4.0.0": + version: 4.0.0 + resolution: "mathml-tag-names@npm:4.0.0" + checksum: 10/811f2555a8058d747b39a0c948668fc1dd609281b93d15f6211162a800f7fa51742743028140ea115dde73e27ade24bfdf3dc4fedb10f45ecdf28064bcea8b72 languageName: node linkType: hard @@ -17405,10 +17542,10 @@ __metadata: languageName: node linkType: hard -"mdn-data@npm:2.12.2, mdn-data@npm:^2.0.30": - version: 2.12.2 - resolution: "mdn-data@npm:2.12.2" - checksum: 10/854e41715a9358e69f9a530117cd6ca7e71d06176469de8d70b1e629753b6827f5bd730995c16ad3750f3c9bad92230f8e4e178de2b34926b05f5205d27d76af +"mdn-data@npm:2.27.1, mdn-data@npm:^2.0.30": + version: 2.27.1 + resolution: "mdn-data@npm:2.27.1" + checksum: 10/5046dc83a961b8ea82a5d6d8331d07df6b15faec61519ce2f83e49766702358e7e6af96413be977ff89080534be6762c1d5963b5dd1180c208a47c0a663226b2 languageName: node linkType: hard @@ -17438,10 +17575,10 @@ __metadata: languageName: node linkType: hard -"meow@npm:^13.2.0": - version: 13.2.0 - resolution: "meow@npm:13.2.0" - checksum: 10/4eff5bc921fed0b8a471ad79069d741a0210036d717547d0c7f36fdaf84ef7a3036225f38b6a53830d84dc9cbf8b944b097fde62381b8b5b215119e735ce1063 +"meow@npm:^14.1.0": + version: 14.1.0 + resolution: "meow@npm:14.1.0" + checksum: 10/c6a22b3912a6bc849dee0d6475cd8bb63b9307e26919ca3ace28dc1aaf3d30257071de32bba496f7b5eec3e62b03a6b7731e3d04d18efb3c3103b829aad52ca5 languageName: node linkType: hard @@ -20081,13 +20218,13 @@ __metadata: languageName: node linkType: hard -"postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.0": - version: 7.1.0 - resolution: "postcss-selector-parser@npm:7.1.0" +"postcss-selector-parser@npm:^7.0.0, postcss-selector-parser@npm:^7.1.0, postcss-selector-parser@npm:^7.1.1": + version: 7.1.1 + resolution: "postcss-selector-parser@npm:7.1.1" dependencies: cssesc: "npm:^3.0.0" util-deprecate: "npm:^1.0.2" - checksum: 10/2caf09e66e2be81d45538f8afdc5439298c89bea71e9943b364e69dce9443d9c5ab33f4dd8b237f1ed7d2f38530338dcc189c1219d888159e6afb5b0afe58b19 + checksum: 10/bb3c6455b20af26a556e3021e21101d8470252644e673c1612f7348ff8dd41b11321329f0694cf299b5b94863f823480b72d3e2f4bd3a89dc43e2d8c0dbad341 languageName: node linkType: hard @@ -20141,14 +20278,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.32, postcss@npm:^8.4.33, postcss@npm:^8.4.47, postcss@npm:^8.4.48, postcss@npm:^8.5.6": - version: 8.5.10 - resolution: "postcss@npm:8.5.10" +"postcss@npm:^8.4.32, postcss@npm:^8.4.47, postcss@npm:^8.4.48, postcss@npm:^8.5.10, postcss@npm:^8.5.6, postcss@npm:^8.5.9": + version: 8.5.12 + resolution: "postcss@npm:8.5.12" dependencies: nanoid: "npm:^3.3.11" picocolors: "npm:^1.1.1" source-map-js: "npm:^1.2.1" - checksum: 10/7eac6169e535b63c8412e94d4f6047fc23efa3e9dde804b541940043c831b25f1cd867d83cd2c4371ad2450c8abcb42c208aa25668c1f0f3650d7f72faf711a8 + checksum: 10/ec6b79b68c363eca3c8ffceb134a4ab637274aee6ac0857614bf7c18d40ce4ce5f9036edec57b7e0be99895724d2599d0ec7328dbd7f407204e7548697b322f1 languageName: node linkType: hard @@ -20489,6 +20626,15 @@ __metadata: languageName: node linkType: hard +"qified@npm:^0.9.0": + version: 0.9.1 + resolution: "qified@npm:0.9.1" + dependencies: + hookified: "npm:^2.1.1" + checksum: 10/eb970e6d4f657441a2e31f4c21983ccec2083d1e30ac4fd153cbdca3ebbc19a0cc4d26876250de350649df0043d35251b210d1b2c8630a4babb384b9d699604b + languageName: node + linkType: hard + "qs@npm:^6.5.2": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -22381,6 +22527,16 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.2.0": + version: 8.2.0 + resolution: "string-width@npm:8.2.0" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10/c4f62877ec08fca155e84a260eb4f58f473cfe5169bd1c1e21ffb563d8e0b7f6d705cc3d250f2ed6bb4f30ee9732ad026f54afaac77aa487e3d1dc1b1969e51b + languageName: node + linkType: hard + "string.prototype.includes@npm:^2.0.1": version: 2.0.1 resolution: "string.prototype.includes@npm:2.0.1" @@ -22522,12 +22678,12 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": - version: 7.1.0 - resolution: "strip-ansi@npm:7.1.0" +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" dependencies: - ansi-regex: "npm:^6.0.1" - checksum: 10/475f53e9c44375d6e72807284024ac5d668ee1d06010740dec0b9744f2ddf47de8d7151f80e5f6190fc8f384e802fdf9504b76a7e9020c9faee7103623338be2 + ansi-regex: "npm:^6.2.2" + checksum: 10/96da3bc6d73cfba1218625a3d66cf7d37a69bf0920d8735b28f9eeaafcdb6c1fe8440e1ae9eb1ba0ca355dbe8702da872e105e2e939fa93e7851b3cb5dd7d316 languageName: node linkType: hard @@ -22639,9 +22795,9 @@ __metadata: version: 0.0.0-use.local resolution: "stylelint-config-carbon@workspace:config/stylelint-config-carbon" dependencies: - "@double-great/stylelint-a11y": "npm:^3.0.2" + "@double-great/stylelint-a11y": "npm:^3.4.10" prettier: "npm:^3.3.3" - stylelint: "npm:^16.0.0" + stylelint: "npm:^17.0.0" stylelint-config-idiomatic-order: "npm:^10.0.0" stylelint-config-prettier: "npm:^9.0.3" stylelint-config-standard: "npm:^36.0.0" @@ -22652,7 +22808,7 @@ __metadata: stylelint-scss: "npm:^6.2.1" stylelint-use-logical: "npm:^2.1.0" peerDependencies: - stylelint: ^16.0.0 + stylelint: ^17.0.0 languageName: unknown linkType: soft @@ -22795,51 +22951,56 @@ __metadata: languageName: node linkType: hard -"stylelint@npm:^16.0.0": - version: 16.10.0 - resolution: "stylelint@npm:16.10.0" +"stylelint@npm:^17.0.0": + version: 17.8.0 + resolution: "stylelint@npm:17.8.0" dependencies: - "@csstools/css-parser-algorithms": "npm:^3.0.1" - "@csstools/css-tokenizer": "npm:^3.0.1" - "@csstools/media-query-list-parser": "npm:^3.0.1" - "@csstools/selector-specificity": "npm:^4.0.0" - "@dual-bundle/import-meta-resolve": "npm:^4.1.0" - balanced-match: "npm:^2.0.0" + "@csstools/css-calc": "npm:^3.1.1" + "@csstools/css-parser-algorithms": "npm:^4.0.0" + "@csstools/css-syntax-patches-for-csstree": "npm:^1.1.2" + "@csstools/css-tokenizer": "npm:^4.0.0" + "@csstools/media-query-list-parser": "npm:^5.0.0" + "@csstools/selector-resolve-nested": "npm:^4.0.0" + "@csstools/selector-specificity": "npm:^6.0.0" colord: "npm:^2.9.3" - cosmiconfig: "npm:^9.0.0" - css-functions-list: "npm:^3.2.3" - css-tree: "npm:^3.0.0" - debug: "npm:^4.3.7" - fast-glob: "npm:^3.3.2" + cosmiconfig: "npm:^9.0.1" + css-functions-list: "npm:^3.3.3" + css-tree: "npm:^3.2.1" + debug: "npm:^4.4.3" + fast-glob: "npm:^3.3.3" fastest-levenshtein: "npm:^1.0.16" - file-entry-cache: "npm:^9.1.0" + file-entry-cache: "npm:^11.1.2" global-modules: "npm:^2.0.0" - globby: "npm:^11.1.0" + globby: "npm:^16.2.0" globjoin: "npm:^0.1.4" - html-tags: "npm:^3.3.1" - ignore: "npm:^6.0.2" - imurmurhash: "npm:^0.1.4" + html-tags: "npm:^5.1.0" + ignore: "npm:^7.0.5" + import-meta-resolve: "npm:^4.2.0" is-plain-object: "npm:^5.0.0" - known-css-properties: "npm:^0.34.0" - mathml-tag-names: "npm:^2.1.3" - meow: "npm:^13.2.0" + mathml-tag-names: "npm:^4.0.0" + meow: "npm:^14.1.0" micromatch: "npm:^4.0.8" normalize-path: "npm:^3.0.0" - picocolors: "npm:^1.0.1" - postcss: "npm:^8.4.47" - postcss-resolve-nested-selector: "npm:^0.1.6" + picocolors: "npm:^1.1.1" + postcss: "npm:^8.5.9" postcss-safe-parser: "npm:^7.0.1" - postcss-selector-parser: "npm:^6.1.2" + postcss-selector-parser: "npm:^7.1.1" postcss-value-parser: "npm:^4.2.0" - resolve-from: "npm:^5.0.0" - string-width: "npm:^4.2.3" - supports-hyperlinks: "npm:^3.1.0" + string-width: "npm:^8.2.0" + supports-hyperlinks: "npm:^4.4.0" svg-tags: "npm:^1.0.0" - table: "npm:^6.8.2" - write-file-atomic: "npm:^5.0.1" + table: "npm:^6.9.0" + write-file-atomic: "npm:^7.0.1" bin: stylelint: bin/stylelint.mjs - checksum: 10/2bc1627e2681414d9c61a96e8298ca7697ce8bc78bb9ffe1c3e370e064ca81cd4d131493a3f315334195b1f039ff99ea0c900e264ca4516c93ee5c36d2e4490d + checksum: 10/960fa9f4c12e8cb87821a17d7d983a365ed5459d0a04560dc026d7f4b07cc7ad81aaeb82fde60599bae4753cb8c9600082563b77a1a53a3df2b8468a0a23ef2d + languageName: node + linkType: hard + +"supports-color@npm:^10.2.2": + version: 10.2.2 + resolution: "supports-color@npm:10.2.2" + checksum: 10/bd132705fc07213a4024131fb061f0a46a48b49ecaf434bb029c0a5aa0339b969236d496d63969e3c248a90fdcac16d4f9c5bf187e7be0bfb5e804ed13bef6b0 languageName: node linkType: hard @@ -22859,7 +23020,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0": +"supports-color@npm:^7.1.0": version: 7.1.0 resolution: "supports-color@npm:7.1.0" dependencies: @@ -22877,13 +23038,13 @@ __metadata: languageName: node linkType: hard -"supports-hyperlinks@npm:^3.1.0": - version: 3.1.0 - resolution: "supports-hyperlinks@npm:3.1.0" +"supports-hyperlinks@npm:^4.4.0": + version: 4.4.0 + resolution: "supports-hyperlinks@npm:4.4.0" dependencies: - has-flag: "npm:^4.0.0" - supports-color: "npm:^7.0.0" - checksum: 10/e893fb035ecd86e42c5225dc1cd24db56eb950ed77b2e8f59c7aaf2836b8b2ef276ffd11f0df88b0b12184832aa2333f875eefcb74d3c47ed2633b6b41d4be43 + has-flag: "npm:^5.0.1" + supports-color: "npm:^10.2.2" + checksum: 10/f128846b76c2d573cb5a44999513f4ccf407969e6ff6ebe8a7587c2bcd24dc75accb310a9188d1afde416d4cbdf9a5069889ad9debc1789d8a9e4057513c5f35 languageName: node linkType: hard @@ -22996,16 +23157,16 @@ __metadata: languageName: node linkType: hard -"table@npm:^6.8.2": - version: 6.8.2 - resolution: "table@npm:6.8.2" +"table@npm:^6.9.0": + version: 6.9.0 + resolution: "table@npm:6.9.0" dependencies: ajv: "npm:^8.0.1" lodash.truncate: "npm:^4.4.2" slice-ansi: "npm:^4.0.0" string-width: "npm:^4.2.3" strip-ansi: "npm:^6.0.1" - checksum: 10/2946162eb87a91b9bf4283214d26830db96f09cf517eff18e7501d47a4770c529b432bb54c9394337c3dfd6c8dbf66581f76edb37e9838beb6ec394080af4ac2 + checksum: 10/976da6d89841566e39628d1ba107ffab126964c9390a0a877a7c54ebb08820bf388d28fe9f8dcf354b538f19634a572a506c38a3762081640013a149cc862af9 languageName: node linkType: hard @@ -24949,6 +25110,15 @@ __metadata: languageName: node linkType: hard +"write-file-atomic@npm:^7.0.1": + version: 7.0.1 + resolution: "write-file-atomic@npm:7.0.1" + dependencies: + signal-exit: "npm:^4.0.1" + checksum: 10/99c9fdf6fb282fc798102bc8763430fbf0f9b26030b510bf085e25c13ac9a36b0a0c8198e52eddcdb6ffcac68f0959a3db7b9b025f40df48374fd699db559f55 + languageName: node + linkType: hard + "write-json-file@npm:^3.2.0": version: 3.2.0 resolution: "write-json-file@npm:3.2.0" From 2818eeb94e76d90e47d4a25b8c64b690b53fc580 Mon Sep 17 00:00:00 2001 From: Adam Alston Date: Wed, 29 Apr 2026 05:50:48 -0400 Subject: [PATCH 003/266] fix: narrow react element and child types (#22011) --- .../__snapshots__/PublicAPI-test.js.snap | 9 +++ .../components/Accordion/AccordionItem.tsx | 6 +- .../src/components/ComboBox/ComboBox.tsx | 3 +- .../ContentSwitcher/ContentSwitcher.tsx | 81 ++++++++++--------- .../src/components/DataTable/TableSlugRow.tsx | 21 ++--- .../src/components/Dropdown/Dropdown.tsx | 3 +- .../FluidTimePicker/FluidTimePicker.tsx | 24 +++--- .../FluidTimePickerSelect.tsx | 10 +++ packages/react/src/components/Grid/Column.tsx | 5 +- .../react/src/components/Grid/ColumnHang.tsx | 7 +- packages/react/src/components/Grid/Row.tsx | 7 +- .../MultiSelect/FilterableMultiSelect.tsx | 3 +- .../components/MultiSelect/MultiSelect.tsx | 3 +- .../components/OverflowMenu/OverflowMenu.tsx | 14 ++-- .../RadioButtonGroup/RadioButtonGroup.tsx | 45 +++++------ packages/react/src/components/Theme/index.tsx | 19 +++-- .../src/components/TreeView/TreeNode.tsx | 11 +-- .../src/components/UIShell/HeaderMenu.tsx | 15 ++-- packages/react/src/internal/FloatingMenu.tsx | 16 ++-- packages/react/src/tools/wrapComponent.ts | 12 +-- 20 files changed, 165 insertions(+), 149 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 1ec9f71a7d2d..dafea54dd6a1 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -5347,6 +5347,9 @@ Map { "onChange": { "type": "func", }, + "readOnly": { + "type": "bool", + }, }, "render": [Function], }, @@ -13363,6 +13366,9 @@ Map { "onChange": { "type": "func", }, + "readOnly": { + "type": "bool", + }, }, "render": [Function], }, @@ -15343,6 +15349,9 @@ Map { "onChange": { "type": "func", }, + "readOnly": { + "type": "bool", + }, }, "render": [Function], }, diff --git a/packages/react/src/components/Accordion/AccordionItem.tsx b/packages/react/src/components/Accordion/AccordionItem.tsx index 3086edd8feb4..7591ea870b93 100644 --- a/packages/react/src/components/Accordion/AccordionItem.tsx +++ b/packages/react/src/components/Accordion/AccordionItem.tsx @@ -76,8 +76,7 @@ export interface AccordionItemProps { */ renderExpando?: ( props: PropsWithChildren - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ) => ReactElement; + ) => ReactElement; /** * The callback function to render the expand button. @@ -85,8 +84,7 @@ export interface AccordionItemProps { */ renderToggle?: ( props: PropsWithChildren - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ) => ReactElement; + ) => ReactElement; /** * The accordion title. diff --git a/packages/react/src/components/ComboBox/ComboBox.tsx b/packages/react/src/components/ComboBox/ComboBox.tsx index 193a44e0bcf6..54001f3b0466 100644 --- a/packages/react/src/components/ComboBox/ComboBox.tsx +++ b/packages/react/src/components/ComboBox/ComboBox.tsx @@ -1528,8 +1528,7 @@ type ComboboxComponentProps = PropsWithChildren< RefAttributes; export interface ComboBoxComponent { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - (props: ComboboxComponentProps): ReactElement | null; + (props: ComboboxComponentProps): ReactElement | null; } export default ComboBox as ComboBoxComponent; diff --git a/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx b/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx index 51538faacef7..5cf071a28e6a 100644 --- a/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx +++ b/packages/react/src/components/ContentSwitcher/ContentSwitcher.tsx @@ -9,24 +9,20 @@ import PropTypes from 'prop-types'; import React, { Children, cloneElement, - isValidElement, useContext, useEffect, useRef, useState, type HTMLAttributes, - type KeyboardEvent, - type MouseEvent, type ReactElement, } from 'react'; import classNames from 'classnames'; import { deprecate } from '../../prop-types/deprecate'; import { LayoutConstraint } from '../Layout'; -import { composeEventHandlers } from '../../tools/events'; -import { getNextIndex, matches, keys } from '../../internal/keyboard'; +import { getNextIndex, match, keys } from '../../internal/keyboard'; import { PrefixContext } from '../../internal/usePrefix'; import { isComponentElement } from '../../internal'; -import { IconSwitch } from '../Switch'; +import { IconSwitch, Switch } from '../Switch'; import type { SwitchEventHandlersParams } from '../Switch/Switch'; export interface ContentSwitcherProps @@ -34,8 +30,7 @@ export interface ContentSwitcherProps /** * Pass in Switch components to be rendered in the ContentSwitcher */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - children?: ReactElement[]; + children?: ReactElement | ReactElement[]; /** * Specify an optional className to be added to the container node @@ -118,23 +113,19 @@ export const ContentSwitcher = ({ } }; - const isKeyboardEvent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - event: any - ): event is KeyboardEvent | globalThis.KeyboardEvent => - event && typeof event === 'object' && 'key' in event; + const hasKey = ( + event: SwitchEventHandlersParams + ): event is SwitchEventHandlersParams & { key: string | number } => + typeof event === 'object' && event !== null && 'key' in event; - const handleChildChange = ( - event: SwitchEventHandlersParams & - (KeyboardEvent | MouseEvent) - ) => { + const handleChildChange = (event: SwitchEventHandlersParams) => { if (typeof event.index === 'undefined') return; const { index } = event; if ( - isKeyboardEvent(event) && - matches(event, [keys.ArrowRight, keys.ArrowLeft]) + hasKey(event) && + (match(event.key, keys.ArrowRight) || match(event.key, keys.ArrowLeft)) ) { const nextIndex = getNextIndex(event.key, index, childrenArray.length); @@ -147,7 +138,10 @@ export const ContentSwitcher = ({ setSelectedIndex(nextIndex); - if (isValidElement(child)) { + if ( + isComponentElement(child, Switch) || + isComponentElement(child, IconSwitch) + ) { onChange({ ...event, index: nextIndex, @@ -158,7 +152,9 @@ export const ContentSwitcher = ({ } } else if ( selectedIndex !== index && - (isKeyboardEvent(event) ? matches(event, [keys.Enter, keys.Space]) : true) + (hasKey(event) + ? match(event.key, keys.Enter) || match(event.key, keys.Space) + : true) ) { setSelectedIndex(index); focusSwitch(index); @@ -185,23 +181,32 @@ export const ContentSwitcher = ({ className={classes} role="tablist" onChange={undefined}> - {children && - Children.map(children, (child, index) => - cloneElement(child, { - index, - onClick: composeEventHandlers([ - handleChildChange, - child.props.onClick, - ]), - onKeyDown: composeEventHandlers([ - handleChildChange, - child.props.onKeyDown, - ]), - selected: index === selectedIndex, - ref: handleItemRef(index), - size, - }) - )} + {Children.map(children, (child, index) => { + if ( + !isComponentElement(child, Switch) && + !isComponentElement(child, IconSwitch) + ) + return child; + + const sharedProps = { + index, + onClick: (event: SwitchEventHandlersParams) => { + handleChildChange(event); + child.props.onClick?.(event); + }, + onKeyDown: (event: SwitchEventHandlersParams) => { + handleChildChange(event); + child.props.onKeyDown?.(event); + }, + selected: index === selectedIndex, + ref: handleItemRef(index), + }; + + return cloneElement(child, { + ...sharedProps, + ...(isComponentElement(child, IconSwitch) ? { size } : {}), + }); + })} ); }; diff --git a/packages/react/src/components/DataTable/TableSlugRow.tsx b/packages/react/src/components/DataTable/TableSlugRow.tsx index f4ca751d257d..994e076a04a6 100644 --- a/packages/react/src/components/DataTable/TableSlugRow.tsx +++ b/packages/react/src/components/DataTable/TableSlugRow.tsx @@ -1,12 +1,17 @@ /** - * Copyright IBM Corp. 2016, 2025 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import PropTypes from 'prop-types'; -import React, { ReactNode, useEffect } from 'react'; +import React, { + cloneElement, + isValidElement, + useEffect, + type ReactNode, +} from 'react'; import classNames from 'classnames'; import { usePrefix } from '../../internal/usePrefix'; import { deprecateComponent } from '../../prop-types/deprecateComponent'; @@ -39,13 +44,11 @@ const TableSlugRow = ({ className, slug }: TableSlugRowProps) => { }); // Slug is always size `mini` - let normalizedSlug; - if (slug) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - normalizedSlug = React.cloneElement(slug as React.ReactElement, { - size: 'mini', - }); - } + const normalizedSlug = isValidElement<{ size?: string }>(slug) + ? cloneElement(slug, { + size: 'mini', + }) + : undefined; return {normalizedSlug}; }; diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx index d5807ac0b223..a22c81f2fe43 100644 --- a/packages/react/src/components/Dropdown/Dropdown.tsx +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -735,8 +735,7 @@ const Dropdown = React.forwardRef( interface DropdownComponent { ( props: DropdownProps & { ref?: Ref } - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ): React.ReactElement | null; + ): React.ReactElement | null; } Dropdown.displayName = 'Dropdown'; diff --git a/packages/react/src/components/FluidTimePicker/FluidTimePicker.tsx b/packages/react/src/components/FluidTimePicker/FluidTimePicker.tsx index fbbf0d196419..841c6bb775df 100644 --- a/packages/react/src/components/FluidTimePicker/FluidTimePicker.tsx +++ b/packages/react/src/components/FluidTimePicker/FluidTimePicker.tsx @@ -1,13 +1,15 @@ /** - * Copyright IBM Corp. 2022 + * Copyright IBM Corp. 2022, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import FluidTextInput, { FluidTextInputProps } from '../FluidTextInput'; +import FluidTimePickerSelect from '../FluidTimePickerSelect'; +import { isComponentElement } from '../../internal'; import { usePrefix } from '../../internal/usePrefix'; import { WarningFilled, WarningAltFilled } from '@carbon/icons-react'; @@ -102,18 +104,20 @@ const FluidTimePicker = React.forwardRef< const childrenWithProps = () => { if (disabled) { return React.Children.toArray(children).map((child) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - React.cloneElement(child as React.ReactElement, { - disabled: true, - }) + isComponentElement(child, FluidTimePickerSelect) + ? cloneElement(child, { + disabled: true, + }) + : child ); } if (readOnly) { return React.Children.toArray(children).map((child) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - React.cloneElement(child as React.ReactElement, { - readOnly: true, - }) + isComponentElement(child, FluidTimePickerSelect) + ? cloneElement(child, { + readOnly: true, + }) + : child ); } return children; diff --git a/packages/react/src/components/FluidTimePickerSelect/FluidTimePickerSelect.tsx b/packages/react/src/components/FluidTimePickerSelect/FluidTimePickerSelect.tsx index b63af9dcc0ae..5c50a3d36767 100644 --- a/packages/react/src/components/FluidTimePickerSelect/FluidTimePickerSelect.tsx +++ b/packages/react/src/components/FluidTimePickerSelect/FluidTimePickerSelect.tsx @@ -46,6 +46,11 @@ export interface FluidTimePickerSelectProps { * the underlying `` changes */ onChange?: React.ChangeEventHandler; + + /** + * Whether the component is read-only. + */ + readOnly?: boolean; } // eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452 @@ -97,6 +102,11 @@ FluidTimePickerSelect.propTypes = { * the underlying `` changes */ onChange: PropTypes.func, + + /** + * Whether the component is read-only. + */ + readOnly: PropTypes.bool, }; export default FluidTimePickerSelect; diff --git a/packages/react/src/components/Grid/Column.tsx b/packages/react/src/components/Grid/Column.tsx index f2c11e1f9d6b..af5f3335e824 100644 --- a/packages/react/src/components/Grid/Column.tsx +++ b/packages/react/src/components/Grid/Column.tsx @@ -8,7 +8,7 @@ import { enabled } from '@carbon/feature-flags'; import cx from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { type ReactElement } from 'react'; import { usePrefix } from '../../internal/usePrefix'; import { useGridSettings } from './GridContext'; import { PolymorphicComponentPropWithRef } from '../../internal/PolymorphicProps'; @@ -95,8 +95,7 @@ export interface ColumnComponent { props: ColumnProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 context?: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ): React.ReactElement | null; + ): ReactElement | null; } // eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452 diff --git a/packages/react/src/components/Grid/ColumnHang.tsx b/packages/react/src/components/Grid/ColumnHang.tsx index e2873f161e7a..6351f3d7cf4e 100644 --- a/packages/react/src/components/Grid/ColumnHang.tsx +++ b/packages/react/src/components/Grid/ColumnHang.tsx @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -7,7 +7,7 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { type ReactElement } from 'react'; import { usePrefix } from '../../internal/usePrefix'; import { PolymorphicProps } from '../../types/common'; @@ -33,8 +33,7 @@ export interface ColumnHangComponent { props: ColumnHangProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 context?: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ): React.ReactElement | null; + ): ReactElement | null; } /** diff --git a/packages/react/src/components/Grid/Row.tsx b/packages/react/src/components/Grid/Row.tsx index dddac3971f3d..af23065d9d11 100644 --- a/packages/react/src/components/Grid/Row.tsx +++ b/packages/react/src/components/Grid/Row.tsx @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -7,7 +7,7 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { type ReactElement } from 'react'; import { usePrefix } from '../../internal/usePrefix'; import { PolymorphicProps } from '../../types/common'; @@ -45,8 +45,7 @@ export interface RowComponent { props: RowProps, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 context?: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ): React.ReactElement | null; + ): ReactElement | null; } function Row({ diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index f5e210cc77c6..40650e52d08a 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx @@ -1146,8 +1146,7 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect< ); }) as { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - (props: FilterableMultiSelectProps): ReactElement; + (props: FilterableMultiSelectProps): ReactElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 propTypes?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 diff --git a/packages/react/src/components/MultiSelect/MultiSelect.tsx b/packages/react/src/components/MultiSelect/MultiSelect.tsx index a2478b7d2dec..f2bc12d5a805 100644 --- a/packages/react/src/components/MultiSelect/MultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/MultiSelect.tsx @@ -908,8 +908,7 @@ interface MultiSelectComponent { displayName: string; ( props: MultiSelectComponentProps - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - ): React.ReactElement | null; + ): React.ReactElement | null; } MultiSelect.displayName = 'MultiSelect'; diff --git a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx index dec0633dd19f..5b83edace56d 100644 --- a/packages/react/src/components/OverflowMenu/OverflowMenu.tsx +++ b/packages/react/src/components/OverflowMenu/OverflowMenu.tsx @@ -9,7 +9,6 @@ import React, { Children, cloneElement, forwardRef, - isValidElement, RefObject, useCallback, useContext, @@ -19,7 +18,6 @@ import React, { type ElementType, type KeyboardEvent, type MouseEvent, - type ReactElement, type ReactNode, type Ref, } from 'react'; @@ -41,10 +39,13 @@ import { deprecate } from '../../prop-types/deprecate'; import { mergeRefs } from '../../tools/mergeRefs'; import { setupGetInstanceId } from '../../tools/setupGetInstanceId'; import { IconButton, IconButtonProps } from '../IconButton'; -import { OverflowMenuItemProps } from '../OverflowMenuItem/OverflowMenuItem'; +import OverflowMenuItem, { + OverflowMenuItemProps, +} from '../OverflowMenuItem/OverflowMenuItem'; import { useOutsideClick } from '../../internal/useOutsideClick'; import { deprecateValuesWithin } from '../../prop-types/deprecateValuesWithin'; import { mapPopoverAlign } from '../../tools/mapPopoverAlign'; +import { isComponentElement } from '../../internal'; const getInstanceId = setupGetInstanceId(); @@ -531,10 +532,9 @@ export const OverflowMenu = forwardRef( ); const childrenWithProps = Children.toArray(children).map((child, index) => { - if (isValidElement(child)) { - const childElement = child as ReactElement; - return cloneElement(childElement, { - closeMenu: childElement.props.closeMenu || closeMenuAndFocus, + if (isComponentElement(child, OverflowMenuItem)) { + return cloneElement(child, { + closeMenu: child.props.closeMenu || closeMenuAndFocus, handleOverflowMenuItemFocus, ref: (el: HTMLElement) => { menuItemRefs.current[index] = el; diff --git a/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.tsx b/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.tsx index 0c81d01bb15e..f9a5eede7434 100644 --- a/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/react/src/components/RadioButtonGroup/RadioButtonGroup.tsx @@ -7,16 +7,16 @@ import PropTypes from 'prop-types'; import React, { + Children, cloneElement, createContext, useEffect, useRef, useState, - type ReactElement, type ReactNode, } from 'react'; import classNames from 'classnames'; -import type { RadioButtonProps } from '../RadioButton'; +import RadioButton, { type RadioButtonProps } from '../RadioButton'; import { Legend } from '../Text/createTextComponent'; import { usePrefix } from '../../internal/usePrefix'; import { WarningFilled, WarningAltFilled } from '@carbon/icons-react'; @@ -178,29 +178,26 @@ const RadioButtonGroup = React.forwardRef( }, [valueSelected]); function getRadioButtons() { - const mappedChildren = React.Children.map( - children as ReactElement, - (radioButton) => { - if (!radioButton) { - return; - } - - const newProps = { - name: name, - key: radioButton.props.value, - value: radioButton.props.value, - onChange: handleOnChange, - checked: radioButton.props.value === selected, - required: required, - }; - - if (!selected && radioButton.props.checked) { - newProps.checked = true; - } - - return React.cloneElement(radioButton, newProps); + const mappedChildren = Children.map(children, (radioButton) => { + if (!isComponentElement(radioButton, RadioButton)) { + return radioButton; } - ); + + const newProps = { + name: name, + key: radioButton.props.value, + value: radioButton.props.value, + onChange: handleOnChange, + checked: radioButton.props.value === selected, + required: required, + }; + + if (!selected && radioButton.props.checked) { + newProps.checked = true; + } + + return cloneElement(radioButton, newProps); + }); return mappedChildren; } diff --git a/packages/react/src/components/Theme/index.tsx b/packages/react/src/components/Theme/index.tsx index b8960d218570..22214c15ab94 100644 --- a/packages/react/src/components/Theme/index.tsx +++ b/packages/react/src/components/Theme/index.tsx @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2023 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -7,7 +7,14 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; -import React, { ElementType, useMemo, type PropsWithChildren } from 'react'; +import React, { + cloneElement, + isValidElement, + useMemo, + type ElementType, + type PropsWithChildren, + type Ref, +} from 'react'; import { usePrefix } from '../../internal/usePrefix'; import { PolymorphicProps } from '../../types/common'; import { LayerContext } from '../Layer/LayerContext'; @@ -34,11 +41,9 @@ export const GlobalTheme = React.forwardRef( }; }, [theme]); - const childrenWithProps = React.cloneElement( - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - children as React.ReactElement, - { ref: ref } - ); + const childrenWithProps = isValidElement<{ ref?: Ref }>(children) + ? cloneElement(children, { ref }) + : children; return ( diff --git a/packages/react/src/components/TreeView/TreeNode.tsx b/packages/react/src/components/TreeView/TreeNode.tsx index 5b45680b7c60..438bc3973bb3 100644 --- a/packages/react/src/components/TreeView/TreeNode.tsx +++ b/packages/react/src/components/TreeView/TreeNode.tsx @@ -9,14 +9,16 @@ import { CaretDown } from '@carbon/icons-react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { + isValidElement, + useCallback, + useContext, useEffect, useRef, useState, - useCallback, - useContext, type ElementType, type FocusEvent, type MouseEvent, + type ReactNode, } from 'react'; import { keys, match, matches } from '../../internal/keyboard'; import { deprecate } from '../../prop-types/deprecate'; @@ -165,9 +167,8 @@ const extractTextContent = (node: React.ReactNode): string => { return node.map(extractTextContent).join(''); } - if (React.isValidElement(node)) { - const element = node as React.ReactElement<{ children?: React.ReactNode }>; - const children = element.props.children; + if (isValidElement<{ children?: ReactNode }>(node)) { + const children = node.props.children; return extractTextContent(children); } diff --git a/packages/react/src/components/UIShell/HeaderMenu.tsx b/packages/react/src/components/UIShell/HeaderMenu.tsx index 5ac563275ef3..1e2b70d19251 100644 --- a/packages/react/src/components/UIShell/HeaderMenu.tsx +++ b/packages/react/src/components/UIShell/HeaderMenu.tsx @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2025 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -11,26 +11,24 @@ import React, { Children, cloneElement, forwardRef, - isValidElement, useContext, useRef, useState, - type ComponentProps, type FocusEvent, type KeyboardEvent, type MouseEvent, - type ReactElement, type ReactNode, type Ref, } from 'react'; import PropTypes from 'prop-types'; import { keys, matches } from '../../internal/keyboard'; import { AriaLabelPropType } from '../../prop-types/AriaPropTypes'; +import { isComponentElement } from '../../internal'; import { PrefixContext } from '../../internal/usePrefix'; import { deprecate } from '../../prop-types/deprecate'; import { composeEventHandlers } from '../../tools/events'; import { useMergedRefs } from '../../internal/useMergedRefs'; -import type HeaderMenuItem from './HeaderMenuItem'; +import HeaderMenuItem from './HeaderMenuItem'; export interface HeaderMenuProps { /** @@ -201,7 +199,7 @@ export const HeaderMenu = frFn((props, ref) => { const hasActiveDescendant = (childrenArg: ReactNode): boolean => Children.toArray(childrenArg).some((child) => { - if (!isValidElement>(child)) { + if (!isComponentElement(child, HeaderMenuItem)) { return false; } @@ -221,9 +219,8 @@ export const HeaderMenu = frFn((props, ref) => { * sequence when they might not want to go through all the items. */ const renderMenuItem = (item: ReactNode, index: number): ReactNode => { - if (isValidElement(item)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - return cloneElement(item as ReactElement, { + if (isComponentElement(item, HeaderMenuItem)) { + return cloneElement(item, { ref: handleItemRef(index), }); } diff --git a/packages/react/src/internal/FloatingMenu.tsx b/packages/react/src/internal/FloatingMenu.tsx index 5d34c2fdb7f6..8d20f30f12d4 100644 --- a/packages/react/src/internal/FloatingMenu.tsx +++ b/packages/react/src/internal/FloatingMenu.tsx @@ -7,7 +7,6 @@ import React, { cloneElement, - JSXElementConstructor, useCallback, useContext, useEffect, @@ -59,6 +58,11 @@ interface FloatingPosition { top: number; } +type FloatingMenuChildProps = { + ref?: (node: HTMLElement | null) => void; + style?: CSSProperties; +}; + export type MenuDirection = | typeof DIRECTION_LEFT | typeof DIRECTION_TOP @@ -78,7 +82,7 @@ interface FloatingMenuProps { /** * Contents of the floating menu. */ - children: ReactElement; + children: ReactElement; /** * Whether the menu alignment should be flipped. @@ -440,13 +444,7 @@ export const FloatingMenu = ({ visibility: 'hidden', top: '0px', }; - const child = children as ReactElement< - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - string | JSXElementConstructor - >; - return cloneElement(child, { + return cloneElement(children, { ref: handleMenuRef, style: { ...styles, diff --git a/packages/react/src/tools/wrapComponent.ts b/packages/react/src/tools/wrapComponent.ts index a868813d5951..74dd8bc2b678 100644 --- a/packages/react/src/tools/wrapComponent.ts +++ b/packages/react/src/tools/wrapComponent.ts @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2016, 2025 + * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -7,7 +7,7 @@ import cx from 'classnames'; import PropTypes from 'prop-types'; -import React, { type HTMLAttributes } from 'react'; +import React, { type HTMLAttributes, type ReactElement } from 'react'; import { usePrefix } from '../internal/usePrefix'; type HTMLTagName = keyof HTMLElementTagNameMap; @@ -26,10 +26,7 @@ const wrapComponent = ({ name, className: getClassName, type, -}: WrapComponentArgs): (( - props: HTMLAttributes - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 -) => React.ReactElement) => { +}: WrapComponentArgs): ((props: HTMLAttributes) => ReactElement) => { /** * * @param {{ className?: string, [x: string]: any}} param0 @@ -55,8 +52,7 @@ const wrapComponent = ({ Component.propTypes = { className: PropTypes.string, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 - return Component as (props: HTMLAttributes) => React.ReactElement; + return Component as (props: HTMLAttributes) => React.ReactElement; }; export default wrapComponent; From d125d301413c304e4e7ad19e634b50e130e54497 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:08:22 +0000 Subject: [PATCH 004/266] chore(deps): bump axios from 0.21.1 to 0.21.4 (#21993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): bump axios from 0.21.1 to 0.21.4 Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.4. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v0.21.4/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.4) --- updated-dependencies: - dependency-name: axios dependency-version: 0.21.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] * chore: yarn dedupe --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sangeetha Babu Co-authored-by: “heloiselui” Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> Co-authored-by: Riddhi Bansal <41935566+riddhybansal@users.noreply.github.com> Co-authored-by: Taylor Jones --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index a1e6aa595bd0..63f5ff4b9f94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8751,11 +8751,11 @@ __metadata: linkType: hard "axios@npm:^0.21.1": - version: 0.21.1 - resolution: "axios@npm:0.21.1" + version: 0.21.4 + resolution: "axios@npm:0.21.4" dependencies: - follow-redirects: "npm:^1.10.0" - checksum: 10/271afc61381d285c688f0579eec170b66dc4177219a3f31e67d1fe485be7a2dae4af2c366fdee703a8ec887c52778fdc7f5fec30650a97f61f68c1a7696648b0 + follow-redirects: "npm:^1.14.0" + checksum: 10/da644592cb6f8f9f8c64fdabd7e1396d6769d7a4c1ea5f8ae8beb5c2eb90a823e3a574352b0b934ac62edc762c0f52647753dc54f7d07279127a7e5c4cd20272 languageName: node linkType: hard @@ -13309,7 +13309,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.10.0, follow-redirects@npm:^1.15.6": +"follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.15.6": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: From bf130cb2763e316444847f05a8abfddb36ba30e6 Mon Sep 17 00:00:00 2001 From: olihf-dematic Date: Wed, 29 Apr 2026 11:41:22 +0100 Subject: [PATCH 005/266] fix(menu-item): bugs with disabled state (#21340) * fix(menu-item): cursor not allowed, only shows on parts of the menu-item * fix(menu-item): do not dispatch close event if entry clicked is disabled * fix(menu-button): prevent closing menu on click regardless of state * fix(menu-tem): prevent mouse focus onto disabled items --------- Co-authored-by: Kritvi <158570656+Kritvi-bhatia17@users.noreply.github.com> --- packages/react/src/components/Menu/MenuItem.tsx | 7 +++++++ .../src/components/menu-button/menu-button.ts | 3 +-- .../web-components/src/components/menu/menu-item.scss | 6 +++++- .../web-components/src/components/menu/menu-item.ts | 11 +++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/Menu/MenuItem.tsx b/packages/react/src/components/Menu/MenuItem.tsx index 87a3cb84c15c..940d0260671d 100644 --- a/packages/react/src/components/Menu/MenuItem.tsx +++ b/packages/react/src/components/Menu/MenuItem.tsx @@ -190,6 +190,12 @@ export const MenuItem = forwardRef( const keyboardClickEvent = (e: KeyboardEvent) => match(e, keys.Enter) || match(e, keys.Space); + function handleMouseDown(e: MouseEvent) { + if (isDisabled) { + e.preventDefault(); + } + } + function handleKeyDown(e: KeyboardEvent) { if (hasChildren && match(e, keys.ArrowRight)) { openSubmenu(); @@ -268,6 +274,7 @@ export const MenuItem = forwardRef( aria-disabled={isDisabled ?? undefined} aria-haspopup={hasChildren ?? undefined} aria-expanded={hasChildren ? submenuOpen : undefined} + onMouseDown={handleMouseDown} onClick={handleClick} onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} diff --git a/packages/web-components/src/components/menu-button/menu-button.ts b/packages/web-components/src/components/menu-button/menu-button.ts index 103a85534482..ef7241eb2645 100644 --- a/packages/web-components/src/components/menu-button/menu-button.ts +++ b/packages/web-components/src/components/menu-button/menu-button.ts @@ -95,6 +95,7 @@ class CDSMenuButton extends HostListenerMixin(LitElement) { return; } + // Handle clicks on trigger only, other listeners handle clicks on menu items const path = event.composedPath(); if (path.includes(trigger)) { if (this._open) { @@ -102,8 +103,6 @@ class CDSMenuButton extends HostListenerMixin(LitElement) { } else { this._open = true; } - } else if (this._open) { - this._closeMenu(); } }; diff --git a/packages/web-components/src/components/menu/menu-item.scss b/packages/web-components/src/components/menu/menu-item.scss index 15b50ea6648e..5ff8ffdd7d1a 100644 --- a/packages/web-components/src/components/menu/menu-item.scss +++ b/packages/web-components/src/components/menu/menu-item.scss @@ -71,11 +71,15 @@ $css--plex: true !default; display: var(--child-icon-property, none); } -:host([aria-disabled='true']) * { +:host([aria-disabled='true']) { color: $text-disabled; cursor: not-allowed; } +:host([aria-disabled='true']) :where(*) { + color: inherit; +} + :host([aria-disabled='true']:hover), :host([aria-disabled='true'].#{$prefix}--menu-item--danger:hover) { background-color: $layer; diff --git a/packages/web-components/src/components/menu/menu-item.ts b/packages/web-components/src/components/menu/menu-item.ts index f7a5d1efc811..9ed9f4e2b580 100644 --- a/packages/web-components/src/components/menu/menu-item.ts +++ b/packages/web-components/src/components/menu/menu-item.ts @@ -188,6 +188,13 @@ class CDSmenuItem extends HostListenerMixin(HostListenerMixin(LitElement)) { this._handleClick(event); } + @HostListener('mousedown') + handleMouseDown(event: MouseEvent) { + if (this.disabled) { + event.preventDefault(); + } + } + @HostListener('mouseenter') handleMouseEnter() { if (this.hasSubmenu) { @@ -263,6 +270,10 @@ class CDSmenuItem extends HostListenerMixin(HostListenerMixin(LitElement)) { } _handleClick = (e: MouseEvent | KeyboardEvent): void => { + if (this.disabled) { + return; + } + if (this.hasSubmenu) { this._openSubmenu(); return; From d443e90203bc31839cbbbfd7e47f7e49f2831887 Mon Sep 17 00:00:00 2001 From: Heloise Lui <71858203+heloiselui@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:24:42 +0100 Subject: [PATCH 006/266] fix(time-picker): announce invalid/warn state message (#22109) * fix(time-picker): announce invalid state messages * test(time-picker): add tests * fix(time-picker): preserve aria-describedby and added tests --- .../components/TimePicker/TimePicker-test.js | 61 +++++++++++++++++++ .../src/components/TimePicker/TimePicker.tsx | 14 +++++ 2 files changed, 75 insertions(+) diff --git a/packages/react/src/components/TimePicker/TimePicker-test.js b/packages/react/src/components/TimePicker/TimePicker-test.js index e6dbd92e58e0..b2d5290b5800 100644 --- a/packages/react/src/components/TimePicker/TimePicker-test.js +++ b/packages/react/src/components/TimePicker/TimePicker-test.js @@ -177,6 +177,15 @@ describe('TimePicker', () => { expect( container.querySelector('.cds--form-requirement') ).toHaveTextContent('Invalid time'); + const invalidText = screen.getByText('Invalid time'); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + invalidText.id + ); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-invalid', + 'true' + ); }); it('should show warning state when warning is true', () => { @@ -192,6 +201,12 @@ describe('TimePicker', () => { expect( container.querySelector('.cds--form-requirement') ).toHaveTextContent('Warning message'); + const warningText = screen.getByText('Warning message'); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + warningText.id + ); + expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-invalid'); }); it('should not show invalid state when disabled', () => { @@ -289,5 +304,51 @@ describe('TimePicker', () => { container.querySelector('.cds--time-picker__input-field-error') ).not.toBeInTheDocument(); }); + + it('should preserve provided aria-describedby when not invalid or warning', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + 'custom-hint' + ); + }); + + it('should merge invalid aria attributes with consumer-provided aria-describedby', () => { + render( + + ); + const invalidText = screen.getByText('Invalid time'); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + `${invalidText.id} custom-hint` + ); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-invalid', + 'true' + ); + }); + + it('should merge warning aria attributes with consumer-provided aria-describedby', () => { + render( + + ); + const warningText = screen.getByText('Warning message'); + expect(screen.getByRole('textbox')).toHaveAttribute( + 'aria-describedby', + `${warningText.id} custom-hint` + ); + expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-invalid'); + }); }); }); diff --git a/packages/react/src/components/TimePicker/TimePicker.tsx b/packages/react/src/components/TimePicker/TimePicker.tsx index cc1c79c39809..a6b3c6126c23 100644 --- a/packages/react/src/components/TimePicker/TimePicker.tsx +++ b/packages/react/src/components/TimePicker/TimePicker.tsx @@ -142,6 +142,7 @@ const frFn = forwardRef; const TimePicker = frFn((props, ref) => { const { + ['aria-describedby']: ariaDescribedBy, children, className, inputClassName, @@ -268,6 +269,17 @@ const TimePicker = frFn((props, ref) => { const readOnlyProps = { readOnly: readOnly, }; + const describedBy = + [ + normalizedProps.invalid + ? normalizedProps.invalidId + : normalizedProps.warn + ? normalizedProps.warnId + : null, + ariaDescribedBy, + ] + .filter(Boolean) + .join(' ') || undefined; return (
@@ -290,6 +302,8 @@ const TimePicker = frFn((props, ref) => { value={value} {...rest} {...readOnlyProps} + aria-describedby={describedBy} + aria-invalid={normalizedProps.invalid ? true : undefined} /> {(normalizedProps.invalid || normalizedProps.warn) && normalizedProps.icon && ( From 0ba3f47865751b37836d0944a7846ad71f813430 Mon Sep 17 00:00:00 2001 From: David Menendez Date: Wed, 29 Apr 2026 07:27:37 -0500 Subject: [PATCH 007/266] fix: include utilities as option in bug report (#22074) --- .github/ISSUE_TEMPLATE/BUG_REPORT.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml index 33bd4636a4b2..eecd1e8b9efd 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yaml @@ -34,6 +34,7 @@ body: - '@carbon/themes' - '@carbon/type' - '@carbon/upgrade' + - '@carbon/utilities' validations: required: true - type: dropdown From cb5b2931036bd7aadbb1dfee0ae3dca7ec16417e Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Wed, 29 Apr 2026 13:01:36 -0500 Subject: [PATCH 008/266] chore(agents): clarify pr template use, add experimental validation (#21967) * chore(agents): clarify pr template use, add experimental validation * chore: update workflow name * clarify pr template validation * remove agents md comment job * chore: yarn format * chore: incorporate feedback --- .github/workflows/ci.yml | 43 --- .github/workflows/pull-request-template.yml | 28 ++ AGENTS.md | 3 + actions/validate-pr-template/action.yml | 12 + actions/validate-pr-template/index.js | 305 ++++++++++++++++++++ docs/guides/reviewing-pull-requests.md | 4 + 6 files changed, 352 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/pull-request-template.yml create mode 100644 actions/validate-pr-template/action.yml create mode 100644 actions/validate-pr-template/index.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bddbd4c0d7b..a5ed285079ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,46 +456,3 @@ jobs: echo "- Storybook deploy preview: ${{ env.storybookUrl }}" >> $GITHUB_STEP_SUMMARY echo "- Commit: ${{ github.event.pull_request.head.sha }}" >> $GITHUB_STEP_SUMMARY echo "- Chromatic build URL: ${{ env.buildUrl }}" >> $GITHUB_STEP_SUMMARY - - flag-agents-md-changes: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Detect AGENTS.md changes - id: changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - with: - filters: | - added_or_modified: - - added|modified: '**/AGENTS.md' - - - name: Post PR comment (if not already present) - if: steps.changes.outputs.added_or_modified == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const marker = ""; - - const body = `${marker} - ⚠️ An AGENTS.md was added or updated in this PR. The length of these files directly correlate with token usage, increased instruction length, and spend. Please follow [best practices](https://www.aihero.dev/a-complete-guide-to-agents-md) and leave a comment explaining why this change is needed. - `.replace(/\n[ \t]+/g, '\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - per_page: 100 - }); - - const alreadyCommented = comments.some(comment => - comment.body?.includes(marker) - ); - - if (!alreadyCommented) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body - }); - } diff --git a/.github/workflows/pull-request-template.yml b/.github/workflows/pull-request-template.yml new file mode 100644 index 000000000000..1aa12d8b8865 --- /dev/null +++ b/.github/workflows/pull-request-template.yml @@ -0,0 +1,28 @@ +name: pull request +on: + pull_request_target: + types: [opened, reopened, ready_for_review, edited] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + follows-pr-template: + name: follows template + if: + ${{ !github.event.pull_request.draft && (github.event.action != 'edited' + || github.event.changes.body != null) }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Validate PR body + uses: ./actions/validate-pr-template diff --git a/AGENTS.md b/AGENTS.md index 504f4ba7aec5..738d66989428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ icons, pictograms, layout, motion, themes, type), and tooling. - Linting, formatting, build and tests should all pass before committing - Before opening or reviewing a pull request (PR), follow the instructions: `docs/guides/reviewing-pull-requests.md` +- When opening a PR, always use and fill out the pull request template: + `.github/PULL_REQUEST_TEMPLATE.md` +- When opening an issue, always use a template: `.github/ISSUE_TEMPLATE/*.yaml` ## Package relationships diff --git a/actions/validate-pr-template/action.yml b/actions/validate-pr-template/action.yml new file mode 100644 index 000000000000..8b5c495b487e --- /dev/null +++ b/actions/validate-pr-template/action.yml @@ -0,0 +1,12 @@ +name: Validate PR template +description: Validate that a pull request body follows the repository PR template +runs: + using: composite + steps: + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + with: + script: | + const validatePRTemplate = require(`${process.env.ACTION_PATH}/index.js`); + await validatePRTemplate({ github, context, core }); diff --git a/actions/validate-pr-template/index.js b/actions/validate-pr-template/index.js new file mode 100644 index 000000000000..f4215afebaaf --- /dev/null +++ b/actions/validate-pr-template/index.js @@ -0,0 +1,305 @@ +function getLines(markdown) { + return markdown.split('\n').map((line) => line.trim()); +} + +function isHeading(line) { + return line.startsWith('#'); +} + +function isHtmlComment(line) { + return line.startsWith(''); +} + +function normalizeChecklistLine(line) { + let normalized = line.trim(); + + while (normalized.startsWith('~')) { + normalized = normalized.slice(1).trimStart(); + } + + while (normalized.endsWith('~')) { + normalized = normalized.slice(0, -1).trimEnd(); + } + + if (normalized.startsWith('-')) { + normalized = normalized.slice(1).trimStart(); + } + + if (normalized.startsWith('[ ]')) { + normalized = normalized.slice(3).trimStart(); + } else if (normalized.startsWith('[x]') || normalized.startsWith('[X]')) { + normalized = normalized.slice(3).trimStart(); + } + + while (normalized.startsWith('~')) { + normalized = normalized.slice(1).trimStart(); + } + + while (normalized.endsWith('~')) { + normalized = normalized.slice(0, -1).trimEnd(); + } + + return normalized.trim(); +} + +function getTemplateHeadings(templateBody) { + return getLines(templateBody).filter((line) => { + return isHeading(line); + }); +} + +function getChecklistItemsFromTemplate(templateBody) { + const lines = getLines(templateBody); + const checklistHeadingIndex = lines.findIndex( + (line) => line === '## PR Checklist' + ); + + if (checklistHeadingIndex === -1) { + return []; + } + + const checklistItems = []; + + for (let index = checklistHeadingIndex + 1; index < lines.length; index++) { + const line = lines[index]; + + if (!line) { + continue; + } + + if (isHeading(line)) { + break; + } + + if (isHtmlComment(line)) { + continue; + } + + if (line.includes('[ ]') || line.includes('[x]') || line.includes('[X]')) { + const item = normalizeChecklistLine(line); + + if (item) { + checklistItems.push(item); + } + } + } + + return checklistItems; +} + +function getMissingHeadings(templateHeadings, prBody) { + const prLines = getLines(prBody); + + return templateHeadings.filter((heading) => { + return !prLines.includes(heading); + }); +} + +function getMissingChecklistItems(templateChecklistItems, prBody) { + const normalizedPRBody = prBody.toLowerCase(); + + return templateChecklistItems.filter((item) => { + return !normalizedPRBody.includes(item.toLowerCase()); + }); +} + +async function getJobUrl({ + github, + core, + owner, + repo, + runId, + runAttempt, + issueNumber, + jobName, + fallbackUrl, +}) { + try { + const jobs = await github.paginate( + github.rest.actions.listJobsForWorkflowRunAttempt, + { + owner, + repo, + run_id: runId, + attempt_number: runAttempt, + per_page: 100, + } + ); + + const currentJob = jobs.find((job) => { + return job.name === jobName; + }); + + if (currentJob) { + return `https://github.com/${owner}/${repo}/actions/runs/${runId}/job/${currentJob.id}?pr=${issueNumber}`; + } + } catch (error) { + core.warning(`Unable to resolve workflow job URL: ${error.message}`); + } + + return fallbackUrl; +} + +module.exports = async ({ github, context, core }) => { + const upstreamOwner = 'carbon-design-system'; + const upstreamRepo = 'carbon'; + const templatePath = '.github/PULL_REQUEST_TEMPLATE.md'; + const labelName = 'status: invalid description'; + const commentMarker = ''; + const owner = context.repo.owner; + const repo = context.repo.repo; + const base = context.payload.pull_request.base; + const baseRepo = base.repo; + const baseOwner = baseRepo.owner.login; + const baseRepoName = baseRepo.name; + const baseRef = base.ref ?? context.payload.repository.default_branch; + const issueNumber = context.issue.number; + const body = context.payload.pull_request.body || ''; + const templateUrl = `https://github.com/${baseOwner}/${baseRepoName}/blob/${baseRef}/${templatePath}`; + const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}?pr=${issueNumber}`; + const jobUrl = await getJobUrl({ + github, + core, + owner, + repo, + runId: context.runId, + runAttempt: context.runAttempt, + issueNumber, + jobName: 'follows template', + fallbackUrl: runUrl, + }); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + + const existingComment = comments.find((comment) => { + return ( + comment.user?.type === 'Bot' && comment.body?.includes(commentMarker) + ); + }); + + async function addLabel() { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [labelName], + }); + } + + async function removeLabel() { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: labelName, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + async function upsertComment(commentBody) { + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: commentBody, + }); + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: commentBody, + }); + } + + const { data: templateFile } = await github.rest.repos.getContent({ + owner: baseOwner ?? upstreamOwner, + repo: baseRepoName ?? upstreamRepo, + path: templatePath, + ref: baseRef, + }); + + const templateBody = Buffer.from(templateFile.content, templateFile.encoding) + .toString('utf8') + .trim(); + + const templateHeadings = getTemplateHeadings(templateBody); + const templateChecklistItems = getChecklistItemsFromTemplate(templateBody); + const missingHeadings = getMissingHeadings(templateHeadings, body); + const missingChecklistItems = getMissingChecklistItems( + templateChecklistItems, + body + ); + const isValid = + missingHeadings.length === 0 && missingChecklistItems.length === 0; + + if (!isValid) { + const failureDetails = []; + + if (missingHeadings.length > 0) { + failureDetails.push(`Missing headings: ${missingHeadings.join(', ')}`); + } + + if (missingChecklistItems.length > 0) { + failureDetails.push( + `Missing checklist items: ${missingChecklistItems.join(', ')}` + ); + } + + const failureComment = [ + commentMarker, + '> [!IMPORTANT]', + '>', + `> Pull requests must use the pull request [template](${templateUrl}). Please edit the description above to resolve this [error](${jobUrl}).`, + '', + '
View the template', + '

', + '', + '```markdown', + templateBody, + '```', + '', + '

', + '
', + ].join('\n'); + + await addLabel(); + await upsertComment(failureComment); + + core.setFailed( + [ + 'Pull request body is missing required content from the PR template.', + ...failureDetails.map((detail) => `- ${detail}`), + `Template: ${templateUrl}`, + `Workflow run: ${jobUrl}`, + 'Edit the PR description to restore the missing headings and checklist item text, then save the PR to rerun this check.', + ].join('\n') + ); + + return; + } + + await removeLabel(); + + if (existingComment) { + await upsertComment( + [ + commentMarker, + 'Thanks for using the pull request description template! 👍', + ].join('\n') + ); + } +}; diff --git a/docs/guides/reviewing-pull-requests.md b/docs/guides/reviewing-pull-requests.md index 86e49a4041ae..68dcacaccd09 100644 --- a/docs/guides/reviewing-pull-requests.md +++ b/docs/guides/reviewing-pull-requests.md @@ -38,6 +38,10 @@ ### 1. Initial Review +> [!IMPORTANT] Pull requests must use and fill out the pull request +> +> [template](https://github.com/carbon-design-system/carbon/blob/main/.github/PULL_REQUEST_TEMPLATE.md). + - **PR Body & Issue Context:** - Read the PR description to understand the overall scope. - Review the related issue (or design spec) to grasp the root problem and From afb89eb6222791907ca9516ced04d3428a1f5eec Mon Sep 17 00:00:00 2001 From: Heloise Lui <71858203+heloiselui@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:46:29 +0100 Subject: [PATCH 009/266] fix(datepicker): add weekday to calendar aria-labels (#22141) * fix(react): add weekday to date picker aria-labels and added tests * fix(wc): add weekday to date picker aria-labels and added tests --- .../components/DatePicker/DatePicker-test.js | 30 ++++++++++++++++--- .../src/components/DatePicker/DatePicker.tsx | 2 ++ .../date-picker/__tests__/date-picker-test.js | 19 ++++++++++++ .../src/components/date-picker/date-picker.ts | 7 +++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/DatePicker/DatePicker-test.js b/packages/react/src/components/DatePicker/DatePicker-test.js index 08f89e1fce16..c9666fc29987 100644 --- a/packages/react/src/components/DatePicker/DatePicker-test.js +++ b/packages/react/src/components/DatePicker/DatePicker-test.js @@ -653,6 +653,28 @@ describe('Single date picker', () => { expect(screen.getByRole('application')).toBeInTheDocument(); }); + it('should include the weekday in the calendar day aria-label', async () => { + render( + {}} + datePickerType="single" + value="04/15/2026"> + + + ); + + await userEvent.click(screen.getByTestId('input')); + + expect( + screen.getByLabelText('Wednesday, April 15, 2026') + ).toBeInTheDocument(); + }); + it('should update the calendar classnames when open', async () => { render( { // eslint-disable-next-line testing-library/no-node-access const belowMinDate = document.querySelector( - '[aria-label="November 26, 2023"]' + '[aria-label="Sunday, November 26, 2023"]' ); await userEvent.click(belowMinDate); @@ -1689,7 +1711,7 @@ describe('Date picker with minDate and maxDate', () => { await userEvent.click(screen.getByTestId('input')); const disabledDate = document.querySelector( - '[aria-label="January 2, 2018"]' + '[aria-label="Tuesday, January 2, 2018"]' ); expect(disabledDate).toHaveClass('flatpickr-disabled'); @@ -1744,7 +1766,7 @@ describe('Date picker with minDate and maxDate', () => { ); // eslint-disable-next-line testing-library/no-node-access const belowMinDate = document.querySelector( - '[aria-label="December 31, 2017"]' + '[aria-label="Sunday, December 31, 2017"]' ); await userEvent.click(screen.getByTestId('input-min-max')); await userEvent.click(belowMinDate); @@ -1772,7 +1794,7 @@ describe('Date picker with minDate and maxDate', () => { // eslint-disable-next-line testing-library/no-node-access const aboveMaxDate = document.querySelector( - '[aria-label="January 4, 2018"]' + '[aria-label="Thursday, January 4, 2018"]' ); await userEvent.click(screen.getByTestId('input-min-max-2')); diff --git a/packages/react/src/components/DatePicker/DatePicker.tsx b/packages/react/src/components/DatePicker/DatePicker.tsx index 847e7ae2a9f9..7219df01a7a6 100644 --- a/packages/react/src/components/DatePicker/DatePicker.tsx +++ b/packages/react/src/components/DatePicker/DatePicker.tsx @@ -54,6 +54,7 @@ function initializeWeekdayShorthand() { } const forEach = Array.prototype.forEach; +const defaultAriaDateFormat = 'l, F j, Y'; /** * @param {number} monthNumber The month number. @@ -603,6 +604,7 @@ const DatePicker = forwardRef((props, ref) => { inline: inline ?? false, onClose: onCalendarClose, disableMobile: true, + ariaDateFormat: defaultAriaDateFormat, defaultDate: value, closeOnSelect: closeOnSelect, mode: datePickerType, diff --git a/packages/web-components/src/components/date-picker/__tests__/date-picker-test.js b/packages/web-components/src/components/date-picker/__tests__/date-picker-test.js index e147900c7ed3..fc1b88f99abd 100644 --- a/packages/web-components/src/components/date-picker/__tests__/date-picker-test.js +++ b/packages/web-components/src/components/date-picker/__tests__/date-picker-test.js @@ -126,6 +126,25 @@ describe('cds-date-picker', () => { expect(el).to.exist; }); + it('should include the weekday in the calendar day aria-label', async () => { + const el = await fixture(html` + + + + + `); + await el.updateComplete; + + const day = el.calendar?.calendarContainer?.querySelector( + '[aria-label="Wednesday, April 15, 2026"]' + ); + + expect(day).to.exist; + }); + it('should handle value changes', async () => { const el = await fixture(html` diff --git a/packages/web-components/src/components/date-picker/date-picker.ts b/packages/web-components/src/components/date-picker/date-picker.ts index 11ef965957e0..ae7c48eab177 100644 --- a/packages/web-components/src/components/date-picker/date-picker.ts +++ b/packages/web-components/src/components/date-picker/date-picker.ts @@ -202,6 +202,8 @@ class CDSDatePicker extends HostListenerMixin(FormMixin(LitElement)) { : enabledRange.split('/'); return { allowInput: this.allowInput, + ariaDateFormat: (this.constructor as typeof CDSDatePicker) + .defaultAriaDateFormat, closeOnSelect: this.closeOnSelect, dateFormat: this.dateFormat ?? @@ -663,6 +665,11 @@ class CDSDatePicker extends HostListenerMixin(FormMixin(LitElement)) { */ static classNoBorder = 'no-border'; + /** + * The default aria date format. + */ + static defaultAriaDateFormat = 'l, F j, Y'; + /** * The default date format. */ From cc54e3bffd5d8ae5040061af5ac050c4d4867308 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Thu, 30 Apr 2026 03:02:25 -0500 Subject: [PATCH 010/266] fix(icon-build-helpers): preserve non-artboard rects (#22129) --- .../src/metadata/extensions/output/optimizer.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/icon-build-helpers/src/metadata/extensions/output/optimizer.js b/packages/icon-build-helpers/src/metadata/extensions/output/optimizer.js index a34fcfc69d8a..2068e2f73be9 100644 --- a/packages/icon-build-helpers/src/metadata/extensions/output/optimizer.js +++ b/packages/icon-build-helpers/src/metadata/extensions/output/optimizer.js @@ -68,10 +68,20 @@ const plugins = [ if (node.name === 'rect') { const width = node.attributes?.width; const height = node.attributes?.height; + const x = node.attributes?.x; + const y = node.attributes?.y; + const rx = node.attributes?.rx; + const ry = node.attributes?.ry; + const isZeroOrMissing = (value) => + value === undefined || Number(value) === 0; if ( sizes.includes(width) && sizes.includes(height) && - width === height + width === height && + isZeroOrMissing(x) && + isZeroOrMissing(y) && + isZeroOrMissing(rx) && + isZeroOrMissing(ry) ) { if (parentNode && parentNode.children) { parentNode.children = parentNode.children.filter( From 1634ed2f28fa30e09cb8cfa9f1a3482dfda3d775 Mon Sep 17 00:00:00 2001 From: Riddhi Bansal <41935566+riddhybansal@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:38:04 +0530 Subject: [PATCH 011/266] feat: added xs size for pagination (#21695) * feat: added xs size for pagination * fix: added xs pagination for web-components * fix: removed extra style * fix: updated snapshots * fix: added comment * fix: added sizes by contextual token * fix: xs size with contextual tokens in web components * fix: added todo comment --- .../__snapshots__/PublicAPI-test.js.snap | 1 + .../Pagination/Pagination.stories.js | 4 +- .../src/components/Pagination/Pagination.tsx | 7 +-- .../Pagination/__tests__/Pagination-test.js | 29 ++++++++++++ .../components/pagination/_pagination.scss | 44 +++---------------- .../src/components/pagination/defs.ts | 4 ++ .../src/components/pagination/pagination.scss | 10 ++--- .../pagination/pagination.stories.ts | 1 + 8 files changed, 51 insertions(+), 49 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index dafea54dd6a1..27b84e2d2357 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -7831,6 +7831,7 @@ Map { "size": { "args": [ [ + "xs", "sm", "md", "lg", diff --git a/packages/react/src/components/Pagination/Pagination.stories.js b/packages/react/src/components/Pagination/Pagination.stories.js index 52485cae1062..5b205be152fb 100644 --- a/packages/react/src/components/Pagination/Pagination.stories.js +++ b/packages/react/src/components/Pagination/Pagination.stories.js @@ -30,7 +30,7 @@ export default { component: Pagination, argTypes: { size: { - options: ['sm', 'md', 'lg'], + options: ['xs', 'sm', 'md', 'lg'], control: { type: 'select' }, }, }, @@ -140,7 +140,7 @@ Default.argTypes = { }, }, size: { - options: ['sm', 'md', 'lg'], + options: ['xs', 'sm', 'md', 'lg'], control: { type: 'select' }, }, totalItems: { diff --git a/packages/react/src/components/Pagination/Pagination.tsx b/packages/react/src/components/Pagination/Pagination.tsx index 964d20597e96..acf28563ad73 100644 --- a/packages/react/src/components/Pagination/Pagination.tsx +++ b/packages/react/src/components/Pagination/Pagination.tsx @@ -134,7 +134,7 @@ export interface PaginationProps /** * Specify the size of the Pagination. */ - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; /** * The total number of items. @@ -237,7 +237,8 @@ const Pagination = React.forwardRef( const className = cx({ [`${prefix}--pagination`]: true, - [`${prefix}--pagination--${size}`]: size, + [`${prefix}--pagination--${size}`]: size, // TODO: V12 - Remove this class + [`${prefix}--layout--size-${size}`]: size, [customClassName]: !!customClassName, }); const totalPages = totalItems @@ -646,7 +647,7 @@ Pagination.propTypes = { /** * Specify the size of the Pagination. */ - size: PropTypes.oneOf(['sm', 'md', 'lg']), + size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), /** * The total number of items. diff --git a/packages/react/src/components/Pagination/__tests__/Pagination-test.js b/packages/react/src/components/Pagination/__tests__/Pagination-test.js index 2f0082837383..cb15590ed6aa 100644 --- a/packages/react/src/components/Pagination/__tests__/Pagination-test.js +++ b/packages/react/src/components/Pagination/__tests__/Pagination-test.js @@ -411,6 +411,35 @@ describe('Pagination', () => { const { container } = render(); expect(container.firstChild).toHaveClass('cds--pagination--sm'); + expect(container.firstChild).toHaveClass('cds--layout--size-sm'); + }); + + it('should respect xs size prop', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('cds--pagination--xs'); + expect(container.firstChild).toHaveClass('cds--layout--size-xs'); + }); + + it('should respect sm size prop', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('cds--pagination--sm'); + expect(container.firstChild).toHaveClass('cds--layout--size-sm'); + }); + + it('should respect md size prop', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('cds--pagination--md'); + expect(container.firstChild).toHaveClass('cds--layout--size-md'); + }); + + it('should respect lg size prop', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('cds--pagination--lg'); + expect(container.firstChild).toHaveClass('cds--layout--size-lg'); }); it('should respect totalItems prop', () => { diff --git a/packages/styles/scss/components/pagination/_pagination.scss b/packages/styles/scss/components/pagination/_pagination.scss index 61e381f03a08..633d4b52861f 100644 --- a/packages/styles/scss/components/pagination/_pagination.scss +++ b/packages/styles/scss/components/pagination/_pagination.scss @@ -18,6 +18,7 @@ @use '../../utilities/convert'; @use '../../utilities/focus-outline' as *; @use '../../utilities/high-contrast-mode' as *; +@use '../../utilities/layout'; /// Pagination styles /// @access public @@ -30,6 +31,7 @@ .#{$prefix}--pagination { @include component-reset.reset; @include type-style('body-compact-01'); + @include layout.use('size', $default: 'md', $min: 'xs', $max: 'lg'); display: flex; overflow: initial; @@ -40,7 +42,7 @@ container-name: pagination; container-type: inline-size; inline-size: calc(100% - 1px); - min-block-size: convert.to-rem(40px); + min-block-size: layout.size('height'); // 42rem = 'md' breakpoint @container pagination (min-width: 42rem) { @@ -66,14 +68,6 @@ } } - .#{$prefix}--pagination--sm { - min-block-size: convert.to-rem(32px); - } - - .#{$prefix}--pagination--lg { - min-block-size: convert.to-rem(48px); - } - .#{$prefix}--pagination .#{$prefix}--select { align-items: center; block-size: 100%; @@ -88,10 +82,8 @@ @include type-style('body-compact-01'); block-size: 100%; - inline-size: auto; - line-height: convert.to-rem(40px); - + line-height: layout.size('height'); min-inline-size: auto; // Firefox-specific fix for three-digit numbers @@ -109,14 +101,6 @@ padding-inline: $spacing-05 2.25rem; } - .#{$prefix}--pagination--sm .#{$prefix}--select-input { - line-height: convert.to-rem(32px); - } - - .#{$prefix}--pagination--lg .#{$prefix}--select-input { - line-height: convert.to-rem(48px); - } - .#{$prefix}--pagination .#{$prefix}--select-input:hover { background: $layer-hover; } @@ -223,12 +207,12 @@ border: none; margin: 0; background: none; - block-size: convert.to-rem(40px); + block-size: layout.size('height'); border-inline-start: 1px solid $border-subtle; cursor: pointer; fill: $icon-primary; - inline-size: convert.to-rem(40px); - min-block-size: convert.to-rem(32px); + inline-size: layout.size('height'); + min-block-size: convert.to-rem(24px); transition: outline $duration-fast-02 motion(standard, productive), background-color $duration-fast-02 motion(standard, productive); @@ -244,20 +228,6 @@ transform: rotate(0.5turn); } - .#{$prefix}--pagination--sm .#{$prefix}--pagination__button, - .#{$prefix}--pagination--sm - .#{$prefix}--btn--ghost.#{$prefix}--pagination__button { - block-size: convert.to-rem(32px); - inline-size: convert.to-rem(32px); - } - - .#{$prefix}--pagination--lg .#{$prefix}--pagination__button, - .#{$prefix}--pagination--lg - .#{$prefix}--btn--ghost.#{$prefix}--pagination__button { - block-size: convert.to-rem(48px); - inline-size: convert.to-rem(48px); - } - .#{$prefix}--pagination__button:focus, .#{$prefix}--btn--ghost:focus.#{$prefix}--pagination__button { @include focus-outline('outline'); diff --git a/packages/web-components/src/components/pagination/defs.ts b/packages/web-components/src/components/pagination/defs.ts index e43683c5d973..c500f10ddf3a 100644 --- a/packages/web-components/src/components/pagination/defs.ts +++ b/packages/web-components/src/components/pagination/defs.ts @@ -9,6 +9,10 @@ * Pagination size. */ export enum PAGINATION_SIZE { + /** + * Extra small size. + */ + XS = 'xs', /** * Small size. */ diff --git a/packages/web-components/src/components/pagination/pagination.scss b/packages/web-components/src/components/pagination/pagination.scss index 11eb4b92c3ed..a18e9b882c35 100644 --- a/packages/web-components/src/components/pagination/pagination.scss +++ b/packages/web-components/src/components/pagination/pagination.scss @@ -14,9 +14,11 @@ $css--plex: true !default; @use '@carbon/styles/scss/breakpoint' as *; @use '@carbon/styles/scss/components/pagination' as *; @use '@carbon/styles/scss/utilities/visually-hidden' as *; +@use '@carbon/styles/scss/layout' as *; :host(#{$prefix}-pagination) { @extend .#{$prefix}--pagination; + @include emit-layout-tokens(); position: relative; outline: none; @@ -40,10 +42,4 @@ $css--plex: true !default; } } -:host(#{$prefix}-pagination[size='sm']) { - @extend .#{$prefix}--pagination--sm; -} - -:host(#{$prefix}-pagination[size='lg']) { - @extend .#{$prefix}--pagination--lg; -} +@include emit-layout-tokens-to-shadow-host('#{$prefix}-pagination'); diff --git a/packages/web-components/src/components/pagination/pagination.stories.ts b/packages/web-components/src/components/pagination/pagination.stories.ts index 9e1e3c9dc565..aeb45cfc7f14 100644 --- a/packages/web-components/src/components/pagination/pagination.stories.ts +++ b/packages/web-components/src/components/pagination/pagination.stories.ts @@ -13,6 +13,7 @@ import { html } from 'lit'; import { action } from 'storybook/actions'; const sizes = { + [`Extra small size (${PAGINATION_SIZE.XS})`]: PAGINATION_SIZE.XS, [`Small size (${PAGINATION_SIZE.SMALL})`]: PAGINATION_SIZE.SMALL, [`Medium size (${PAGINATION_SIZE.MEDIUM})`]: PAGINATION_SIZE.MEDIUM, [`Large size (${PAGINATION_SIZE.LARGE})`]: PAGINATION_SIZE.LARGE, From a507191e845fcf8baa11d6b4456ada7bdae4c0eb Mon Sep 17 00:00:00 2001 From: Adam Alston Date: Thu, 30 Apr 2026 06:15:21 -0400 Subject: [PATCH 012/266] fix: fire ComboBox onChange on Tab selection (#22007) --- .../src/components/ComboBox/ComboBox-test.js | 24 +++++++++++++++++++ .../src/components/ComboBox/ComboBox.tsx | 5 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/ComboBox/ComboBox-test.js b/packages/react/src/components/ComboBox/ComboBox-test.js index e79c759936e5..39258e37ad5a 100644 --- a/packages/react/src/components/ComboBox/ComboBox-test.js +++ b/packages/react/src/components/ComboBox/ComboBox-test.js @@ -202,6 +202,30 @@ describe('ComboBox', () => { }); }); + it('should call `onChange` when a highlighted item is committed on Tab', async () => { + const user = userEvent.setup(); + + render( + <> + + + + ); + + await user.click(findInputNode()); + await user.type(findInputNode(), 'Item'); + await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab]'); + + expect(mockProps.onChange).toHaveBeenCalledTimes(1); + expect(mockProps.onChange).toHaveBeenCalledWith({ + selectedItem: mockProps.items[1], + }); + expect( + screen.getByRole('button', { name: 'Next focus target' }) + ).toHaveFocus(); + }); + describe('onInputChange', () => { let onInputChange; beforeEach(() => { diff --git a/packages/react/src/components/ComboBox/ComboBox.tsx b/packages/react/src/components/ComboBox/ComboBox.tsx index 54001f3b0466..787e4e141d48 100644 --- a/packages/react/src/components/ComboBox/ComboBox.tsx +++ b/packages/react/src/components/ComboBox/ComboBox.tsx @@ -895,9 +895,10 @@ const ComboBox = forwardRef( if ( (type === ItemClick || type === FunctionSelectItem || - type === InputKeyDownEnter) && + type === InputKeyDownEnter || + (!allowCustomValue && type === InputBlur)) && typeof newSelectedItem !== 'undefined' && - !isEqual(selectedItemProp, newSelectedItem) + !isEqual(currentSelectedItem, newSelectedItem) ) { if (items.some((item) => isEqual(item, newSelectedItem))) { committedCustomValueRef.current = ''; From 5bad7a350480d57f5afff2d1b8b6428277fbdee1 Mon Sep 17 00:00:00 2001 From: Fatma Date: Thu, 30 Apr 2026 11:57:54 +0100 Subject: [PATCH 013/266] chore(Tile): add controls args to every story (#21612) Co-authored-by: Sangeetha Babu Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> --- .../react/src/components/Tile/Tile.stories.js | 178 +++++++++++------- 1 file changed, 106 insertions(+), 72 deletions(-) diff --git a/packages/react/src/components/Tile/Tile.stories.js b/packages/react/src/components/Tile/Tile.stories.js index a440f9af285b..0a58e8518efa 100644 --- a/packages/react/src/components/Tile/Tile.stories.js +++ b/packages/react/src/components/Tile/Tile.stories.js @@ -52,8 +52,12 @@ export default { }, argTypes: { light: { + control: { + type: 'boolean', + }, + description: 'Light variant', table: { - disable: true, + defaultValue: { summary: 'false' }, }, }, slug: { @@ -61,6 +65,32 @@ export default { disable: true, }, }, + decorator: { + table: { + disable: true, + }, + }, + href: { + control: { type: 'text' }, + }, + clicked: { + control: { type: 'boolean' }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + selected: { + control: { + type: 'boolean', + }, + }, + title: { + control: { + type: 'text', + }, + }, }, parameters: { docs: { @@ -69,9 +99,9 @@ export default { }, }; -export const Default = () => { +export const Default = (args) => { return ( - + Default tile

@@ -80,10 +110,20 @@ export const Default = () => { ); }; -export const DefaultWithLayer = () => ( +Default.args = { + light: false, + clicked: false, + disabled: false, + hasRoundedCorners: false, + href: '', + selected: false, + title: '', +}; + +export const DefaultWithLayer = (args) => ( {(layer) => ( - + Default tile

@@ -93,65 +133,51 @@ export const DefaultWithLayer = () => (
); +DefaultWithLayer.args = { + ...Default.args, +}; + export const Clickable = (args) => { return ( - + Clickable Tile ); }; Clickable.args = { + ...Default.args, + href: 'https://www.carbondesignsystem.com/', disabled: false, -}; - -Clickable.argTypes = { - disabled: { - control: { - type: 'boolean', - }, - }, + clicked: false, }; export const ClickableWithCustomIcon = (args) => { return ( - + Clickable Tile ); }; ClickableWithCustomIcon.args = { - disabled: false, -}; - -ClickableWithCustomIcon.argTypes = { - disabled: { - control: { - type: 'boolean', - }, - }, + ...Clickable.args, }; -export const ClickableWithLayer = () => ( +export const ClickableWithLayer = (args) => ( {(layer) => ( - + Clickable tile )} ); +ClickableWithLayer.args = { + ...Clickable.args, +}; + export const Selectable = (args) => { return ( @@ -161,15 +187,10 @@ export const Selectable = (args) => { }; Selectable.args = { + ...Default.args, + selected: false, disabled: false, -}; - -Selectable.argTypes = { - disabled: { - control: { - type: 'boolean', - }, - }, + title: 'title', }; export const MultiSelect = (args) => { @@ -189,15 +210,7 @@ export const MultiSelect = (args) => { }; MultiSelect.args = { - disabled: false, -}; - -MultiSelect.argTypes = { - disabled: { - control: { - type: 'boolean', - }, - }, + ...Selectable.args, }; export const Radio = (args) => { @@ -210,18 +223,16 @@ export const Radio = (args) => { + style={{ marginBottom: '.5rem' }}> Option 1 + style={{ marginBottom: '.5rem' }}> Option 2 - + Option 3 @@ -240,13 +251,14 @@ Radio.argTypes = { }, }; -export const RadioWithLayer = () => ( +export const RadioWithLayer = (args) => ( {(layer) => ( + name={`radio tile group ${layer}`} + {...args}> ( ); -export const Expandable = () => { +RadioWithLayer.args = { + disabled: false, +}; + +export const Expandable = (args) => { return (
- +
Above the fold content here
@@ -279,14 +292,26 @@ export const Expandable = () => { ); }; -export const ExpandableWithInteractive = () => { +Expandable.args = { + ...Default.args, + expanded: false, + tileCollapsedIconText: 'Interact to Expand tile', + tileExpandedIconText: 'Interact to Collapse tile', +}; + +Expandable.argTypes = { + expanded: { control: 'boolean' }, + tileCollapsedIconText: { control: 'text' }, + tileExpandedIconText: { control: 'text' }, +}; + +export const ExpandableWithInteractive = (args) => { return (
console.log('click')} id="expandable-tile-1" - tileCollapsedIconText="Interact to Expand tile" - tileExpandedIconText="Interact to Collapse tile"> + {...args}>
Above the fold content here @@ -306,15 +331,20 @@ export const ExpandableWithInteractive = () => { ); }; -export const ExpandableWithLayer = () => { +ExpandableWithInteractive.args = { + ...Expandable.args, +}; + +ExpandableWithInteractive.argTypes = { + ...Expandable.argTypes, +}; + +export const ExpandableWithLayer = (args) => { return ( {(layer) => (
- +
Above the fold content here
@@ -328,6 +358,10 @@ export const ExpandableWithLayer = () => { ); }; +ExpandableWithLayer.args = { + ...Expandable.args, +}; + const aiLabel = ( From dbb3bfa107005272b4e3a343186f42928212f612 Mon Sep 17 00:00:00 2001 From: Mahmoud <132728978+maradwan26@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:32:53 -0400 Subject: [PATCH 014/266] feat(password-input): new `xs` size (#22112) * feat(password-input): new xs size * test: add tests * fix: invalid icon positioning * fix: use icon-only for fluid variant * refactor: tooltip styles * chore: rename attribute --- .../__snapshots__/PublicAPI-test.js.snap | 1 + .../TextInput/PasswordInput.stories.js | 2 +- .../components/TextInput/PasswordInput.tsx | 12 ++--- .../TextInput/__tests__/PasswordInput-test.js | 9 +++- .../components/text-input/_text-input.scss | 45 +++++-------------- .../fluid-password-input.scss | 15 ------- .../__tests__/password-input-test.js | 12 ++++- .../src/components/password-input/defs.ts | 7 ++- .../password-input/password-input.scss | 18 +------- .../password-input/password-input.stories.ts | 1 + .../password-input/password-input.ts | 11 ++--- .../src/components/tooltip/tooltip.scss | 14 +++++- 12 files changed, 62 insertions(+), 85 deletions(-) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 27b84e2d2357..6b846ab2d56c 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -7983,6 +7983,7 @@ Map { "size": { "args": [ [ + "xs", "sm", "md", "lg", diff --git a/packages/react/src/components/TextInput/PasswordInput.stories.js b/packages/react/src/components/TextInput/PasswordInput.stories.js index 1787acd4f706..82d894e218fb 100644 --- a/packages/react/src/components/TextInput/PasswordInput.stories.js +++ b/packages/react/src/components/TextInput/PasswordInput.stories.js @@ -113,7 +113,7 @@ Default.argTypes = { action: 'onClick', }, size: { - options: ['sm', 'md', 'lg', 'xl'], + options: ['xs', 'sm', 'md', 'lg'], control: { type: 'select', }, diff --git a/packages/react/src/components/TextInput/PasswordInput.tsx b/packages/react/src/components/TextInput/PasswordInput.tsx index 45d8c6669f7e..2605949c9fbe 100644 --- a/packages/react/src/components/TextInput/PasswordInput.tsx +++ b/packages/react/src/components/TextInput/PasswordInput.tsx @@ -140,9 +140,9 @@ export interface PasswordInputProps showPasswordLabel?: string; /** - * Specify the size of the Text Input. Supports `sm`, `md`, or `lg`. + * Specify the size of the Text Input. Supports `xs`, `sm`, `md`, or `lg`. */ - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; /** * Specify the alignment of the tooltip to the icon-only button. @@ -196,7 +196,7 @@ const PasswordInput = forwardRef( onTogglePasswordVisibility, placeholder, readOnly, - size = 'md', + size, showPasswordLabel = 'Show password', tooltipPosition = 'bottom', tooltipAlignment = 'end', @@ -234,7 +234,6 @@ const PasswordInput = forwardRef( [`${prefix}--text-input--invalid`]: normalizedProps.invalid, [`${prefix}--text-input--warning`]: normalizedProps.warn, [`${prefix}--text-input--${size}`]: size, // TODO: V12 - Remove this class - [`${prefix}--layout--size-${size}`]: size, } ); const sharedTextInputProps = { @@ -289,6 +288,7 @@ const PasswordInput = forwardRef( `${prefix}--text-input__field-wrapper`, { [`${prefix}--text-input__field-wrapper--warning`]: normalizedProps.warn, + [`${prefix}--layout--size-${size}`]: size, } ); const iconClasses = classNames({ @@ -529,9 +529,9 @@ PasswordInput.propTypes = { showPasswordLabel: PropTypes.string, /** - * Specify the size of the Text Input. Supports `sm`, `md`, or `lg`. + * Specify the size of the Text Input. Supports `xs`, `sm`, `md`, or `lg`. */ - size: PropTypes.oneOf(['sm', 'md', 'lg']), + size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), /** * Specify the alignment of the tooltip to the icon-only button. diff --git a/packages/react/src/components/TextInput/__tests__/PasswordInput-test.js b/packages/react/src/components/TextInput/__tests__/PasswordInput-test.js index 933ad0e929e7..500c61e42759 100644 --- a/packages/react/src/components/TextInput/__tests__/PasswordInput-test.js +++ b/packages/react/src/components/TextInput/__tests__/PasswordInput-test.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2022 + * Copyright IBM Corp. 2022, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -222,13 +222,18 @@ describe('PasswordInput', () => { }); it('should respect size prop', () => { - render( + const { container } = render( ); expect(screen.getByLabelText('PasswordInput label')).toHaveClass( `${prefix}--text-input--sm` ); + + const fieldWrapper = container.querySelector( + `.${prefix}--text-input__field-wrapper` + ); + expect(fieldWrapper).toHaveClass(`${prefix}--layout--size-sm`); }); it('should respect type prop', () => { diff --git a/packages/styles/scss/components/text-input/_text-input.scss b/packages/styles/scss/components/text-input/_text-input.scss index ccd287ba318e..8943077a094f 100644 --- a/packages/styles/scss/components/text-input/_text-input.scss +++ b/packages/styles/scss/components/text-input/_text-input.scss @@ -61,15 +61,7 @@ } .#{$prefix}--password-input { - padding-inline-end: $spacing-08; - } - - .#{$prefix}--text-input--sm.#{$prefix}--password-input { - padding-inline-end: $spacing-07; - } - - .#{$prefix}--text-input--lg.#{$prefix}--password-input { - padding-inline-end: $spacing-09; + padding-inline-end: layout.size('height'); } .#{$prefix}--text-input::placeholder { @@ -123,33 +115,26 @@ justify-content: center; padding: 0; border: 0; + aspect-ratio: 1; background: none; block-size: 100%; cursor: pointer; - inline-size: convert.to-rem(40px); inset-inline-end: 0; min-block-size: auto; transition: outline $duration-fast-01 motion(standard, productive); } .#{$prefix}--toggle-password-tooltip .#{$prefix}--popover { - inset-inline-start: -($spacing-08); + @include layout.use('size', $default: 'md', $min: 'xs', $max: 'lg'); + + gap: $spacing-02; + inset-inline-start: calc(-1 * (layout.size('height'))); } .#{$prefix}--toggle-password-tooltip .#{$prefix}--popover-content { min-inline-size: $spacing-08; } - .#{$prefix}--text-input--sm - + .#{$prefix}--btn.#{$prefix}--text-input--password__visibility__toggle.#{$prefix}--tooltip__trigger { - inline-size: convert.to-rem(32px); - } - - .#{$prefix}--text-input--lg - + .#{$prefix}--btn.#{$prefix}--text-input--password__visibility__toggle.#{$prefix}--tooltip__trigger { - inline-size: convert.to-rem(48px); - } - .#{$prefix}--btn.#{$prefix}--text-input--password__visibility__toggle.#{$prefix}--tooltip__trigger svg { fill: $icon-primary; @@ -164,17 +149,15 @@ padding-inline-end: $spacing-08; } - .#{$prefix}--text-input--invalid.#{$prefix}--password-input { - padding-inline-end: convert.to-rem(64px); - } - - .#{$prefix}--text-input--invalid - + .#{$prefix}--text-input--password__visibility__toggle { - inset-inline-end: $spacing-05; + .#{$prefix}--text-input--invalid.#{$prefix}--password-input, + .#{$prefix}--text-input--warning.#{$prefix}--password-input { + padding-inline-end: calc(layout.size('height') + 1.5rem); } .#{$prefix}--password-input-wrapper .#{$prefix}--text-input__invalid-icon { - inset-inline-end: $spacing-08; + @include layout.use('size', $default: 'md', $min: 'xs', $max: 'lg'); + + inset-inline-end: layout.size('height'); } .#{$prefix}--text-input:disabled @@ -251,10 +234,6 @@ @include focus-outline('invalid'); box-shadow: none; - - .#{$prefix}--text-input--password__visibility__toggle { - inset-inline-end: $spacing-08; - } } //----------------------------- diff --git a/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss b/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss index 79c177c87b4f..f88b6042b6b8 100644 --- a/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss +++ b/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss @@ -47,22 +47,7 @@ $css--plex: true !default; inline-size: 100%; } - .#{$prefix}--btn--icon-only { - border: none; - background-color: transparent; - block-size: 100%; - cursor: pointer; - inline-size: 100%; - - &:focus { - @include focus-outline('outline'); - } - } - .#{$prefix}--text-input--password__visibility__toggle svg { pointer-events: none; } - .#{$prefix}--toggle-password-tooltip { - block-size: 100%; - } } diff --git a/packages/web-components/src/components/password-input/__tests__/password-input-test.js b/packages/web-components/src/components/password-input/__tests__/password-input-test.js index 15687329f816..be74a1a37512 100644 --- a/packages/web-components/src/components/password-input/__tests__/password-input-test.js +++ b/packages/web-components/src/components/password-input/__tests__/password-input-test.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2025 + * Copyright IBM Corp. 2025, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -147,6 +147,16 @@ describe('cds-password-input', () => { expect( Array.from(classList).some((cls) => cls.includes('--text-input--sm')) ).to.be.true; + + const fieldWrapper = el.shadowRoot.querySelector( + '.cds--text-input__field-wrapper' + ); + const fieldWrapperClassList = fieldWrapper?.classList || []; + expect( + Array.from(fieldWrapperClassList).some((cls) => + cls.includes('--layout--size-sm') + ) + ).to.be.true; }); it('should apply type attribute', async () => { diff --git a/packages/web-components/src/components/password-input/defs.ts b/packages/web-components/src/components/password-input/defs.ts index 55d1a6300154..f9e79948619a 100644 --- a/packages/web-components/src/components/password-input/defs.ts +++ b/packages/web-components/src/components/password-input/defs.ts @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2025 + * Copyright IBM Corp. 2025, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -11,6 +11,11 @@ export { FORM_ELEMENT_COLOR_SCHEME as INPUT_COLOR_SCHEME } from '../../globals/s * Input size. */ export enum INPUT_SIZE { + /** + * Extra small size. + */ + EXTRA_SMALL = 'xs', + /** * Small size. */ diff --git a/packages/web-components/src/components/password-input/password-input.scss b/packages/web-components/src/components/password-input/password-input.scss index 2e1baa8c78b4..e8ba1cd2cdae 100644 --- a/packages/web-components/src/components/password-input/password-input.scss +++ b/packages/web-components/src/components/password-input/password-input.scss @@ -1,5 +1,5 @@ // -// Copyright IBM Corp. 2025 +// Copyright IBM Corp. 2025, 2026 // // This source code is licensed under the Apache-2.0 license found in the // LICENSE file in the root directory of this source tree. @@ -20,10 +20,6 @@ $css--plex: true !default; inline-size: 100%; outline: none; - padding-inline-end: $spacing-08; - .#{$prefix}--text-input__invalid-icon { - inset-inline-end: $spacing-08; - } .#{$prefix}--icon-visibility-on, .#{$prefix}--icon-visibility-off { vertical-align: middle; @@ -44,18 +40,6 @@ $css--plex: true !default; inline-size: 100%; } - .#{$prefix}--btn--icon-only { - border: none; - background-color: transparent; - block-size: 100%; - cursor: pointer; - inline-size: 100%; - - &:focus { - @include focus-outline('outline'); - } - } - .#{$prefix}--text-input--password__visibility__toggle svg { pointer-events: none; } diff --git a/packages/web-components/src/components/password-input/password-input.stories.ts b/packages/web-components/src/components/password-input/password-input.stories.ts index 215d17b02248..499eeb6f0087 100644 --- a/packages/web-components/src/components/password-input/password-input.stories.ts +++ b/packages/web-components/src/components/password-input/password-input.stories.ts @@ -17,6 +17,7 @@ import { } from './password-input'; const sizes = { + [`Extra small size (${INPUT_SIZE.EXTRA_SMALL})`]: INPUT_SIZE.EXTRA_SMALL, [`Small size (${INPUT_SIZE.SMALL})`]: INPUT_SIZE.SMALL, [`Medium size (${INPUT_SIZE.MEDIUM})`]: INPUT_SIZE.MEDIUM, [`Large size (${INPUT_SIZE.LARGE})`]: INPUT_SIZE.LARGE, diff --git a/packages/web-components/src/components/password-input/password-input.ts b/packages/web-components/src/components/password-input/password-input.ts index 619a5f552e64..995326370b8f 100644 --- a/packages/web-components/src/components/password-input/password-input.ts +++ b/packages/web-components/src/components/password-input/password-input.ts @@ -195,7 +195,6 @@ class CDSPasswordInput extends CDSTextInput { [`${prefix}--text-input--invalid`]: normalizedProps.invalid, [`${prefix}--text-input--warning`]: normalizedProps.warn, [`${prefix}--text-input--${size}`]: size !== undefined, // TODO v12 - remove this class - [`${prefix}--layout--size-${size}`]: size !== undefined, [`${prefix}--password-input`]: true, }); @@ -207,6 +206,7 @@ class CDSPasswordInput extends CDSTextInput { const fieldWrapperClasses = classMap({ [`${prefix}--text-input__field-wrapper`]: true, [`${prefix}--text-input__field-wrapper--warning`]: normalizedProps.warn, + [`${prefix}--layout--size-${size}`]: size !== undefined, }); const labelClasses = classMap({ @@ -228,12 +228,7 @@ class CDSPasswordInput extends CDSTextInput { : iconLoader(View16, { class: `${prefix}--icon-visibility-on` }); const passwordVisibilityTooltipClasses = classMap({ - [`${prefix}--text-input--password__visibility__toggle`]: true, - [`${prefix}--btn`]: true, - [`${prefix}--tooltip__trigger`]: true, - [`${prefix}--tooltip--a11y`]: true, [`${prefix}--toggle-password-tooltip`]: true, - [`${prefix}--btn--disabled`]: normalizedProps.disabled || readonly, [`${prefix}--tooltip--${this.tooltipDirection}`]: this.tooltipDirection, [`${prefix}--tooltip--align-${this.tooltipAlignment}`]: this.tooltipAlignment, @@ -241,10 +236,9 @@ class CDSPasswordInput extends CDSTextInput { const passwordVisibilityButtonClasses = classMap({ [`${prefix}--text-input--password__visibility__toggle`]: true, - [`${prefix}--btn--icon-only`]: true, + [`${prefix}--btn`]: true, [`${prefix}--tooltip__trigger`]: true, [`${prefix}--tooltip--a11y`]: true, - [`${prefix}--toggle-password-tooltip`]: true, [`${prefix}--btn--disabled`]: normalizedProps.disabled || readonly, }); @@ -335,6 +329,7 @@ class CDSPasswordInput extends CDSTextInput { ${passwordIsVisible ? this.hidePasswordLabel diff --git a/packages/web-components/src/components/tooltip/tooltip.scss b/packages/web-components/src/components/tooltip/tooltip.scss index e4700beb2bfb..b76d842d9be2 100644 --- a/packages/web-components/src/components/tooltip/tooltip.scss +++ b/packages/web-components/src/components/tooltip/tooltip.scss @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2019, 2024 + * Copyright IBM Corp. 2019, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -7,6 +7,8 @@ @use '@carbon/styles/scss/config' as *; @use '@carbon/styles/scss/components/tooltip'; +@use '@carbon/styles/scss/spacing' as *; +@use '@carbon/styles/scss/utilities/layout'; @use '@carbon/styles/scss/theme' as *; // For some reason `#{prefix}-tooltip` doesn't work here, will need to investigate @@ -48,3 +50,13 @@ :host(#{$prefix}-definition-tooltip) #{$prefix}-popover-content::part(content) { @extend .#{$prefix}--definition-tooltip; } + +// Password input "Show/Hide Password" tooltip +:host(#{$prefix}-tooltip-content[toggle-password-tooltip-content]) { + .#{$prefix}--popover { + @include layout.use('size', $default: 'md', $min: 'xs', $max: 'lg'); + + gap: $spacing-02; + inset-inline-start: calc(-1 * layout.size('height')); + } +} From 046c4486421931de3ab21b960749bb996c45fbb7 Mon Sep 17 00:00:00 2001 From: Sangeetha Babu Date: Thu, 30 Apr 2026 17:27:59 +0530 Subject: [PATCH 015/266] feat(tabs): create dismissible story (#21571) * feat(tabs): create dismissible story * feat(tabs): update prop name * feat(Tabs): update dismissable story * feat(tabs): disbaled issue handling * feat(tabs): add dimissable conatined tabs story * feat(tabs): contined style update * fix(tabs): update license year * feat(tabs): feedback for index and type * fix(tab): update name * feat(tabs): dismiss logic update * feat(tabs): style parity with react * feat(tabs): update aria hidden for dismisable * fix(tabs): update overview * fix(tabs): update controls * fix(tabs): contained hover icon hover * fix(tabs): update selectedIndex position --- .../components/tabs/stories/tabs-wrapper.ts | 133 ++++++++++++++++++ .../web-components/src/components/tabs/tab.ts | 90 +++++++++++- .../src/components/tabs/tabs-story.scss | 20 ++- .../src/components/tabs/tabs.mdx | 8 ++ .../src/components/tabs/tabs.scss | 73 +++++++++- .../src/components/tabs/tabs.stories.ts | 60 ++++++++ .../src/components/tabs/tabs.ts | 95 +++++++++++-- 7 files changed, 456 insertions(+), 23 deletions(-) create mode 100644 packages/web-components/src/components/tabs/stories/tabs-wrapper.ts diff --git a/packages/web-components/src/components/tabs/stories/tabs-wrapper.ts b/packages/web-components/src/components/tabs/stories/tabs-wrapper.ts new file mode 100644 index 000000000000..97c263ecafec --- /dev/null +++ b/packages/web-components/src/components/tabs/stories/tabs-wrapper.ts @@ -0,0 +1,133 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { LitElement, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { prefix } from '../../../globals/settings'; +import { TABS_TYPE } from '../tabs'; +import '../../button'; + +/** + * Wrapper component for dismissable tabs story with state management + */ +export class DismissableTabsWrapper extends LitElement { + private _defaultTabs = [ + { id: 'all', label: 'Dashboard', value: 'all' }, + { id: 'cloudFoundry', label: 'Monitoring', value: 'cloudFoundry' }, + { id: 'staging', label: 'Activity', value: 'staging' }, + { id: 'dea', label: 'Settings', value: 'dea', disabled: true }, + ]; + /** + * Array of tab configurations + */ + @state() + private _tabs = this._defaultTabs; + + /** + * Whether tabs are disabled + */ + @property({ type: Boolean }) + disabled = false; + + /** + * Whether tabs are contained + */ + @property({ type: Boolean }) + contained = false; + /** + * Whether the rendered Tab children should be dismissable. + */ + @property({ type: Boolean }) + dismissable = true; + + /** + * Selection mode + */ + @property({ attribute: 'selection-mode' }) + selectionMode = 'automatic'; + + /** + * Selected index for the initially selected content + */ + @property({ attribute: 'selected-index' }) + selectedIndex = 0; + + /** + * Handle tab dismissed event + */ + private _handleDismissed(event: CustomEvent) { + const { index } = event.detail; + this._tabs = this._tabs.filter((_, i) => i !== index); + } + + /** + * Handle before selected event + */ + private _handleBeforeSelected(event: CustomEvent) { + if (this.disabled) { + event.preventDefault(); + } + } + + private resetTabs() { + this._tabs = [...this._defaultTabs]; + } + + render() { + const { resetTabs } = this; + return html` + + Reset + + + ${this._tabs.map( + (tab) => html` + + ${tab.label} + + ` + )} + +
+ ${this._tabs.map( + (tab) => html` + + ` + )} +
+ `; + } + + /** + * Disable shadow DOM to inherit styles from parent + */ + createRenderRoot() { + return this; + } +} + +customElements.define('tabs-story-wrapper', DismissableTabsWrapper); + +// Made with Bob diff --git a/packages/web-components/src/components/tabs/tab.ts b/packages/web-components/src/components/tabs/tab.ts index e5028445d46d..a8fe6983c782 100644 --- a/packages/web-components/src/components/tabs/tab.ts +++ b/packages/web-components/src/components/tabs/tab.ts @@ -7,17 +7,25 @@ import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { property } from 'lit/decorators.js'; +import { property, state } from 'lit/decorators.js'; import { prefix } from '../../globals/settings'; import CDSContentSwitcherItem from '../content-switcher/content-switcher-item'; import { TABS_ICON_SIZE, TABS_TYPE } from './defs'; import styles from './tabs.scss?lit'; import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; +import '../button/button'; +import Close16 from '@carbon/icons/es/close/16.js'; +import iconLoader from '../../globals/internal/icon-loader'; +import { classMap } from 'lit/directives/class-map.js'; /** * Basic tab. * * @element cds-tab + * @fires cds-tab-beingclosed + * The custom event fired before a tab is closed upon a user gesture. + * Cancellation of this event stops changing the user-initiated action. + * @fires cds-tab-closed - The custom event fired after a a tab is closed upon a user gesture. */ @customElement(`${prefix}-tab`) export default class CDSTab extends CDSContentSwitcherItem { @@ -60,6 +68,18 @@ export default class CDSTab extends CDSContentSwitcherItem { @property({ type: Boolean, reflect: true, attribute: 'badge-indicator' }) badgeIndicator = false; + /** + * Whether this tab should be dismissable. + */ + @state() + _dismissable = false; + + /** + * The index of the tab component + */ + @state() + _index = -1; + /** * Handles `slotchange` event. */ @@ -77,13 +97,39 @@ export default class CDSTab extends CDSContentSwitcherItem { } render() { + const navLinkClasses = classMap({ + [`${prefix}--tabs__nav-link`]: true, + [`${prefix}--tabs__nav-link--dismissable`]: this._dismissable, + }); + const closeButtonClasses = classMap({ + [`${prefix}--tabs__nav-item--close`]: this._dismissable, + [`${prefix}--tabs__nav-item--close--hidden`]: !this._dismissable, + }); const { badgeIndicator, disabled, selected, tabTitle, _handleSlotChange: handleSlotChange, + _handleClick: handleClick, } = this; + + const iconButton = html` + + ${iconLoader(Close16, { 'aria-label': 'close', slot: 'icon' })} + + `; + const accessibleLabel = tabTitle || this.getAttribute('aria-label'); const isIconOnly = this.iconOnly || @@ -91,7 +137,7 @@ export default class CDSTab extends CDSContentSwitcherItem { // No `href`/`tabindex` to not to make this `` click-focusable const tabLink = html` ` : ''} + ${iconButton} `; if (isIconOnly && accessibleLabel && !disabled) { @@ -118,5 +165,44 @@ export default class CDSTab extends CDSContentSwitcherItem { return tabLink; } + _handleClick(event: Event) { + event.stopPropagation(); + const init = { + bubbles: true, + cancelable: true, + composed: true, + detail: { + index: this._index, + }, + }; + if ( + this.dispatchEvent( + new CustomEvent( + (this.constructor as typeof CDSTab).eventBeforeClose, + init + ) + ) + ) { + this.dispatchEvent( + new CustomEvent((this.constructor as typeof CDSTab).eventClose, init) + ); + } + } + + /** + * The name of the custom event fired before a tab is closed upon a user gesture. + * Cancellation of this event stops changing the user-initiated action. + */ + static get eventBeforeClose() { + return `${prefix}-tab-beingclosed`; + } + + /** + * The name of the custom event fired after a a tab is closed upon a user gesture. + */ + static get eventClose() { + return `${prefix}-tab-closed`; + } + static styles = styles; } diff --git a/packages/web-components/src/components/tabs/tabs-story.scss b/packages/web-components/src/components/tabs/tabs-story.scss index 7f305658e2f7..85702c1066d7 100644 --- a/packages/web-components/src/components/tabs/tabs-story.scss +++ b/packages/web-components/src/components/tabs/tabs-story.scss @@ -8,15 +8,33 @@ @use '@carbon/styles/scss/config' as *; @use '@carbon/styles/scss/spacing' as *; @use '@carbon/styles/scss/theme' as *; +@use '@carbon/styles/scss/utilities/focus-outline' as *; @use '@carbon/styles/scss/utilities/layout'; @use '@carbon/styles/scss/components/form/form' as form; @include form.form; +body { + @include layout.use('density', $default: 'normal'); +} .#{$prefix}-ce-demo-devenv--tab-panels { flex: 1; align-self: stretch; - padding: $spacing-05; +} + +//----------------------------- +// Tab Panel Styles +//----------------------------- +[role='tabpanel'] { + padding: layout.density('padding-inline'); + + &:focus { + @include focus-outline('outline'); + } +} +// Contained tabs background for panels (using descendant selector to handle wrapper div) +#{$prefix}-tabs[type='contained'] ~ * [role='tabpanel'] { + background: $layer; } .#{$prefix}--tab-content { diff --git a/packages/web-components/src/components/tabs/tabs.mdx b/packages/web-components/src/components/tabs/tabs.mdx index be76b5dd5d98..1262d4aa3d67 100644 --- a/packages/web-components/src/components/tabs/tabs.mdx +++ b/packages/web-components/src/components/tabs/tabs.mdx @@ -48,6 +48,14 @@ flexibility in what is in rendered inside of `cds-tab` and a tab's panel. +### Dismissable + + + +### Dismissable Contained + + + ### Skeleton diff --git a/packages/web-components/src/components/tabs/tabs.scss b/packages/web-components/src/components/tabs/tabs.scss index fca75782be25..b8206680f033 100644 --- a/packages/web-components/src/components/tabs/tabs.scss +++ b/packages/web-components/src/components/tabs/tabs.scss @@ -146,16 +146,15 @@ $inset-transition: inset 110ms motion(standard, productive); } :host(#{$prefix}-tab:not([type='contained'])) { - margin-inline-end: convert.to-rem(1px); - .#{$prefix}--tabs__nav-link { padding-block-end: calc(#{$spacing-03} + #{convert.to-rem(1px)}); padding-block-start: calc(#{$spacing-04} - #{convert.to-rem(1px)}); } } -:host(#{$prefix}-tab:not([type='contained']):last-of-type) { - margin-inline-end: 0; +:host(#{$prefix}-tab:not([type='contained'])) + + :host(#{$prefix}-tab:not([type='contained'])) { + margin-inline-start: convert.to-rem(1px); } :host(#{$prefix}-tab[badge-indicator][icon-only]:not([icon-size='lg'])) @@ -338,7 +337,12 @@ $inset-transition: inset 110ms motion(standard, productive); } :host(#{$prefix}-tab[type='contained']:hover) { - background-color: $layer-accent-hover; + background-color: $layer-accent-01; +} +:host(#{$prefix}-tab[type='contained']) { + .#{$prefix}--tabs__nav-link:hover { + background-color: $layer-accent-hover; + } } // Disabled tab never gets selected, but we guard for manual addition of `selected` attribute @@ -435,3 +439,62 @@ $inset-transition: inset 110ms motion(standard, productive); background-color: SelectedItem; } } + +:host(#{$prefix}-tab) { + .#{$prefix}--tabs__nav-link--dismissable { + display: flex; + padding-inline-end: $spacing-08; + } + .#{$prefix}--tabs--dismissable.#{$prefix}--tabs--contained { + padding-inline-end: calc($spacing-09 - 1px); + } +} +:host(#{$prefix}-tab[type='contained']) { + .#{$prefix}--tabs__nav-link.#{$prefix}--tabs__nav-link--dismissable { + padding-inline-end: calc($spacing-09 - 1px); + } +} +:host(#{$prefix}-tab) { + .#{$prefix}--tabs__nav-item--close { + position: relative; + display: flex; + align-items: center; + inset-inline-start: calc(-#{$spacing-04} - 1px); + margin-inline-start: calc(-#{$spacing-06} + 1px); + + &[disabled] { + pointer-events: none; + } + } + .#{$prefix}--tabs__nav-item--close--hidden { + display: none; + } + .#{$prefix}--tabs__nav-item--close::part(button) { + @include button-reset.reset(); + + block-size: convert.to-rem(24px); + inline-size: convert.to-rem(24px); + padding-block: $spacing-02; + padding-inline: $spacing-02; + pointer-events: inherit; + + svg { + block-size: convert.to-rem(16px); + fill: $text-secondary; + inline-size: convert.to-rem(16px); + } + + svg:hover { + fill: $icon-primary; + } + + &:hover { + background-color: $layer-hover; + } + + &:focus, + &:active { + @include focus-outline('outline'); + } + } +} diff --git a/packages/web-components/src/components/tabs/tabs.stories.ts b/packages/web-components/src/components/tabs/tabs.stories.ts index f9b5ece32497..e2a28b1d93ec 100644 --- a/packages/web-components/src/components/tabs/tabs.stories.ts +++ b/packages/web-components/src/components/tabs/tabs.stories.ts @@ -27,6 +27,7 @@ import '../button'; import '../checkbox'; import './index'; import '../text-input'; +import './stories/tabs-wrapper'; const args = { contained: false, @@ -207,6 +208,65 @@ export const Contained = { `, }; +export const Dismissable = { + args: { + dismissable: true, + selectedIndex: 0, + }, + argTypes: { + dismissable: { + control: 'boolean', + description: 'Whether the rendered Tab children should be dismissable.', + }, + selectedIndex: { + control: 'number', + description: + 'Specify a selected index for the initially selected content.', + }, + }, + render: ({ dismissable, selectedIndex }) => { + return html` + + + + `; + }, +}; +export const DismissableContained = { + args: { + contained: true, + dismissable: true, + selectedIndex: 0, + }, + argTypes: { + dismissable: { + control: 'boolean', + description: 'Whether the rendered Tab children should be dismissable.', + }, + selectedIndex: { + control: 'number', + description: + 'Specify a selected index for the initially selected content.', + }, + }, + render: ({ contained, dismissable, selectedIndex }) => { + return html` + + + + `; + }, +}; + export const Icon20Only = { args: iconStoriesArgs, argTypes: iconStoriesArgTypes, diff --git a/packages/web-components/src/components/tabs/tabs.ts b/packages/web-components/src/components/tabs/tabs.ts index 61f0111d90a7..0f5a40927e9c 100644 --- a/packages/web-components/src/components/tabs/tabs.ts +++ b/packages/web-components/src/components/tabs/tabs.ts @@ -207,6 +207,48 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { } } + @HostListener('cds-tab-closed') + protected _handleTabClosed(event: CustomEvent) { + const { selectorItem, selectorItemEnabled, selectorItemSelected } = this + .constructor as typeof CDSTabs; + const { index } = event.detail; + + const allTabs = this.querySelectorAll(selectorItem); + const enabledTabsBeforeRemoval = + this.querySelectorAll(selectorItemEnabled); + const activeItem = this.querySelector(selectorItemSelected); + const indexInEnabledTabs = Array.from(enabledTabsBeforeRemoval).indexOf( + allTabs[index] + ); + const activeTabClosed = activeItem === allTabs[index]; + requestAnimationFrame(() => { + const enabledTabs = this.querySelectorAll(selectorItemEnabled); + if (enabledTabs.length > 0) { + if (activeTabClosed) { + enabledTabs[0].selected = true; + this.value = enabledTabs[0].value; + } + const nextHighlightedItem = + indexInEnabledTabs < enabledTabs.length + ? enabledTabs[indexInEnabledTabs] + : enabledTabs[indexInEnabledTabs - 1]; + nextHighlightedItem.highlighted = true; + nextHighlightedItem.shadowRoot + ?.querySelector( + `.${prefix}--tabs__nav-link--dismissable` + ) + ?.focus(); + nextHighlightedItem.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } else { + this.value = ''; + return; + } + }); + } + /** * Handles click on overflow scroll buttons. * @@ -254,6 +296,7 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { if (nextItem) { (nextItem as CDSTab).hideDivider = true; } + this._updateTabsState(); } protected _selectionDidChange( @@ -321,6 +364,12 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { @property({ reflect: true }) type = TABS_TYPE.REGULAR; + /** + * Whether the rendered Tab children should be dismissable. + */ + @property({ type: Boolean, reflect: true }) + dismissable; + /** * Specify the icon size used by icon-only tabs. */ @@ -430,21 +479,7 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { firstUpdated() { // Call super to run content-switcher init logic (initial selection) super.firstUpdated(); - const { selectorTablist, selectorItemEnabled } = this - .constructor as typeof CDSTabs; - const { selectionMode, selectedIndex } = this; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- https://github.com/carbon-design-system/carbon/issues/20452 - const tablist = this.shadowRoot!.querySelector(selectorTablist)!; - this.tablist = tablist; - if (selectionMode === 'manual') { - const firstItem = - this.querySelectorAll(selectorItemEnabled)[selectedIndex]; - if (firstItem) { - firstItem.highlighted = true; - firstItem.selected = true; - this.value = firstItem.value; - } - } + this._tabInitialLoad(); this._cleanAndCreateIntersectionObserverContainer({ create: true }); } @@ -492,6 +527,10 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { this._contentNode.style.insetInlineStart = `-${this._currentScrollPosition}px`; } } + + if (changedProperties.has('dismissable')) { + this._updateTabsState(); + } } /** @@ -582,6 +621,32 @@ export default class CDSTabs extends HostListenerMixin(CDSContentSwitcher) { `; } + protected _updateTabsState() { + const { selectorItem } = this.constructor as typeof CDSTabs; + const tabs = this.querySelectorAll(selectorItem); + tabs.forEach((tab, index) => { + tab._dismissable = this.dismissable; + tab._index = index; + }); + } + + protected _tabInitialLoad() { + const { selectorTablist, selectorItemEnabled } = this + .constructor as typeof CDSTabs; + const { selectionMode, selectedIndex } = this; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- https://github.com/carbon-design-system/carbon/issues/20452 + const tablist = this.shadowRoot!.querySelector(selectorTablist)!; + this.tablist = tablist; + const firstItem = + this.querySelectorAll(selectorItemEnabled)[selectedIndex]; + if (firstItem) { + if (selectionMode === 'manual') { + firstItem.highlighted = true; + } + firstItem.selected = true; + this.value = firstItem.value; + } + } /** * Symbols of keys that triggers opening/closing menu and selecting/deselecting menu item. */ From 55ba1064642c6fc17185e14bb8d9b5269a61b5a5 Mon Sep 17 00:00:00 2001 From: Lee Chase Date: Thu, 30 Apr 2026 15:37:00 +0100 Subject: [PATCH 016/266] feat: add withRowGap property to grid (#21430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add withRowGap property to grid * fix: address review issues * fix: only direct descendant flex row gap * fix: subgrid row gap * fix: sub rowgap for web components * fix: use cds-grid-gutter-start as per column gap * chore: yarn format --------- Co-authored-by: Taylor Jones Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> Co-authored-by: “heloiselui” --- packages/grid/scss/_css-grid.scss | 35 ++++++ packages/grid/scss/_flex-grid.scss | 26 ++++ .../__snapshots__/PublicAPI-test.js.snap | 6 + .../react/src/components/Grid/CSSGrid.tsx | 57 ++++++--- .../react/src/components/Grid/FlexGrid.mdx | 73 +++++++++++ .../src/components/Grid/FlexGrid.stories.js | 74 ++++++++++++ .../react/src/components/Grid/FlexGrid.tsx | 8 ++ packages/react/src/components/Grid/Grid.mdx | 44 +++++++ .../react/src/components/Grid/Grid.stories.js | 113 ++++++++++++++++++ packages/react/src/components/Grid/Grid.tsx | 6 + .../react/src/components/Grid/GridTypes.ts | 6 + .../components/Grid/__tests__/Grid-test.js | 56 +++++++++ .../components/grid/__tests__/grid-test.js | 89 ++++++++++++++ .../src/components/grid/grid.mdx | 31 +++++ .../src/components/grid/grid.scss | 20 ++++ .../src/components/grid/grid.stories.ts | 92 ++++++++++++++ .../src/components/grid/grid.ts | 13 +- 17 files changed, 733 insertions(+), 16 deletions(-) create mode 100644 packages/web-components/src/components/grid/__tests__/grid-test.js diff --git a/packages/grid/scss/_css-grid.scss b/packages/grid/scss/_css-grid.scss index 3a0c50d2b464..e553efcbc0ee 100644 --- a/packages/grid/scss/_css-grid.scss +++ b/packages/grid/scss/_css-grid.scss @@ -150,6 +150,21 @@ )}; } + // ----------------------------------------------------------------------------- + // Row gap + // ----------------------------------------------------------------------------- + + // Add row gap support for CSS Grid + // Row gap should match the total gutter (start + end margins) + .#{$prefix}--css-grid--with-row-gap { + row-gap: var(--cds-grid-gutter); + } + + // Narrow mode has 0 + 1rem = 1rem total gutter + .#{$prefix}--css-grid--narrow.#{$prefix}--css-grid--with-row-gap { + row-gap: calc(var(--cds-grid-gutter) / 2); + } + // ----------------------------------------------------------------------------- // Alignment // ----------------------------------------------------------------------------- @@ -193,6 +208,7 @@ } .#{$prefix}--subgrid--condensed { + --cds-grid-gutter: #{$grid-gutter-condensed}; --cds-grid-gutter-start: #{math.div($grid-gutter-condensed, 2)}; --cds-grid-gutter-end: #{math.div($grid-gutter-condensed, 2)}; --cds-grid-column-hang: #{math.div($grid-gutter, 2) - math.div( @@ -201,6 +217,25 @@ )}; } + // ----------------------------------------------------------------------------- + // Row gap + // ----------------------------------------------------------------------------- + + // Add row gap support for CSS subgrids + // Row gap should match the total gutter (start + end margins) + .#{$prefix}--subgrid--with-row-gap { + row-gap: #{$grid-gutter}; + } + + // Narrow mode has 0 + 1rem = 1rem total gutter + .#{$prefix}--subgrid--narrow.#{$prefix}--subgrid--with-row-gap { + row-gap: #{math.div($grid-gutter, 2)}; + } + + .#{$prefix}--subgrid--condensed.#{$prefix}--subgrid--with-row-gap { + row-gap: #{$grid-gutter-condensed}; + } + // ----------------------------------------------------------------------------- // Column hang // ----------------------------------------------------------------------------- diff --git a/packages/grid/scss/_flex-grid.scss b/packages/grid/scss/_flex-grid.scss index 82da0490b3ba..556c0c5e3654 100644 --- a/packages/grid/scss/_flex-grid.scss +++ b/packages/grid/scss/_flex-grid.scss @@ -318,6 +318,32 @@ padding-block: $condensed-gutter * 0.5; } + // Add row gap support for Flexbox Grid using gap and margin-block-end + .#{$prefix}--grid--with-row-gap { + --cds-grid-row-gap: #{$grid-gutter}; + } + + // Sets the gap between rows and wrapped columns + .#{$prefix}--grid--with-row-gap > .#{$prefix}--row { + gap: var(--cds-grid-row-gap) 0; + margin-block-end: var(--cds-grid-row-gap); + } + + // Condensed mode has 0 in FlexGrid + .#{$prefix}--grid--with-row-gap.#{$prefix}--grid--condensed { + --cds-grid-row-gap: 0; + } + + // Narrow mode has 0 + 1rem = 1rem total gutter + .#{$prefix}--grid--with-row-gap.#{$prefix}--grid--narrow { + --cds-grid-row-gap: calc(#{$grid-gutter} / 2); + } + + // Remove margin from the last row + .#{$prefix}--grid--with-row-gap > .#{$prefix}--row:last-child { + margin-block-end: 0; + } + @include -make-grid-columns($breakpoints, $grid-gutter); @include -no-gutter(); @include -hang($grid-gutter); diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 6b846ab2d56c..dc54b931d6e7 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -4155,6 +4155,9 @@ Map { "narrow": { "type": "bool", }, + "withRowGap": { + "type": "bool", + }, }, "render": [Function], }, @@ -5490,6 +5493,9 @@ Map { "narrow": { "type": "bool", }, + "withRowGap": { + "type": "bool", + }, }, }, "GridSettings" => { diff --git a/packages/react/src/components/Grid/CSSGrid.tsx b/packages/react/src/components/Grid/CSSGrid.tsx index aa73a71adfcd..1a38e120babe 100644 --- a/packages/react/src/components/Grid/CSSGrid.tsx +++ b/packages/react/src/components/Grid/CSSGrid.tsx @@ -29,6 +29,7 @@ const CSSGrid = React.forwardRef< condensed = false, fullWidth = false, narrow = false, + withRowGap, ...rest }, ref? @@ -50,6 +51,7 @@ const CSSGrid = React.forwardRef< as={as} className={customClassName} mode={mode} + withRowGap={withRowGap} {...rest}> {children} @@ -64,6 +66,7 @@ const CSSGrid = React.forwardRef< [`${prefix}--css-grid--full-width`]: fullWidth, [`${prefix}--css-grid--start`]: align === 'start', [`${prefix}--css-grid--end`]: align === 'end', + [`${prefix}--css-grid--with-row-gap`]: withRowGap, }); // cast as any to let TypeScript allow passing in attributes to base component @@ -115,6 +118,12 @@ CSSGrid.propTypes = { * typographic alignment with and without containers. */ narrow: PropTypes.bool, + + /** + * Add a row gap to the grid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + withRowGap: PropTypes.bool, }; type SubgridMode = 'wide' | 'narrow' | 'condensed'; @@ -134,6 +143,12 @@ interface SubgridBaseProps { * Specify the gutter mode for the subgrid */ mode?: SubgridMode; + + /** + * Add a row gap to the subgrid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + withRowGap?: boolean; } // eslint-disable-next-line react/display-name -- https://github.com/carbon-design-system/carbon/issues/20452 @@ -143,21 +158,27 @@ const Subgrid = React.forwardRef< SubgridBaseProps & { as?: React.ElementType; } & React.HTMLAttributes ->(({ as, className: customClassName, children, mode, ...rest }, ref) => { - const prefix = usePrefix(); - const className = cx(customClassName, { - [`${prefix}--subgrid`]: true, - [`${prefix}--subgrid--condensed`]: mode === 'condensed', - [`${prefix}--subgrid--narrow`]: mode === 'narrow', - [`${prefix}--subgrid--wide`]: mode === 'wide', - }); - const BaseComponent = as || 'div'; - return ( - - {children} - - ); -}); +>( + ( + { as, className: customClassName, children, mode, withRowGap, ...rest }, + ref + ) => { + const prefix = usePrefix(); + const className = cx(customClassName, { + [`${prefix}--subgrid`]: true, + [`${prefix}--subgrid--condensed`]: mode === 'condensed', + [`${prefix}--subgrid--narrow`]: mode === 'narrow', + [`${prefix}--subgrid--wide`]: mode === 'wide', + [`${prefix}--subgrid--with-row-gap`]: withRowGap, + }); + const BaseComponent = as || 'div'; + return ( + + {children} + + ); + } +); Subgrid.propTypes = { /** @@ -179,6 +200,12 @@ Subgrid.propTypes = { * Specify the gutter mode for the subgrid */ mode: PropTypes.oneOf(['wide', 'narrow', 'condensed'] as SubgridMode[]), + + /** + * Add a row gap to the grid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + withRowGap: PropTypes.bool, }; const CSSGridComponent: GridComponent = CSSGrid; diff --git a/packages/react/src/components/Grid/FlexGrid.mdx b/packages/react/src/components/Grid/FlexGrid.mdx index cca7d166ac69..391144247ed7 100644 --- a/packages/react/src/components/Grid/FlexGrid.mdx +++ b/packages/react/src/components/Grid/FlexGrid.mdx @@ -24,6 +24,7 @@ import { stackblitzPrefillConfig } from '../../../previewer/codePreviewer'; - [Mix-and-match](#mix-and-match) - [Auto columns](#auto-columns) - [Offset columns](#offset-columns) +- [Row gap](#row-gap) - [Component API](#component-api) - [Using the `as` prop](#using-the-as-prop) - [FAQ](#faq) @@ -230,6 +231,78 @@ breakpoint. At the medium breakpoint, it will be offset by two columns. ]} /> +## Row gap + +The `FlexGrid` component supports an optional `withRowGap` prop that adds +vertical spacing between rows. When enabled, the row gap automatically matches +the current gutter mode: + +- Default (wide): 32px row gap +- Narrow: 16px row gap +- Condensed: 1px row gap + +This feature is useful when you want consistent vertical spacing between rows +that matches your horizontal gutter spacing. + +```jsx +import { FlexGrid, Row, Column } from '@carbon/react'; + +function MyComponent() { + return ( + + + Row 1, Column 1 + Row 1, Column 2 + Row 1, Column 3 + + + Row 2, Column 1 + Row 2, Column 2 + Row 2, Column 3 + + + ); +} +``` + +You can combine `withRowGap` with different gutter modes: + +```jsx +{ + /* Narrow grid with row gap */ +} + + + Content + + + Content + +; + +{ + /* Condensed grid with row gap */ +} + + + Content + + + Content + +; +``` + + stackblitzPrefillConfig(FlexGridStories.WithRowGap), + }, + ]} +/> + ## Component API diff --git a/packages/react/src/components/Grid/FlexGrid.stories.js b/packages/react/src/components/Grid/FlexGrid.stories.js index b6d2d836d07e..48ab72a1dbc7 100644 --- a/packages/react/src/components/Grid/FlexGrid.stories.js +++ b/packages/react/src/components/Grid/FlexGrid.stories.js @@ -453,3 +453,77 @@ Default.argTypes = { }, }, }; + +export const WithRowGap = (args) => { + function DemoContent({ children }) { + return ( +
+
{children}
+
+ ); + } + return ( +
+ + + + Row 1, Col 1 + + + Row 1, Col 2 + + + Row 1, Col 3 + + + Row 1, Col 4 + + + + + Row 2, Col 1 + + + Row 2, Col 2 + + + Row 2, Col 3 + + + Row 2, Col 4 + + + +
+ ); +}; + +WithRowGap.args = { + withRowGap: true, + narrow: false, + condensed: false, +}; + +WithRowGap.argTypes = { + withRowGap: { + control: { + type: 'boolean', + }, + }, + narrow: { + control: { + type: 'boolean', + }, + }, + condensed: { + control: { + type: 'boolean', + }, + }, + children: { + control: false, + }, + className: { + control: false, + }, +}; diff --git a/packages/react/src/components/Grid/FlexGrid.tsx b/packages/react/src/components/Grid/FlexGrid.tsx index 696301504b5b..562200b0dfc6 100644 --- a/packages/react/src/components/Grid/FlexGrid.tsx +++ b/packages/react/src/components/Grid/FlexGrid.tsx @@ -26,6 +26,7 @@ const FlexGrid = React.forwardRef< condensed = false, narrow = false, fullWidth = false, + withRowGap = false, className: containerClassName, children, ...rest @@ -38,6 +39,7 @@ const FlexGrid = React.forwardRef< [`${prefix}--grid--condensed`]: condensed, [`${prefix}--grid--narrow`]: narrow, [`${prefix}--grid--full-width`]: fullWidth, + [`${prefix}--grid--with-row-gap`]: withRowGap, }); // cast as any to let TypeScript allow passing in attributes to base component const BaseComponent = as || 'div'; @@ -83,6 +85,12 @@ FlexGrid.propTypes = { * typographic alignment with and without containers. */ narrow: PropTypes.bool, + + /** + * Add a row gap to the grid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + withRowGap: PropTypes.bool, }; const FlexGridComponent = FlexGrid as GridComponent; diff --git a/packages/react/src/components/Grid/Grid.mdx b/packages/react/src/components/Grid/Grid.mdx index 77677d4a2e25..747d9698d939 100644 --- a/packages/react/src/components/Grid/Grid.mdx +++ b/packages/react/src/components/Grid/Grid.mdx @@ -22,6 +22,7 @@ import { stackblitzPrefillConfig } from '../../../previewer/codePreviewer'; - [Full width grid](#full-width-grid) - [Condensed grid](#condensed-grid) - [Narrow grid](#narrow-grid) +- [Row gap](#row-gap) - [Subgrid](#subgrid) - [Mixed gutter modes](#mixed-gutter-modes) - [Auto columns](#auto-columns) @@ -167,6 +168,49 @@ or the `narrow` prop to enable the narrow grid with a 16px gutter. ]} /> +## Row gap + +By default, the CSS Grid does not add vertical spacing (row gap) between grid +items. You can enable row gap by using the `withRowGap` prop on the `Grid` +component. When enabled, the row gap will automatically match the current gutter +size: + +- Default mode: 2rem (32px) row gap +- Narrow mode: 1rem (16px) row gap +- Condensed mode: 1px row gap + +This is useful when you want consistent vertical spacing between rows that +matches your horizontal gutter spacing. + +```jsx +import { Grid, Column } from '@carbon/react'; + +function MyComponent() { + return ( + + Row 1, Column 1 + Row 1, Column 2 + Row 1, Column 3 + Row 1, Column 4 + Row 2, Column 1 + Row 2, Column 2 + Row 2, Column 3 + Row 2, Column 4 + + ); +} +``` + + stackblitzPrefillConfig(GridStories.WithRowGap), + }, + ]} +/> + ## Subgrid `Grid` components can be nested within one another to achieve more advanced diff --git a/packages/react/src/components/Grid/Grid.stories.js b/packages/react/src/components/Grid/Grid.stories.js index 12f95681bc11..2b7e83756173 100644 --- a/packages/react/src/components/Grid/Grid.stories.js +++ b/packages/react/src/components/Grid/Grid.stories.js @@ -85,6 +85,57 @@ Default.argTypes = { }, }; +export const WithRowGap = (args) => { + return ( +
+ + + + + + + + + + +
+ ); +}; + +WithRowGap.args = { + withRowGap: true, + narrow: false, + condensed: false, +}; + +WithRowGap.argTypes = { + withRowGap: { + control: { + type: 'boolean', + }, + description: + 'Add a row gap to the grid that matches the current gutter size', + }, + narrow: { + control: { + type: 'boolean', + }, + description: 'Container hangs 16px into the gutter', + }, + condensed: { + control: { + type: 'boolean', + }, + description: 'Collapse the gutter to 1px', + }, + children: { + control: false, + }, + className: { + control: false, + }, +}; + export const Narrow = () => { return (
@@ -254,6 +305,68 @@ export const Subgrid = () => { ); }; +export const SubgridWithRowGap = (args) => ( + + + + {/* Nested subgrid with row gap */} + + + + + + + + + {/* Nested subgrid with row gap */} + + + + + + + + + {/* Nested subgrid with row gap */} + + + + + + + +); + +SubgridWithRowGap.args = { + fullWidth: false, + narrow: false, + condensed: false, +}; + +SubgridWithRowGap.argTypes = { + children: { + control: false, + }, + className: { + control: false, + }, + fullWidth: { + control: { + type: 'boolean', + }, + }, + narrow: { + control: { + type: 'boolean', + }, + }, + condensed: { + control: { + type: 'boolean', + }, + }, +}; + export const MixedGutterModes = () => { return (
diff --git a/packages/react/src/components/Grid/Grid.tsx b/packages/react/src/components/Grid/Grid.tsx index 325367c76e5d..e46842a2e1f9 100644 --- a/packages/react/src/components/Grid/Grid.tsx +++ b/packages/react/src/components/Grid/Grid.tsx @@ -57,6 +57,12 @@ Grid.propTypes = { * typographic alignment with and without containers. */ narrow: PropTypes.bool, + + /** + * Add a row gap to the grid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + withRowGap: PropTypes.bool, }; const GridAsGridComponent = Grid as GridComponent; diff --git a/packages/react/src/components/Grid/GridTypes.ts b/packages/react/src/components/Grid/GridTypes.ts index c2a4f7116146..ccef9d64c66f 100644 --- a/packages/react/src/components/Grid/GridTypes.ts +++ b/packages/react/src/components/Grid/GridTypes.ts @@ -40,6 +40,12 @@ export interface GridBaseProps { * typographic alignment with and without containers. */ narrow?: boolean; + + /** + * Add a row gap to the grid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + withRowGap?: boolean; } export type GridProps = diff --git a/packages/react/src/components/Grid/__tests__/Grid-test.js b/packages/react/src/components/Grid/__tests__/Grid-test.js index 331c3f543ee8..a241b7135428 100644 --- a/packages/react/src/components/Grid/__tests__/Grid-test.js +++ b/packages/react/src/components/Grid/__tests__/Grid-test.js @@ -49,6 +49,27 @@ describe('FlexGrid', () => { expect.stringContaining('grid--full-width') ); }); + + it('should support setting the with-row-gap class through the `withRowGap` prop', () => { + const { container } = render(); + expect(container.firstChild.className).toEqual( + expect.stringContaining('grid--with-row-gap') + ); + }); + + it('should not set the with-row-gap class when `withRowGap` is false', () => { + const { container } = render(); + expect(container.firstChild.className).not.toEqual( + expect.stringContaining('grid--with-row-gap') + ); + }); + + it('should not set the with-row-gap class when `withRowGap` is not set', () => { + const { container } = render(); + expect(container.firstChild.className).not.toEqual( + expect.stringContaining('grid--with-row-gap') + ); + }); }); describe('Grid', () => { @@ -125,4 +146,39 @@ describe('Grid', () => { expect.stringContaining('grid--end') ); }); + + it('should support setting the with-row-gap class through the `withRowGap` prop', () => { + const { container } = render(); + expect(container.firstChild.className).toEqual( + expect.stringContaining('css-grid--with-row-gap') + ); + }); + + it('should not set the with-row-gap class when `withRowGap` is false', () => { + const { container } = render(); + expect(container.firstChild.className).not.toEqual( + expect.stringContaining('css-grid--with-row-gap') + ); + }); + + it('should not set the with-row-gap class when `withRowGap` is not set', () => { + const { container } = render(); + expect(container.firstChild.className).not.toEqual( + expect.stringContaining('css-grid--with-row-gap') + ); + }); + + // Add subgrid tests: + it('should support setting the with-row-gap class on subgrid through the `withRowGap` prop', () => { + const { container } = render( + + + + ); + // Query for nested grid with subgrid class + const subgrid = container.querySelector('.cds--subgrid'); + expect(subgrid.className).toEqual( + expect.stringContaining('subgrid--with-row-gap') + ); + }); }); diff --git a/packages/web-components/src/components/grid/__tests__/grid-test.js b/packages/web-components/src/components/grid/__tests__/grid-test.js new file mode 100644 index 000000000000..2433416c2c6f --- /dev/null +++ b/packages/web-components/src/components/grid/__tests__/grid-test.js @@ -0,0 +1,89 @@ +/** + * Copyright IBM Corp. 2024, 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import '@carbon/web-components/es/components/grid/index.js'; +import { expect, fixture, html } from '@open-wc/testing'; + +describe('cds-grid', () => { + it('should support a custom className on the outermost element', async () => { + const el = await fixture(html` + + `); + expect(el).to.have.class('custom-class'); + }); + + it('should render children that are given', async () => { + const el = await fixture(html` + + Test + + `); + const testNode = el.querySelector('#test'); + expect(testNode).to.exist; + }); + + it('should support setting the condensed attribute', async () => { + const el = await fixture(html``); + expect(el).to.have.attribute('condensed'); + expect(el.condensed).to.be.true; + }); + + it('should support setting the full-width attribute', async () => { + const el = await fixture(html``); + expect(el).to.have.attribute('full-width'); + expect(el.fullWidth).to.be.true; + }); + + it('should support setting the narrow attribute', async () => { + const el = await fixture(html``); + expect(el).to.have.attribute('narrow'); + expect(el.narrow).to.be.true; + }); + + it('should support setting the align attribute as start', async () => { + const el = await fixture(html``); + expect(el).to.have.attribute('align', 'start'); + expect(el.align).to.equal('start'); + }); + + it('should support setting the align attribute as end', async () => { + const el = await fixture(html``); + expect(el).to.have.attribute('align', 'end'); + expect(el.align).to.equal('end'); + }); + + it('should support setting the with-row-gap attribute', async () => { + const el = await fixture(html``); + expect(el).to.have.attribute('with-row-gap'); + expect(el.withRowGap).to.be.true; + }); + + it('should not set the with-row-gap attribute when withRowGap is false', async () => { + const el = await fixture(html``); + el.withRowGap = false; + await el.updateComplete; + expect(el).to.not.have.attribute('with-row-gap'); + expect(el.withRowGap).to.be.false; + }); + + it('should not set the with-row-gap attribute when withRowGap is not set', async () => { + const el = await fixture(html``); + expect(el).to.not.have.attribute('with-row-gap'); + expect(el.withRowGap).to.be.false; + }); + + it('should support setting the with-row-gap attribute on subgrid', async () => { + const el = await fixture(html` + + + + `); + const subgrid = el.querySelector(':scope > cds-grid'); + expect(subgrid).to.have.attribute('with-row-gap'); + expect(subgrid.withRowGap).to.be.true; + }); +}); diff --git a/packages/web-components/src/components/grid/grid.mdx b/packages/web-components/src/components/grid/grid.mdx index 5e9d26f39d04..238de5fe54d7 100644 --- a/packages/web-components/src/components/grid/grid.mdx +++ b/packages/web-components/src/components/grid/grid.mdx @@ -17,6 +17,8 @@ import * as GridStories from './grid.stories'; - [Gutter modes](#gutter-modes) - [Wide grid](#wide-grid) - [Condensed grid](#condensed-grid) + - [Narrow grid](#narrow-grid) +- [Row gap](#row-gap) - [Subgrid](#subgrid) - [Mixed gutter modes](#mixed-gutter-modes) - [Auto columns](#auto-columns) @@ -117,6 +119,35 @@ or the `narrow` prop to enable the narrow grid with a 16px gutter. +## Row gap + +By default, the CSS Grid does not add vertical spacing (row gap) between grid +items. You can enable row gap by using the `with-row-gap` attribute on the +`cds-grid` component. When enabled, the row gap will automatically match the +current gutter size: + +- **Default mode**: 2rem (32px) row gap +- **Narrow mode**: 1rem (16px) row gap +- **Condensed mode**: 1px row gap + +This is useful when you want consistent vertical spacing between rows that +matches your horizontal gutter spacing. + +```html + + Row 1, Column 1 + Row 1, Column 2 + Row 1, Column 3 + Row 1, Column 4 + Row 2, Column 1 + Row 2, Column 2 + Row 2, Column 3 + Row 2, Column 4 + +``` + + + ## Subgrid `grid` components can be nested within one another to achieve more advanced diff --git a/packages/web-components/src/components/grid/grid.scss b/packages/web-components/src/components/grid/grid.scss index 74e81bcc4779..a60ae5a5e58e 100644 --- a/packages/web-components/src/components/grid/grid.scss +++ b/packages/web-components/src/components/grid/grid.scss @@ -35,6 +35,14 @@ $css-grid: #{$prefix}--css-grid; @extend .#{$css-grid}--full-width; } +:host(#{$prefix}-grid[with-row-gap]) [grid] { + row-gap: var(--cds-grid-gutter); +} + +:host(#{$prefix}-grid[narrow][with-row-gap]) [grid] { + row-gap: calc(var(--cds-grid-gutter) / 2); +} + /* sub grid styles */ $sub-grid: #{$prefix}--subgrid; :host(#{$prefix}-grid) [subgrid] { @@ -55,5 +63,17 @@ $sub-grid: #{$prefix}--subgrid; @extend .#{$sub-grid}--narrow; } +:host(#{$prefix}-grid) [subgrid][with-row-gap] { + row-gap: calc(var(--cds-grid-gutter-start) + var(--cds-grid-gutter-end)); +} + +:host(#{$prefix}-grid) [subgrid][mode='narrow'][with-row-gap] { + row-gap: var(--cds-grid-gutter-end); +} + +:host(#{$prefix}-grid) [subgrid][mode='condensed'][with-row-gap] { + row-gap: var(--cds-grid-gutter-start); +} + // Column styles @include grid-columns(#{$prefix}-grid); diff --git a/packages/web-components/src/components/grid/grid.stories.ts b/packages/web-components/src/components/grid/grid.stories.ts index 49e2e541f328..8b0699bfc946 100644 --- a/packages/web-components/src/components/grid/grid.stories.ts +++ b/packages/web-components/src/components/grid/grid.stories.ts @@ -88,6 +88,45 @@ export const FullWidth = { `, }; +export const WithRowGap = { + args: { + ...defaultArgs, + withRowGap: true, + }, + argTypes: { + ...controls, + withRowGap: { + control: 'boolean', + description: + 'Add a row gap to the grid that matches the current gutter size', + }, + }, + parameters: { + percy: { + skip: true, + }, + }, + render: ({ condensed, narrow, fullWidth, withRowGap }) => + html` + + + + + + + + + + `, +}; + export const MixedGutterModes = { render: () => html` @@ -338,6 +377,59 @@ export const Subgrid = { `, }; +export const SubgridWithRowGap = { + args: { + ...defaultArgs, + withRowGap: true, + }, + argTypes: { + ...controls, + withRowGap: { + control: 'boolean', + description: + 'Add a row gap to the grid that matches the current gutter size', + }, + }, + render: ({ condensed, narrow, fullWidth, withRowGap }) => + html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, +}; + const meta = { title: 'Elements/Grid', decorators: [ diff --git a/packages/web-components/src/components/grid/grid.ts b/packages/web-components/src/components/grid/grid.ts index e988088437b5..32b5d0652407 100644 --- a/packages/web-components/src/components/grid/grid.ts +++ b/packages/web-components/src/components/grid/grid.ts @@ -46,6 +46,13 @@ class CDSGrid extends LitElement { @property({ reflect: true, attribute: 'full-width', type: Boolean }) fullWidth = false; + /** + * Add a row gap to the grid that matches the current gutter size. + * This is useful when you want consistent vertical spacing between rows. + */ + @property({ reflect: true, attribute: 'with-row-gap', type: Boolean }) + withRowGap = false; + @consume({ context: gridContext, subscribe: true }) @property({ attribute: false }) public gridContextIn?: GridContext; @@ -66,7 +73,11 @@ class CDSGrid extends LitElement { } else if (this.condensed) { subMode = SUB_GRID_MODE.CONDENSED; } - return html`
+ return html`
`; } else { From 6e239c963dba18e0e69204cc9f09df6d0d13d394 Mon Sep 17 00:00:00 2001 From: Riddhi Bansal <41935566+riddhybansal@users.noreply.github.com> Date: Fri, 1 May 2026 00:08:42 +0530 Subject: [PATCH 017/266] feat: fluid combo box web component (#22097) * feat: fluid combo box * fix: ai label styles * chore: format --------- Co-authored-by: Mahmoud --- .../__tests__/fluid-combo-box-test.js | 440 ++++++++++++++++++ .../fluid-combo-box-skeleton.ts | 34 ++ .../fluid-combo-box/fluid-combo-box.mdx | 49 ++ .../fluid-combo-box/fluid-combo-box.scss | 252 ++++++++++ .../fluid-combo-box.stories.ts | 345 ++++++++++++++ .../fluid-combo-box/fluid-combo-box.ts | 48 ++ .../src/components/fluid-combo-box/index.ts | 9 + 7 files changed, 1177 insertions(+) create mode 100644 packages/web-components/src/components/fluid-combo-box/__tests__/fluid-combo-box-test.js create mode 100644 packages/web-components/src/components/fluid-combo-box/fluid-combo-box-skeleton.ts create mode 100644 packages/web-components/src/components/fluid-combo-box/fluid-combo-box.mdx create mode 100644 packages/web-components/src/components/fluid-combo-box/fluid-combo-box.scss create mode 100644 packages/web-components/src/components/fluid-combo-box/fluid-combo-box.stories.ts create mode 100644 packages/web-components/src/components/fluid-combo-box/fluid-combo-box.ts create mode 100644 packages/web-components/src/components/fluid-combo-box/index.ts diff --git a/packages/web-components/src/components/fluid-combo-box/__tests__/fluid-combo-box-test.js b/packages/web-components/src/components/fluid-combo-box/__tests__/fluid-combo-box-test.js new file mode 100644 index 000000000000..666feb85eb6d --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/__tests__/fluid-combo-box-test.js @@ -0,0 +1,440 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { expect, fixture, html } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import '@carbon/web-components/es/components/fluid-combo-box/index.js'; +import '@carbon/web-components/es/components/ai-label/index.js'; + +const fluidComboBox = html` + + Option 1 + Option 2 + Option 3 + +`; + +const fluidComboBoxWithValue = (value) => html` + + Option 1 + Option 2 + Option 3 + +`; + +const getInput = (el) => el.shadowRoot.querySelector('#trigger-button'); +const getItems = (el) => el.querySelectorAll('cds-combo-box-item'); +const getListBox = (el) => el.shadowRoot.querySelector('.cds--list-box'); + +const waitForUpdates = async (el) => { + await el.updateComplete; + await Promise.resolve(); +}; + +const openMenu = async (el) => { + getInput(el).click(); + await waitForUpdates(el); +}; + +const setInputValue = async (el, value) => { + const input = getInput(el); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + await waitForUpdates(el); +}; + +describe('cds-fluid-combo-box', function () { + it('should render', async () => { + const el = await fixture(fluidComboBox); + expect(el).to.exist; + }); + + it('should render title text', async () => { + const el = await fixture(fluidComboBox); + const titleText = el.shadowRoot.querySelector('.cds--label'); + expect(titleText.textContent.trim()).to.equal('Combo box Label'); + }); + + it('should have combo box items', async () => { + const el = await fixture(fluidComboBox); + const items = getItems(el); + expect(items.length).to.equal(3); + }); + + it('should open when clicking the input', async () => { + const el = await fixture(fluidComboBox); + + expect(el.open).to.be.false; + await openMenu(el); + expect(el.open).to.be.true; + }); + + it('should call cds-combo-box-selected when selection is cleared', async () => { + const el = await fixture(fluidComboBoxWithValue('option-1')); + await waitForUpdates(el); + const selectedEvents = []; + + el.addEventListener('cds-combo-box-selected', (event) => { + selectedEvents.push(event.detail); + }); + + await setInputValue(el, 'Option 1'); + await waitForUpdates(el); + + const clearButton = el.shadowRoot.querySelector('#selection-button'); + expect(clearButton).to.exist; + clearButton.click(); + await waitForUpdates(el); + + expect(el.value).to.equal(''); + expect(getInput(el).value).to.equal(''); + expect(selectedEvents.length).to.equal(1); + expect(selectedEvents[0].item).to.equal(undefined); + }); + + it('should not allow selecting a disabled option on click', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + + `); + + await openMenu(el); + el.querySelector('cds-combo-box-item[value="option-2"]').click(); + await waitForUpdates(el); + + expect(el.value).to.equal(''); + }); + + it('should not let the user expand the menu when disabled', async () => { + const el = await fixture(html` + + Option 1 + + `); + const input = getInput(el); + + expect(input.disabled).to.be.true; + input.click(); + await waitForUpdates(el); + + expect(el.open).to.be.false; + }); + + it('should not let the user expand the menu when readonly', async () => { + const el = await fixture(html` + + Option 1 + + `); + const input = getInput(el); + + expect(input.readOnly).to.be.true; + input.click(); + await waitForUpdates(el); + + expect(el.open).to.be.false; + }); + + it('should clear input on blur when no selected item exists and allow-custom-value is false', async () => { + const el = await fixture(fluidComboBox); + + await setInputValue(el, 'no-match'); + getInput(el).dispatchEvent( + new FocusEvent('focusout', { + bubbles: true, + composed: true, + relatedTarget: document.body, + }) + ); + await waitForUpdates(el); + + expect(el.value).to.equal(''); + expect(getInput(el).value).to.equal(''); + }); + + it('should clear input when closing with trigger if no item matches and allow-custom-value is false', async () => { + const el = await fixture(fluidComboBox); + + await setInputValue(el, 'xyz'); + expect(el.open).to.be.true; + expect(getInput(el).value).to.equal('xyz'); + + getInput(el).click(); + await waitForUpdates(el); + + expect(el.open).to.be.false; + expect(el.value).to.equal(''); + expect(getInput(el).value).to.equal(''); + }); + + it('should restore filtered items visibility after closing the menu', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + + `); + const items = getItems(el); + + await setInputValue(el, '2'); + expect(items[0].style.display).to.equal('none'); + expect(items[1].style.display).to.equal(''); + + getInput(el).click(); + await waitForUpdates(el); + + expect(items[0].style.display).to.equal(''); + expect(items[1].style.display).to.equal(''); + }); + + it('should open the menu when Enter is pressed with an empty input', async () => { + const el = await fixture(fluidComboBox); + const items = getItems(el); + + getInput(el).focus(); + await sendKeys({ press: 'Enter' }); + await waitForUpdates(el); + + expect(el.open).to.be.true; + items.forEach((item) => { + expect(item.selected).to.be.false; + }); + }); + + it('should support should-filter-item=true filtering and highlight the first match', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + const items = getItems(el); + + await setInputValue(el, '3'); + + expect(el.open).to.be.true; + expect(items[0].style.display).to.equal('none'); + expect(items[1].style.display).to.equal('none'); + expect(items[2].style.display).to.equal(''); + expect(items[2].highlighted).to.be.true; + }); + + it('should close the menu on Escape when open', async () => { + const el = await fixture(fluidComboBox); + + await openMenu(el); + getInput(el).focus(); + await sendKeys({ press: 'Escape' }); + await waitForUpdates(el); + + expect(el.open).to.be.false; + }); + + it('should keep custom value on Enter when allow-custom-value is set', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + + `); + + await setInputValue(el, 'Apple'); + getInput(el).focus(); + await sendKeys({ press: 'Enter' }); + await waitForUpdates(el); + + expect(el.open).to.be.false; + expect(el.value).to.equal('Apple'); + expect(getInput(el).value).to.equal('Apple'); + }); + + it('should keep custom value on blur when allow-custom-value is set', async () => { + const el = await fixture(html` + + Option 1 + + `); + + await setInputValue(el, 'Apple'); + getInput(el).dispatchEvent( + new FocusEvent('focusout', { + bubbles: true, + composed: true, + relatedTarget: document.body, + }) + ); + await waitForUpdates(el); + + expect(el.value).to.equal('Apple'); + expect(getInput(el).value).to.equal('Apple'); + }); + + it('should emit custom value through cds-combo-box-selected when allow-custom-value is set', async () => { + const el = await fixture(html` + + Option 1 + + `); + let latestDetail; + + el.addEventListener('cds-combo-box-selected', (event) => { + latestDetail = event.detail; + }); + + await setInputValue(el, 'Apple'); + getInput(el).focus(); + await sendKeys({ press: 'Enter' }); + await waitForUpdates(el); + + expect(latestDetail.item).to.equal(null); + expect(latestDetail.value).to.equal('Apple'); + }); + + it('should support AI Label decorator', async () => { + const el = await fixture(html` + + Option 1 + AI + + `); + + await el.updateComplete; + + expect(el.hasAttribute('ai-label')).to.be.true; + + const aiLabel = el.querySelector('cds-ai-label'); + expect(aiLabel).to.exist; + }); + + describe('condensed mode', () => { + it('should apply condensed attribute when is-condensed is set', async () => { + const el = await fixture(html` + + Option 1 + + `); + + await el.updateComplete; + + expect(el.isCondensed).to.be.true; + expect(el.hasAttribute('is-condensed')).to.be.true; + }); + + it('should not apply condensed attribute by default', async () => { + const el = await fixture(fluidComboBox); + + await el.updateComplete; + + expect(el.isCondensed).to.be.false; + expect(el.hasAttribute('is-condensed')).to.be.false; + }); + }); + + describe('invalid and warn states', () => { + it('should not display invalid state when readonly', async () => { + const el = await fixture(html` + + Option 1 + + `); + const listBox = getListBox(el); + + expect(listBox.classList.contains('cds--dropdown--invalid')).to.be.false; + expect(el.shadowRoot.textContent).not.to.contain('Invalid text'); + }); + + it('should not display invalid state when disabled', async () => { + const el = await fixture(html` + + Option 1 + + `); + const listBox = getListBox(el); + + expect(listBox.classList.contains('cds--dropdown--invalid')).to.be.false; + expect(el.shadowRoot.textContent).not.to.contain('Invalid text'); + }); + + it('should not display warn state when readonly', async () => { + const el = await fixture(html` + + Option 1 + + `); + const listBox = getListBox(el); + + expect(listBox.classList.contains('cds--dropdown--warn')).to.be.false; + expect(el.shadowRoot.textContent).not.to.contain('Warning text'); + }); + + it('should not display warn state when disabled', async () => { + const el = await fixture(html` + + Option 1 + + `); + const listBox = getListBox(el); + + expect(listBox.classList.contains('cds--dropdown--warn')).to.be.false; + expect(el.shadowRoot.textContent).not.to.contain('Warning text'); + }); + + it('should display invalid state when not readonly or disabled', async () => { + const el = await fixture(html` + + Option 1 + + `); + const listBox = getListBox(el); + + expect(listBox.classList.contains('cds--dropdown--invalid')).to.be.true; + expect(el.shadowRoot.textContent).to.contain('Invalid text'); + }); + + it('should display warn state when not readonly or disabled', async () => { + const el = await fixture(html` + + Option 1 + + `); + const listBox = getListBox(el); + + expect(listBox.classList.contains('cds--dropdown--warn')).to.be.true; + expect(el.shadowRoot.textContent).to.contain('Warning text'); + }); + }); + + describe('cds-fluid-combo-box-skeleton', function () { + it('should render with the expected classes', async () => { + const el = await fixture( + html`` + ); + expect(el).to.exist; + + const wrapperFluid = el.shadowRoot.querySelector( + '.cds--list-box__wrapper--fluid' + ); + expect(wrapperFluid).to.exist; + + const skeleton = el.shadowRoot.querySelector( + '.cds--skeleton.cds--list-box' + ); + expect(skeleton).to.exist; + + const label = el.shadowRoot.querySelector('.cds--list-box__label'); + expect(label).to.exist; + + const field = el.shadowRoot.querySelector('.cds--list-box__field'); + expect(field).to.exist; + }); + }); +}); diff --git a/packages/web-components/src/components/fluid-combo-box/fluid-combo-box-skeleton.ts b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box-skeleton.ts new file mode 100644 index 000000000000..922ce7588543 --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box-skeleton.ts @@ -0,0 +1,34 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { prefix } from '../../globals/settings'; +import { html, LitElement } from 'lit'; +import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; +import styles from './fluid-combo-box.scss?lit'; + +/** + * Fluid combo box skeleton. + * + * @element cds-fluid-combo-box-skeleton + */ +@customElement(`${prefix}-fluid-combo-box-skeleton`) +class CDSFluidComboBoxSkeleton extends LitElement { + render() { + return html` +
+
+ +
+
+
+ `; + } + + static styles = styles; +} + +export default CDSFluidComboBoxSkeleton; diff --git a/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.mdx b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.mdx new file mode 100644 index 000000000000..b13ced3b46b8 --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.mdx @@ -0,0 +1,49 @@ +import { ArgTypes, Canvas, Markdown, Meta } from '@storybook/addon-docs/blocks'; +import { cdnJs } from '../../globals/internal/storybook-cdn'; +import * as FluidComboBoxStories from './fluid-combo-box.stories'; + + + +# Fluid Combo Box + +[Source code](https://github.com/carbon-design-system/carbon/tree/main/packages/web-components/src/components/fluid-combo-box) + +## Table of Contents + +- [Overview](#overview) +- [Skeleton](#skeleton) +- [Condensed](#condensed) +- [With AI Label](#with-ai-label) +- [Component API](#component-api) +- [CDN](#cdn) +- [Feedback](#feedback) + +## Overview + + + +## Skeleton + + + +## Condensed + + + +## With AI Label + + + +## Component API + +## `cds-fluid-combo-box` + + + +{`${cdnJs({ components: ['fluid-combo-box'] })}`} + +## Feedback + +Help us improve this component by providing feedback, asking questions on Slack, +or updating this file on +[GitHub](https://github.com/carbon-design-system/carbon/edit/main/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.mdx). diff --git a/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.scss b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.scss new file mode 100644 index 000000000000..8001168809b1 --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.scss @@ -0,0 +1,252 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +$css--plex: true !default; +@use '@carbon/styles/scss/config' as *; +@use '@carbon/styles/scss/components/fluid-combo-box/index'; +@use '@carbon/styles/scss/components/combo-box' as *; +@use '@carbon/styles/scss/components/list-box' as *; +@use '@carbon/styles/scss/layout' as *; +@use '@carbon/styles/scss/spacing' as *; +@use '@carbon/styles/scss/theme' as *; +@use '@carbon/styles/scss/type' as *; +@use '@carbon/styles/scss/utilities/skeleton' as *; +@use '@carbon/styles/scss/utilities' as *; +@use '@carbon/styles/scss/colors'; +@use '@carbon/styles/scss/utilities/convert' as *; + +:host(#{$prefix}-fluid-combo-box) { + @extend .#{$prefix}--list-box__wrapper; + @extend .#{$prefix}--list-box__wrapper--fluid; + + @include emit-layout-tokens(); + + outline: none; + + .#{$prefix}--list-box { + box-sizing: border-box; + } + + .#{$prefix}--list-box--expanded { + border-block-end-color: $border-subtle; + } + + .#{$prefix}--assistive-text { + inset-block-start: -100%; + inset-inline-start: -100%; + } + + .#{$prefix}--label[hidden] { + display: none; + } + + .#{$prefix}--list-box__menu { + inset-block-start: 100%; + margin-block-start: 1px; + outline: none; + } + + .#{$prefix}--list-box--disabled { + border-block-end-color: transparent; + pointer-events: none; + } + + .#{$prefix}--list-box__menu-item__selected-icon { + inset-block-start: to-rem(20px); + } + + .#{$prefix}--list-box__divider { + display: block; + border: none; + margin: 0 1rem; + border-block-end: 1px solid $border-subtle; + } +} + +:host(#{$prefix}-fluid-combo-box:not([is-condensed])) { + ::slotted(#{$prefix}-combo-box-item) { + block-size: $spacing-10; + } +} + +:host(#{$prefix}-fluid-combo-box[invalid]) { + @extend .#{$prefix}--list-box__wrapper--fluid--invalid; + @include focus-outline('invalid'); + + outline-offset: 0; +} + +// Direction top +:host(#{$prefix}-fluid-combo-box[direction='top']) { + @extend .#{$prefix}--list-box--up; + + .#{$prefix}--list-box__menu { + inset-block-start: auto; + } +} + +// AI Label styles +:host(#{$prefix}-fluid-combo-box[ai-label]) { + @extend .#{$prefix}--list-box__wrapper--slug; + + ::slotted(#{$prefix}-ai-label), + ::slotted(#{$prefix}-slug) { + position: absolute; + inset-block-start: 73%; + inset-inline-end: $spacing-09; + } + + ::slotted(#{$prefix}-ai-label:not([revert-active])), + ::slotted(#{$prefix}-slug:not([revert-active])) { + transform: translateY(-73%); + } + + .#{$prefix}--list-box__wrapper--decorator { + @include ai-gradient; + } +} + +// AI label dividers - always present +:host(#{$prefix}-fluid-combo-box[ai-label]) ::slotted(#{$prefix}-ai-label) { + &::after { + position: absolute; + background-color: $border-subtle-01; + block-size: to-rem(16px); + content: ''; + inline-size: to-rem(1px); + inset-inline-start: calc(#{$spacing-06} - to-rem(1px)); + } +} + +:host(#{$prefix}-fluid-combo-box[ai-label][read-only]), +:host(#{$prefix}-fluid-combo-box[ai-label][disabled]) { + .#{$prefix}--list-box { + border-block-end-color: $ai-border-strong; + } + + .#{$prefix}--list-box__field { + border-block-end: 1px solid $ai-border-strong; + } + + ::slotted(#{$prefix}-ai-label)::before { + display: none; + } +} + +// Preserve AI gradient in invalid/warn states +:host( + #{$prefix}-fluid-combo-box[ai-label][invalid]:not([disabled]):not( + [read-only] + ) + ), +:host( + #{$prefix}-fluid-combo-box[ai-label][warn]:not([disabled]):not([read-only]) + ) { + .#{$prefix}--list-box__wrapper--decorator { + @include ai-gradient; + } +} + +// AI label - adjust position when clear button is visible +:host(#{$prefix}-fluid-combo-box[ai-label][isClosable]) { + ::slotted(#{$prefix}-ai-label), + ::slotted(#{$prefix}-slug) { + inset-inline-end: calc($spacing-10 + 18px); + } +} + +// Condensed styles +:host(#{$prefix}-fluid-combo-box[is-condensed]) { + @extend .#{$prefix}--list-box__wrapper--fluid--condensed; + + .#{$prefix}--list-box__menu-item__selected-icon { + inset-block-start: auto; + } + + ::slotted(#{$prefix}-combo-box-item) { + block-size: auto; + } +} + +// Disabled state +:host(#{$prefix}-fluid-combo-box[disabled]) { + .#{$prefix}--list-box { + cursor: not-allowed; + } + + .#{$prefix}--list-box__menu-icon svg { + fill: $icon-disabled; + } + + .#{$prefix}--list-box__field, + .#{$prefix}--list-box__selection { + pointer-events: none; + } + + .#{$prefix}--list-box__field:focus, + .#{$prefix}--list-box__selection:focus { + outline: none; + } + + .#{$prefix}--label { + color: $text-disabled; + cursor: not-allowed; + } +} + +:host(#{$prefix}-fluid-combo-box[read-only]) { + @extend .#{$prefix}--dropdown--readonly; + + .#{$prefix}--text-input { + border-block-end-color: $border-subtle; + } + + .#{$prefix}--list-box__field, + .#{$prefix}--list-box__selection { + pointer-events: none; + } +} + +:host(#{$prefix}-fluid-combo-box[invalid]:not([disabled]):not([read-only])), +:host(#{$prefix}-fluid-combo-box[warn]:not([disabled]):not([read-only])) { + .#{$prefix}--form__helper-text { + padding: to-rem(8px) 4rem to-rem(8px) $spacing-05; + inline-size: auto; + margin-block-start: 0; + } + + .#{$prefix}--list-box { + border-block-end: 1px solid transparent; + } + + .#{$prefix}--list-box.#{$prefix}--list-box--warning + .#{$prefix}--list-box__invalid-icon, + .#{$prefix}--list-box__invalid-icon--warning { + inset-block-start: to-rem(81px); + inset-inline-end: $spacing-05; + pointer-events: none; + } +} + +:host(#{$prefix}-fluid-combo-box[warn]:not([disabled]):not([read-only])) { + .#{$prefix}--form__helper-text { + color: $text-primary; + } +} + +:host(#{$prefix}-fluid-combo-box[invalid]:not([disabled]):not([read-only])) { + .#{$prefix}--form__helper-text { + color: $text-error; + } +} + +// Skeleton +:host(#{$prefix}-fluid-combo-box-skeleton) { + @extend .#{$prefix}--list-box__wrapper--fluid; + + display: block; +} diff --git a/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.stories.ts b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.stories.ts new file mode 100644 index 000000000000..d4beae78c092 --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.stories.ts @@ -0,0 +1,345 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import './index'; +import View16 from '@carbon/icons/es/view/16.js'; +import FolderOpen16 from '@carbon/icons/es/folder--open/16.js'; +import Folders16 from '@carbon/icons/es/folders/16.js'; +import '../combo-box/combo-box-item'; +import '../ai-label'; +import '../icon-button'; +import { iconLoader } from '../../globals/internal/icon-loader'; +import '../toggle-tip/toggletip'; + +const content = html` +
+

AI Explained

+

84%

+

Confidence score

+

+ Lorem ipsum dolor sit amet, di os consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut fsil labore et dolore magna aliqua. +

+
+

Model type

+

Foundation model

+
+`; + +const actions = html` + + ${iconLoader(View16, { slot: 'icon' })} + View + + + ${iconLoader(FolderOpen16, { slot: 'icon' })} + Open folder + + + ${iconLoader(Folders16, { slot: 'icon' })} + Folders + + View details +`; + +const items = [ + { + value: 'option-0', + text: 'Lorem, ipsum dolor sit amet consectetur adipisicing elit.', + }, + { + value: 'option-1', + text: 'Option 1', + }, + { + value: 'option-2', + text: 'Option 2', + }, + { + value: 'option-3', + text: 'Option 3 - a disabled item', + disabled: true, + }, + { + value: 'option-4', + text: 'Option 4', + }, +]; + +const args = { + defaultWidth: 400, + ariaLabel: '', + autoAlign: false, + direction: 'bottom', + disabled: false, + isCondensed: false, + invalid: false, + invalidText: + 'Error message that is really long can wrap to more lines but should not be excessively long', + open: false, + readOnly: false, + titleText: 'Label', + value: '', + warn: false, + warnText: + 'Warning message that is really long can wrap to more lines but should not be excessively long.', +}; + +const argTypes = { + defaultWidth: { + control: { type: 'range', min: 300, max: 800, step: 50 }, + }, + ariaLabel: { + control: 'text', + description: + 'Specify a label to be read by screen readers on the container node.', + }, + autoAlign: { + control: 'boolean', + description: + 'Will auto-align the combo box. This attribute is currently experimental and is subject to future changes.', + }, + direction: { + control: 'select', + options: ['top', 'bottom'], + description: + "Specify the direction of the combo box dropdown. Can be either 'top' or 'bottom'.", + }, + disabled: { + control: 'boolean', + description: 'Specify whether the control is disabled.', + }, + isCondensed: { + control: 'boolean', + description: + 'Specify if the combo box should render its menu items in condensed mode.', + }, + invalid: { + control: 'boolean', + description: 'Specify if the currently selected value is invalid.', + }, + invalidText: { + control: 'text', + description: 'Message which is displayed if the value is invalid.', + }, + label: { + control: 'text', + description: 'The default content of the trigger button.', + }, + open: { + control: 'boolean', + description: 'Specify if the combo box should be open, or not.', + }, + readOnly: { + control: 'boolean', + description: 'Whether or not the combo box is readonly.', + }, + titleText: { + control: 'text', + description: + 'Text that will be read by a screen reader when visiting this control.', + }, + value: { + control: 'text', + description: 'The value of the selected item.', + }, + warn: { + control: 'boolean', + description: 'Specify whether the control is currently in warning state.', + }, + warnText: { + control: 'text', + description: + 'Provide the text that is displayed when the control is in warning state.', + }, +}; + +export const Default = { + args, + argTypes, + render: ({ + defaultWidth, + ariaLabel, + autoAlign, + direction, + disabled, + isCondensed, + invalid, + invalidText, + label, + open, + readOnly, + titleText, + value, + warn, + warnText, + }) => html` +
+ + ${items.map( + (elem) => html` + ${elem.text} + ` + )} + +
+ `, +}; + +export const Condensed = { + args: { + ...args, + isCondensed: true, + }, + argTypes, + render: ({ + defaultWidth, + ariaLabel, + autoAlign, + direction, + disabled, + isCondensed, + invalid, + invalidText, + label, + open, + readOnly, + titleText, + value, + warn, + warnText, + }) => html` +
+ + ${items.map( + (elem) => html` + ${elem.text} + ` + )} + +
+ `, +}; + +export const Skeleton = { + parameters: { + percy: { + skip: true, + }, + }, + args: { + defaultWidth: 400, + }, + argTypes: { + defaultWidth: { + control: { type: 'range', min: 300, max: 800, step: 50 }, + }, + }, + render: ({ defaultWidth }) => html` +
+ +
+ `, +}; + +export const WithAILabel = { + args, + argTypes: { + ...argTypes, + }, + render: ({ + defaultWidth, + ariaLabel, + autoAlign, + direction, + disabled, + isCondensed, + invalid, + invalidText, + label, + open, + readOnly, + titleText, + value, + warn, + warnText, + }) => html` +
+ + + ${content}${actions} + ${items.map( + (elem) => html` + ${elem.text} + ` + )} + +
+ `, +}; + +const meta = { + decorators: [ + (story) => { + return html`
${story()}
`; + }, + ], + title: 'Components/Fluid Components/FluidComboBox', +}; + +export default meta; diff --git a/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.ts b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.ts new file mode 100644 index 000000000000..ef29e2897c8c --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/fluid-combo-box.ts @@ -0,0 +1,48 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { prefix } from '../../globals/settings'; +import { property } from 'lit/decorators.js'; +import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; +import CDSComboBox from '../combo-box/combo-box'; +import styles from './fluid-combo-box.scss?lit'; + +/** + * Fluid combo box. + * + * @element cds-fluid-combo-box + */ +@customElement(`${prefix}-fluid-combo-box`) +class CDSFluidComboBox extends CDSComboBox { + /** + * Specify if the combo box should render its menu items in condensed mode. + */ + @property({ type: Boolean, reflect: true, attribute: 'is-condensed' }) + isCondensed = false; + + connectedCallback() { + this.setAttribute('isFluid', 'true'); + super.connectedCallback(); + } + + updated(changedProperties) { + super.updated(changedProperties); + if ( + changedProperties.has('invalid') || + changedProperties.has('disabled') || + changedProperties.has('readOnly') || + changedProperties.has('warn') || + changedProperties.has('isCondensed') + ) { + this.requestUpdate(); + } + } + + static styles = [CDSComboBox.styles, styles]; +} + +export default CDSFluidComboBox; diff --git a/packages/web-components/src/components/fluid-combo-box/index.ts b/packages/web-components/src/components/fluid-combo-box/index.ts new file mode 100644 index 000000000000..4c5de3830007 --- /dev/null +++ b/packages/web-components/src/components/fluid-combo-box/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright IBM Corp. 2026 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import './fluid-combo-box'; +import './fluid-combo-box-skeleton'; From 406ef5f9efc4293a6ed6024e4ff65b360406e1bd Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Fri, 1 May 2026 02:50:30 -0500 Subject: [PATCH 018/266] docs(storybook): add section overview pages (#22159) --- packages/react/src/components/Heading/Section.mdx | 12 ++++++++++++ .../src/components/heading/section.mdx | 12 ++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/react/src/components/Heading/Section.mdx create mode 100644 packages/web-components/src/components/heading/section.mdx diff --git a/packages/react/src/components/Heading/Section.mdx b/packages/react/src/components/Heading/Section.mdx new file mode 100644 index 000000000000..4929e9b5ed76 --- /dev/null +++ b/packages/react/src/components/Heading/Section.mdx @@ -0,0 +1,12 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Section + +The `Section` component is documented with `Heading` because `Heading` uses the +nearest `Section` to infer the correct heading level. + +Review the +[Heading component overview](../?path=/docs/components-heading--overview) for +usage guidance and examples. diff --git a/packages/web-components/src/components/heading/section.mdx b/packages/web-components/src/components/heading/section.mdx new file mode 100644 index 000000000000..53a6638e5122 --- /dev/null +++ b/packages/web-components/src/components/heading/section.mdx @@ -0,0 +1,12 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Section + +The `section` component is documented with `heading` because `heading` uses the +nearest `section` to infer the correct heading level. + +Review the +[Heading component overview](../?path=/docs/components-heading--overview) for +usage guidance and examples. From 764f0eb354e2299b7dae59df6ec4ec70f7918f6c Mon Sep 17 00:00:00 2001 From: Mahmoud <132728978+maradwan26@users.noreply.github.com> Date: Fri, 1 May 2026 04:05:48 -0400 Subject: [PATCH 019/266] fix(docs-multiselect): unnecessary type assertion (#22146) * fix(docs-multiselect): unnecessary type assertion * refactor: remove unnecessary type assertions --------- Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> --- .../components/multi-select/multi-select.mdx | 75 ++++++++++--------- .../multi-select/multi-select.stories.ts | 11 ++- .../components/multi-select/multi-select.ts | 7 +- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/packages/web-components/src/components/multi-select/multi-select.mdx b/packages/web-components/src/components/multi-select/multi-select.mdx index 68af4330114c..8a00951d76fb 100644 --- a/packages/web-components/src/components/multi-select/multi-select.mdx +++ b/packages/web-components/src/components/multi-select/multi-select.mdx @@ -69,42 +69,45 @@ alphabetically by default. ```typescript - const customSortItems = ( - menuItems: NodeList, - { - values, - compareItems, - locale, - }: { - values: string[]; - compareItems: ( - itemA: string, - itemB: string, - options: { locale: string } - ) => number; - locale: string; - } - ) => { - const menuItemsArray = Array.from(menuItems); - - return menuItemsArray.sort((itemA, itemB) => { - const hasItemA = values.includes((itemA as HTMLInputElement).value); - const hasItemB = values.includes((itemB as HTMLInputElement).value); - - if (hasItemA && !hasItemB) { - return 1; - } - if (hasItemB && !hasItemA) { - return -1; - } - - return compareItems( - (itemA as HTMLElement).innerText.trim(), - (itemB as HTMLElement).innerText.trim(), - { locale } - ); - }); - }; +const getItemValue = (item: Node) => + item instanceof Element ? (item.getAttribute('value') ?? '') : ''; + +const customSortItems = ( + menuItems: NodeList, + { + values, + compareItems, + locale, + }: { + values: string[]; + compareItems: ( + itemA: string, + itemB: string, + options: { locale: string } + ) => number; + locale: string; + } +) => { + const menuItemsArray = Array.from(menuItems); + + return menuItemsArray.sort((itemA, itemB) => { + const hasItemA = values.includes(getItemValue(itemA)); + const hasItemB = values.includes(getItemValue(itemB)); + + if (hasItemA && !hasItemB) { + return 1; + } + if (hasItemB && !hasItemA) { + return -1; + } + + return compareItems( + itemA.textContent?.trim() ?? '', + itemB.textContent?.trim() ?? '', + { locale } + ); + }); +}; ... diff --git a/packages/web-components/src/components/multi-select/multi-select.stories.ts b/packages/web-components/src/components/multi-select/multi-select.stories.ts index 53d5ce9e8844..bdfe7e5bf01e 100644 --- a/packages/web-components/src/components/multi-select/multi-select.stories.ts +++ b/packages/web-components/src/components/multi-select/multi-select.stories.ts @@ -1077,6 +1077,9 @@ export const WithCustomSorting = { warnText, } = args ?? {}; + const getItemValue = (item: Node) => + item instanceof Element ? (item.getAttribute('value') ?? '') : ''; + const customSortItems = ( menuItems: NodeList, { @@ -1096,8 +1099,8 @@ export const WithCustomSorting = { const menuItemsArray = Array.from(menuItems); return menuItemsArray.sort((itemA, itemB) => { - const hasItemA = values.includes((itemA as HTMLInputElement).value); - const hasItemB = values.includes((itemB as HTMLInputElement).value); + const hasItemA = values.includes(getItemValue(itemA)); + const hasItemB = values.includes(getItemValue(itemB)); if (hasItemA && !hasItemB) { return 1; @@ -1107,8 +1110,8 @@ export const WithCustomSorting = { } return compareItems( - (itemA as HTMLElement).innerText.trim(), - (itemB as HTMLElement).innerText.trim(), + itemA.textContent?.trim() ?? '', + itemB.textContent?.trim() ?? '', { locale } ); }); diff --git a/packages/web-components/src/components/multi-select/multi-select.ts b/packages/web-components/src/components/multi-select/multi-select.ts index 1acc32fc6d80..2224beef033d 100644 --- a/packages/web-components/src/components/multi-select/multi-select.ts +++ b/packages/web-components/src/components/multi-select/multi-select.ts @@ -703,6 +703,9 @@ class CDSMultiSelect extends CDSDropdown { options: { locale: string } ) => number = this.defaultCompareItems; + private getItemValue = (item: Node) => + item instanceof Element ? (item.getAttribute('value') ?? '') : ''; + protected defaultSortItems = ( menuItems: NodeList, { values, compareItems, locale = 'en' } @@ -710,8 +713,8 @@ class CDSMultiSelect extends CDSDropdown { const menuItemsArray = Array.from(menuItems); const sortedArray = menuItemsArray.sort((itemA, itemB) => { - const hasItemA = values.includes((itemA as HTMLInputElement).value); - const hasItemB = values.includes((itemB as HTMLInputElement).value); + const hasItemA = values.includes(this.getItemValue(itemA)); + const hasItemB = values.includes(this.getItemValue(itemB)); // Prefer whichever item is in the `value` array first if (hasItemA && !hasItemB) { From 3a286e94f8322ef66a90a7be07b4537b7fe02f8d Mon Sep 17 00:00:00 2001 From: Mahmoud <132728978+maradwan26@users.noreply.github.com> Date: Fri, 1 May 2026 09:48:19 -0400 Subject: [PATCH 020/266] Revert "fix(menu): nested menu closes unexpectedly when hovering back from submenu item to parent menu item" (#22142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(menu): nested menu closes unexpectedly when hovering back from su…" This reverts commit 4790820ef828dc04823c70bcd7625ea467f1e1a2. * test: add test --- .../react/src/components/Menu/Menu-test.js | 31 ++++++++++--------- packages/react/src/components/Menu/Menu.tsx | 8 +---- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/packages/react/src/components/Menu/Menu-test.js b/packages/react/src/components/Menu/Menu-test.js index 51cddb38265b..18378ae61cff 100644 --- a/packages/react/src/components/Menu/Menu-test.js +++ b/packages/react/src/components/Menu/Menu-test.js @@ -1,5 +1,5 @@ /** - * Copyright IBM Corp. 2023, 2025 + * Copyright IBM Corp. 2023, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -87,6 +87,21 @@ describe('Menu', () => { expect(onClose).toHaveBeenCalled(); }); + it('should call onClose when clicking outside of the Menu', async () => { + const onClose = jest.fn(); + render( +
+ + + +
+ ); + + await userEvent.click(screen.getByTestId('container')); + + expect(onClose).toHaveBeenCalled(); + }); + it('should be open if open is supplied', () => { render(); @@ -131,20 +146,6 @@ describe('Menu', () => { expect(document.querySelector('.custom-class')).toBeInTheDocument(); document.body.removeChild(el); }); - - it('should not call onClose when relatedTarget is null on blur', () => { - const onClose = jest.fn(); - render( - - - - ); - - const menu = screen.getByRole('menu'); - fireEvent.blur(menu, { relatedTarget: null }); - - expect(onClose).not.toHaveBeenCalled(); - }); }); describe('Submenu behavior', () => { diff --git a/packages/react/src/components/Menu/Menu.tsx b/packages/react/src/components/Menu/Menu.tsx index 2d0650a53dfd..3b9089691547 100644 --- a/packages/react/src/components/Menu/Menu.tsx +++ b/packages/react/src/components/Menu/Menu.tsx @@ -287,13 +287,7 @@ const Menu = forwardRef(function Menu( } function handleBlur(e: React.FocusEvent) { - if ( - open && - onClose && - isRoot && - e.relatedTarget && - !menu.current?.contains(e.relatedTarget) - ) { + if (open && onClose && isRoot && !menu.current?.contains(e.relatedTarget)) { handleClose(); } } From 21677a185e80f945b2d001a02ce7994d0079fbbc Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Fri, 1 May 2026 23:07:27 +0530 Subject: [PATCH 021/266] fix(a11y): use aria-errormessage for invalid text inputs (#21297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(a11y): use aria-errormessage for invalid text inputs * fix(a11y): use aria-errormessage for invalid text inputs and add tests * Apply suggestion from @sangeethababu9223 Co-authored-by: Sangeetha Babu * fix(textinput): yarn format * fix(text-input): align invalid helper aria and add tests --------- Co-authored-by: Heloise Lui <71858203+heloiselui@users.noreply.github.com> Co-authored-by: Sangeetha Babu Co-authored-by: “heloiselui” --- .../src/components/TextArea/TextArea-test.js | 36 ++++++++++++ .../src/components/TextArea/TextArea.tsx | 4 +- .../src/components/TextInput/TextInput.tsx | 7 ++- .../__tests__/ControlledPasswordInput-test.js | 56 +++++++++++++++++++ .../TextInput/__tests__/PasswordInput-test.js | 18 ++++++ .../TextInput/__tests__/TextInput-test.js | 50 +++++++++++++++++ .../react/src/components/TextInput/util.ts | 2 +- 7 files changed, 168 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/TextArea/TextArea-test.js b/packages/react/src/components/TextArea/TextArea-test.js index b5746de0ac75..796efa470565 100644 --- a/packages/react/src/components/TextArea/TextArea-test.js +++ b/packages/react/src/components/TextArea/TextArea-test.js @@ -162,6 +162,42 @@ describe('TextArea', () => { expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid'); }); + it('should set aria-errormessage to the invalid message element id', () => { + render( +