From 5a0b9727118144d6f29b84201b24294496846786 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 09:04:10 +0200 Subject: [PATCH 01/17] feat: Control group component --- build-tools/utils/pluralize.js | 1 + pages/control-group/action-button.page.tsx | 2 +- pages/control-group/common.tsx | 2 +- pages/control-group/labels.page.tsx | 2 +- pages/control-group/permutations.page.tsx | 2 +- .../__snapshots__/documenter.test.ts.snap | 307 ++++++++++++++++++ .../test-utils-wrappers.test.tsx.snap | 80 +++++ .../__tests__/control-group.test.tsx | 4 +- .../control-group/__tests__/common.tsx | 2 +- .../__tests__/control-group.test.tsx | 6 +- .../control-group/grouped-control-styles.ts | 2 +- .../components => }/control-group/index.tsx | 9 +- .../control-group/interfaces.ts | 11 +- .../components => }/control-group/styles.scss | 6 +- .../control-group/test-classes/styles.scss | 0 src/input/__tests__/control-group.test.tsx | 4 +- src/input/internal.tsx | 2 +- .../components/button-trigger/index.tsx | 2 +- .../__tests__/control-group.test.tsx | 4 +- .../internal-segmented-control.tsx | 2 +- src/segmented-control/internal.tsx | 2 +- src/select/__tests__/control-group.test.tsx | 4 +- .../index.ts} | 2 +- 23 files changed, 423 insertions(+), 35 deletions(-) rename src/{internal/components => }/control-group/__tests__/common.tsx (82%) rename src/{internal/components => }/control-group/__tests__/control-group.test.tsx (95%) rename src/{internal/components => }/control-group/grouped-control-styles.ts (89%) rename src/{internal/components => }/control-group/index.tsx (92%) rename src/{internal/components => }/control-group/interfaces.ts (77%) rename src/{internal/components => }/control-group/styles.scss (94%) rename src/{internal/components => }/control-group/test-classes/styles.scss (100%) rename src/test-utils/dom/{internal/control-group.ts => control-group/index.ts} (87%) diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 6f21e5c1427..f03df7fa256 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -24,6 +24,7 @@ const pluralizationMap = { ColumnLayout: 'ColumnLayouts', Container: 'Containers', ContentLayout: 'ContentLayouts', + ControlGroup: 'ControlGroups', CopyToClipboard: 'CopyToClipboards', DateInput: 'DateInputs', DatePicker: 'DatePickers', diff --git a/pages/control-group/action-button.page.tsx b/pages/control-group/action-button.page.tsx index 50298d0f274..668ef4774d7 100644 --- a/pages/control-group/action-button.page.tsx +++ b/pages/control-group/action-button.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import ControlGroup, { ControlGroupProps } from '~components/internal/components/control-group'; +import ControlGroup, { ControlGroupProps } from '~components/control-group'; import { PermutationsPage } from '../app/templates'; import createPermutations from '../utils/permutations'; diff --git a/pages/control-group/common.tsx b/pages/control-group/common.tsx index 4c2c5f3fe59..4ba88e886a6 100644 --- a/pages/control-group/common.tsx +++ b/pages/control-group/common.tsx @@ -3,9 +3,9 @@ import React from 'react'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import { ControlGroupProps } from '~components/control-group/interfaces'; import FormField from '~components/form-field'; import Input from '~components/input'; -import { ControlGroupProps } from '~components/internal/components/control-group/interfaces'; import Multiselect, { MultiselectProps } from '~components/multiselect'; import RadioGroup from '~components/radio-group'; import SegmentedControl, { SegmentedControlProps } from '~components/segmented-control'; diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx index 84117d86f90..314a6bac2d9 100644 --- a/pages/control-group/labels.page.tsx +++ b/pages/control-group/labels.page.tsx @@ -4,8 +4,8 @@ import React from 'react'; import { SpaceBetween } from '~components'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import ControlGroup from '~components/control-group'; import Input from '~components/input'; -import ControlGroup from '~components/internal/components/control-group'; import Multiselect, { MultiselectProps } from '~components/multiselect'; import Select, { SelectProps } from '~components/select'; diff --git a/pages/control-group/permutations.page.tsx b/pages/control-group/permutations.page.tsx index cf3cd13e747..42c9d9dd4ce 100644 --- a/pages/control-group/permutations.page.tsx +++ b/pages/control-group/permutations.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import ControlGroup, { ControlGroupProps } from '~components/internal/components/control-group'; +import ControlGroup, { ControlGroupProps } from '~components/control-group'; import { PermutationsPage } from '../app/templates'; import createPermutations from '../utils/permutations'; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 7842b538252..5ae20764e8e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -10742,6 +10742,262 @@ Note that the secondary header will not have a high-contrast treatement, even if } `; +exports[`Components definition for control-group matches the snapshot: control-group 1`] = ` +{ + "dashCaseName": "control-group", + "events": [], + "functions": [], + "name": "ControlGroup", + "properties": [ + { + "description": "Specifies an action button rendered next to the grouped controls.", + "inlineType": { + "name": "ControlGroupProps.ActionButton", + "properties": [ + { + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "name": "ariaLabel", + "optional": false, + "type": "string", + }, + { + "name": "disabled", + "optional": true, + "type": "boolean", + }, + { + "name": "disabledReason", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "IconProps.Name", + "type": "union", + "values": [ + "search", + "map", + "filter", + "key", + "file", + "pause", + "play", + "microphone", + "remove", + "copy", + "menu", + "script", + "close", + "status-pending", + "refresh", + "external", + "history", + "group", + "calendar", + "ellipsis", + "zoom-in", + "zoom-out", + "dot", + "security", + "download", + "edit", + "add-plus", + "anchor-link", + "angle-left-double", + "angle-left", + "angle-right-double", + "angle-right", + "angle-up", + "angle-down", + "announcement", + "arrow-left", + "arrow-right", + "arrow-up", + "arrow-down", + "at-symbol", + "audio-full", + "audio-half", + "audio-off", + "backward-10-seconds", + "bug", + "call", + "caret-down-filled", + "caret-down", + "caret-left-filled", + "caret-right-filled", + "caret-up-filled", + "caret-up", + "check", + "contact", + "closed-caption", + "closed-caption-unavailable", + "command-prompt", + "convert-code", + "delete-marker", + "drag-indicator", + "edit-gen-ai", + "envelope", + "exit-full-screen", + "expand", + "face-happy", + "face-happy-filled", + "face-neutral", + "face-neutral-filled", + "face-sad", + "face-sad-filled", + "file-open", + "flag", + "folder-open", + "folder", + "forward-10-seconds", + "full-screen", + "gen-ai", + "gen-ai-token", + "globe", + "grid-view", + "group-active", + "heart", + "heart-filled", + "insert-row", + "internal-link", + "keyboard", + "light-dark", + "list-view", + "location-pin", + "lock-private", + "microphone-off", + "mini-player", + "multiscreen", + "notification", + "redo", + "resize-area", + "search-gen-ai", + "settings", + "send", + "share", + "shrink", + "sign-out", + "side-bar", + "slash", + "slash-divider", + "star-filled", + "star-half", + "star", + "status-in-progress", + "status-info", + "status-negative", + "status-not-started", + "status-positive", + "status-stopped", + "status-warning", + "stop-circle", + "subtract-minus", + "suggestions", + "suggestions-gen-ai", + "support", + "thumbs-down-filled", + "thumbs-down", + "thumbs-up-filled", + "thumbs-up", + "ticket", + "transcript", + "treeview-collapse", + "treeview-expand", + "undo", + "unlocked", + "upload-download", + "upload", + "user-profile-active", + "user-profile", + "video-off", + "video-on", + "video-unavailable", + "video-camera-off", + "video-camera-on", + "video-camera-unavailable", + "view-full", + "view-horizontal", + "view-vertical", + "zoom-to-fit", + ], + }, + "name": "iconName", + "optional": false, + "type": "string", + }, + { + "inlineType": { + "name": "CancelableEventHandler", + "parameters": [ + { + "name": "event", + "type": "CustomEvent", + }, + ], + "returnType": "void", + "type": "function", + }, + "name": "onClick", + "optional": true, + "type": "CancelableEventHandler", + }, + ], + "type": "object", + }, + "name": "actionButton", + "optional": true, + "type": "ControlGroupProps.ActionButton", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "defaultValue": "'horizontal'", + "inlineType": { + "name": ""horizontal" | "vertical"", + "type": "union", + "values": [ + "horizontal", + "vertical", + ], + }, + "name": "direction", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "name": "inlineLabelText", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for copy-to-clipboard matches the snapshot: copy-to-clipboard 1`] = ` { "dashCaseName": "copy-to-clipboard", @@ -40635,6 +40891,34 @@ Note that, despite its typings, this may return null for group items since group ], "name": "ContentLayoutWrapper", }, + { + "methods": [ + { + "description": "Returns the action button, or null if no action is set.", + "name": "findActionButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "description": "Returns the visible inline label element, or null if no label is set.", + "name": "findInlineLabel", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + ], + "name": "ControlGroupWrapper", + }, { "methods": [ { @@ -52363,6 +52647,29 @@ Note that, despite its typings, this may return null for group items since group ], "name": "ContentLayoutWrapper", }, + { + "methods": [ + { + "description": "Returns the action button, or null if no action is set.", + "name": "findActionButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ButtonWrapper", + }, + }, + { + "description": "Returns the visible inline label element, or null if no label is set.", + "name": "findInlineLabel", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + ], + "name": "ControlGroupWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 41b1607f330..0ae69c9b241 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -33,6 +33,7 @@ import CollectionPreferencesWrapper from './collection-preferences'; import ColumnLayoutWrapper from './column-layout'; import ContainerWrapper from './container'; import ContentLayoutWrapper from './content-layout'; +import ControlGroupWrapper from './control-group'; import CopyToClipboardWrapper from './copy-to-clipboard'; import DateInputWrapper from './date-input'; import DatePickerWrapper from './date-picker'; @@ -136,6 +137,7 @@ export { CollectionPreferencesWrapper }; export { ColumnLayoutWrapper }; export { ContainerWrapper }; export { ContentLayoutWrapper }; +export { ControlGroupWrapper }; export { CopyToClipboardWrapper }; export { DateInputWrapper }; export { DatePickerWrapper }; @@ -889,6 +891,34 @@ findAllContentLayouts(selector?: string): Array; * @returns {ContentLayoutWrapper | null} */ findClosestContentLayout(): ContentLayoutWrapper | null; +/** + * Returns the wrapper of the first ControlGroup that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first ControlGroup. + * If no matching ControlGroup is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {ControlGroupWrapper | null} + */ +findControlGroup(selector?: string): ControlGroupWrapper | null; + +/** + * Returns an array of ControlGroup wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the ControlGroups inside the current wrapper. + * If no matching ControlGroup is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllControlGroups(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent ControlGroup for the current element, + * or the element itself if it is an instance of ControlGroup. + * If no ControlGroup is found, returns \`null\`. + * + * @returns {ControlGroupWrapper | null} + */ +findClosestControlGroup(): ControlGroupWrapper | null; /** * Returns the wrapper of the first CopyToClipboard that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first CopyToClipboard. @@ -3361,6 +3391,19 @@ ElementWrapper.prototype.findContentLayout = function(selector) { ElementWrapper.prototype.findAllContentLayouts = function(selector) { return this.findAllComponents(ContentLayoutWrapper, selector); }; +ElementWrapper.prototype.findControlGroup = function(selector) { + let rootSelector = \`.\${ControlGroupWrapper.rootSelector}\`; + if("legacyRootSelector" in ControlGroupWrapper && ControlGroupWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${ControlGroupWrapper.rootSelector}, .\${ControlGroupWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ControlGroupWrapper); +}; + +ElementWrapper.prototype.findAllControlGroups = function(selector) { + return this.findAllComponents(ControlGroupWrapper, selector); +}; ElementWrapper.prototype.findCopyToClipboard = function(selector) { let rootSelector = \`.\${CopyToClipboardWrapper.rootSelector}\`; if("legacyRootSelector" in CopyToClipboardWrapper && CopyToClipboardWrapper.legacyRootSelector){ @@ -4483,6 +4526,11 @@ ElementWrapper.prototype.findClosestContentLayout = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(ContentLayoutWrapper); }; +ElementWrapper.prototype.findClosestControlGroup = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(ControlGroupWrapper); +}; ElementWrapper.prototype.findClosestCopyToClipboard = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4911,6 +4959,7 @@ import CollectionPreferencesWrapper from './collection-preferences'; import ColumnLayoutWrapper from './column-layout'; import ContainerWrapper from './container'; import ContentLayoutWrapper from './content-layout'; +import ControlGroupWrapper from './control-group'; import CopyToClipboardWrapper from './copy-to-clipboard'; import DateInputWrapper from './date-input'; import DatePickerWrapper from './date-picker'; @@ -5014,6 +5063,7 @@ export { CollectionPreferencesWrapper }; export { ColumnLayoutWrapper }; export { ContainerWrapper }; export { ContentLayoutWrapper }; +export { ControlGroupWrapper }; export { CopyToClipboardWrapper }; export { DateInputWrapper }; export { DatePickerWrapper }; @@ -5503,6 +5553,23 @@ findContentLayout(selector?: string): ContentLayoutWrapper; * @returns {MultiElementWrapper} */ findAllContentLayouts(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the ControlGroups with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches ControlGroups. + * + * @param {string} [selector] CSS Selector + * @returns {ControlGroupWrapper} + */ +findControlGroup(selector?: string): ControlGroupWrapper; + +/** + * Returns a multi-element wrapper that matches ControlGroups with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches ControlGroups. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllControlGroups(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the CopyToClipboards with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches CopyToClipboards. @@ -7128,6 +7195,19 @@ ElementWrapper.prototype.findContentLayout = function(selector) { ElementWrapper.prototype.findAllContentLayouts = function(selector) { return this.findAllComponents(ContentLayoutWrapper, selector); }; +ElementWrapper.prototype.findControlGroup = function(selector) { + let rootSelector = \`.\${ControlGroupWrapper.rootSelector}\`; + if("legacyRootSelector" in ControlGroupWrapper && ControlGroupWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${ControlGroupWrapper.rootSelector}, .\${ControlGroupWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ControlGroupWrapper); +}; + +ElementWrapper.prototype.findAllControlGroups = function(selector) { + return this.findAllComponents(ControlGroupWrapper, selector); +}; ElementWrapper.prototype.findCopyToClipboard = function(selector) { let rootSelector = \`.\${CopyToClipboardWrapper.rootSelector}\`; if("legacyRootSelector" in CopyToClipboardWrapper && CopyToClipboardWrapper.legacyRootSelector){ diff --git a/src/autosuggest/__tests__/control-group.test.tsx b/src/autosuggest/__tests__/control-group.test.tsx index 3a53fbe14be..7286a4d4d8c 100644 --- a/src/autosuggest/__tests__/control-group.test.tsx +++ b/src/autosuggest/__tests__/control-group.test.tsx @@ -4,9 +4,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import Autosuggest from '../../../lib/components/autosuggest'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; +import ControlGroup from '../../../lib/components/control-group'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; diff --git a/src/internal/components/control-group/__tests__/common.tsx b/src/control-group/__tests__/common.tsx similarity index 82% rename from src/internal/components/control-group/__tests__/common.tsx rename to src/control-group/__tests__/common.tsx index 2dad08dcc68..1c82e2d23eb 100644 --- a/src/internal/components/control-group/__tests__/common.tsx +++ b/src/control-group/__tests__/common.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { useGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; +import { useGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; export function PositionProbe({ testId = 'probe' }: { testId?: string }) { const { position } = useGroupedControlContext(); diff --git a/src/internal/components/control-group/__tests__/control-group.test.tsx b/src/control-group/__tests__/control-group.test.tsx similarity index 95% rename from src/internal/components/control-group/__tests__/control-group.test.tsx rename to src/control-group/__tests__/control-group.test.tsx index bb30b9a4a5d..2762a4327b0 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/control-group/__tests__/control-group.test.tsx @@ -3,9 +3,9 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ControlGroup from '../../../../../lib/components/internal/components/control-group'; -import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; -import ControlGroupWrapper from '../../../../../lib/components/test-utils/dom/internal/control-group'; +import ControlGroup from '../../../lib/components/control-group'; +import { ResetGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; +import ControlGroupWrapper from '../../../lib/components/test-utils/dom/control-group'; import { DirectionProbe, PositionProbe } from './common'; function findControlGroup(container: HTMLElement) { diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/control-group/grouped-control-styles.ts similarity index 89% rename from src/internal/components/control-group/grouped-control-styles.ts rename to src/control-group/grouped-control-styles.ts index 0f6f81f7e12..f758b90a3a9 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/control-group/grouped-control-styles.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { GroupedControlContextProps } from '../../context/control-group-context'; +import { GroupedControlContextProps } from '../internal/context/control-group-context'; /** * Builds the shared grouped-control CSS classes for a consumer. diff --git a/src/internal/components/control-group/index.tsx b/src/control-group/index.tsx similarity index 92% rename from src/internal/components/control-group/index.tsx rename to src/control-group/index.tsx index ca3da291077..8126b49ea1c 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/control-group/index.tsx @@ -1,14 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +'use client'; import React from 'react'; import clsx from 'clsx'; import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; -import { InternalButton } from '../../../button/internal'; -import { getBaseProps } from '../../base-component'; -import { GroupedControlContext, GroupedControlPosition } from '../../context/control-group-context'; -import { flattenChildren } from '../../utils/flatten-children'; +import { InternalButton } from '../button/internal'; +import { getBaseProps } from '../internal/base-component'; +import { GroupedControlContext, GroupedControlPosition } from '../internal/context/control-group-context'; +import { flattenChildren } from '../internal/utils/flatten-children'; import { ControlGroupProps } from './interfaces'; import styles from './styles.css.js'; diff --git a/src/internal/components/control-group/interfaces.ts b/src/control-group/interfaces.ts similarity index 77% rename from src/internal/components/control-group/interfaces.ts rename to src/control-group/interfaces.ts index 0e07c917694..ffcd1b13320 100644 --- a/src/internal/components/control-group/interfaces.ts +++ b/src/control-group/interfaces.ts @@ -1,14 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ButtonProps } from '../../../button/interfaces'; -import { IconProps } from '../../../icon/interfaces'; -import { BaseComponentProps } from '../../../types/base-component'; -import { GroupedControlDirection } from '../../context/control-group-context'; -import { CancelableEventHandler } from '../../events'; +import { ButtonProps } from '../button/interfaces'; +import { IconProps } from '../icon/interfaces'; +import { BaseComponentProps } from '../types/base-component'; +import { CancelableEventHandler } from '../types/events'; export interface ControlGroupProps extends BaseComponentProps { children?: React.ReactNode; - direction?: GroupedControlDirection; + direction?: 'horizontal' | 'vertical'; inlineLabelText?: string; /** * Specifies an action button rendered next to the grouped controls. diff --git a/src/internal/components/control-group/styles.scss b/src/control-group/styles.scss similarity index 94% rename from src/internal/components/control-group/styles.scss rename to src/control-group/styles.scss index 7e0d69b2f7f..bc4c43df0d1 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/control-group/styles.scss @@ -3,9 +3,9 @@ SPDX-License-Identifier: Apache-2.0 */ -@use '../../styles' as styles; -@use '../../styles/tokens' as awsui; -@use '../../styles/forms/mixins' as forms; +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; +@use '../internal/styles/forms/mixins' as forms; $margin-overlap: calc(-1 * #{awsui.$border-width-field}); diff --git a/src/internal/components/control-group/test-classes/styles.scss b/src/control-group/test-classes/styles.scss similarity index 100% rename from src/internal/components/control-group/test-classes/styles.scss rename to src/control-group/test-classes/styles.scss diff --git a/src/input/__tests__/control-group.test.tsx b/src/input/__tests__/control-group.test.tsx index c4c3f019a07..518c1f23e02 100644 --- a/src/input/__tests__/control-group.test.tsx +++ b/src/input/__tests__/control-group.test.tsx @@ -3,9 +3,9 @@ import React from 'react'; import { render } from '@testing-library/react'; +import ControlGroup from '../../../lib/components/control-group'; import Input from '../../../lib/components/input'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 1ee2756882c..d3a96244231 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -10,11 +10,11 @@ import { } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import InternalButton from '../button/internal'; +import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; diff --git a/src/internal/components/button-trigger/index.tsx b/src/internal/components/button-trigger/index.tsx index 8691b739230..b0a578c63a7 100644 --- a/src/internal/components/button-trigger/index.tsx +++ b/src/internal/components/button-trigger/index.tsx @@ -6,13 +6,13 @@ import clsx from 'clsx'; import { isThemeActive, Theme } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; +import { getGroupedControlClassNames } from '../../../control-group/grouped-control-styles'; import InternalIcon from '../../../icon/internal'; import { BaseComponentProps } from '../../../types/base-component'; import { BaseKeyDetail, CancelableEventHandler } from '../../../types/events'; import { getBaseProps } from '../../base-component'; import { GroupedControlContextProps } from '../../context/control-group-context'; import { fireCancelableEvent, fireKeyboardEvent } from '../../events'; -import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; import { GeneratedAnalyticsMetadataButtonTriggerCollapse, GeneratedAnalyticsMetadataButtonTriggerExpand, diff --git a/src/multiselect/__tests__/control-group.test.tsx b/src/multiselect/__tests__/control-group.test.tsx index 2bc7c9fb356..8699b53f4f7 100644 --- a/src/multiselect/__tests__/control-group.test.tsx +++ b/src/multiselect/__tests__/control-group.test.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; +import ControlGroup from '../../../lib/components/control-group'; import Multiselect from '../../../lib/components/multiselect'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const options = [{ value: '1', label: 'One' }]; const noop = () => {}; diff --git a/src/segmented-control/internal-segmented-control.tsx b/src/segmented-control/internal-segmented-control.tsx index 5d478a27523..a78dfb1b8f5 100644 --- a/src/segmented-control/internal-segmented-control.tsx +++ b/src/segmented-control/internal-segmented-control.tsx @@ -3,7 +3,7 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; import { GroupedControlContextProps } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; diff --git a/src/segmented-control/internal.tsx b/src/segmented-control/internal.tsx index 24d719bbc9a..b4d64a8938e 100644 --- a/src/segmented-control/internal.tsx +++ b/src/segmented-control/internal.tsx @@ -3,9 +3,9 @@ import React from 'react'; import clsx from 'clsx'; +import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; import InternalFormField from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; diff --git a/src/select/__tests__/control-group.test.tsx b/src/select/__tests__/control-group.test.tsx index df208ec11ef..c18ad2a2920 100644 --- a/src/select/__tests__/control-group.test.tsx +++ b/src/select/__tests__/control-group.test.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ControlGroup from '../../../lib/components/internal/components/control-group'; +import ControlGroup from '../../../lib/components/control-group'; import Select from '../../../lib/components/select'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; describe('Select control in control group', () => { test('resets the context for custom dropdown content', () => { diff --git a/src/test-utils/dom/internal/control-group.ts b/src/test-utils/dom/control-group/index.ts similarity index 87% rename from src/test-utils/dom/internal/control-group.ts rename to src/test-utils/dom/control-group/index.ts index b930a7ee862..6da63d1ed8b 100644 --- a/src/test-utils/dom/internal/control-group.ts +++ b/src/test-utils/dom/control-group/index.ts @@ -4,7 +4,7 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils- import ButtonWrapper from '../button'; -import testUtilStyles from '../../../internal/components/control-group/test-classes/styles.selectors.js'; +import testUtilStyles from '../../../control-group/test-classes/styles.selectors.js'; export default class ControlGroupWrapper extends ComponentWrapper { static rootSelector: string = testUtilStyles.root; From 535770fbd1ad0329547fd5f5830067f24a5c8d67 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 09:30:51 +0200 Subject: [PATCH 02/17] Update API docs --- .../__snapshots__/documenter.test.ts.snap | 11 ++++++++--- src/multiselect/interfaces.ts | 1 + src/select/interfaces.ts | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 5ae20764e8e..44424d054b0 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -20399,7 +20399,9 @@ Set this property if the dropdown would otherwise be constrained by a scrollable for example inside table and split view layouts. We recommend you use discretion, and don't enable this property unless necessary -because fixed positioning results in a slight, visible lag when scrolling complex pages.", +because fixed positioning results in a slight, visible lag when scrolling complex pages. + +When the component is rendered in a control group, the dropdown will be rendered using this portal-based method regardless of this property.", "name": "expandToViewport", "optional": true, "type": "boolean", @@ -20537,7 +20539,8 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "type": "string", }, { - "description": "Shows tokens inside the trigger instead of below it.", + "description": "Shows tokens inside the trigger instead of below it. +When the component is rendered inside a control group, the tokens are rendered inside the trigger regardless of this property.", "name": "inlineTokens", "optional": true, "type": "boolean", @@ -26845,7 +26848,9 @@ Set this property if the dropdown would otherwise be constrained by a scrollable for example inside table and split view layouts. We recommend you use discretion, and don't enable this property unless necessary -because fixed positioning results in a slight, visible lag when scrolling complex pages.", +because fixed positioning results in a slight, visible lag when scrolling complex pages. + +When the component is rendered in a control group, the dropdown will be rendered using this portal-based method regardless of this property.", "name": "expandToViewport", "optional": true, "type": "boolean", diff --git a/src/multiselect/interfaces.ts b/src/multiselect/interfaces.ts index 1ca60ab205e..7cd9dbecbb7 100644 --- a/src/multiselect/interfaces.ts +++ b/src/multiselect/interfaces.ts @@ -33,6 +33,7 @@ export interface MultiselectProps extends BaseSelectProps { hideTokens?: boolean; /** * Shows tokens inside the trigger instead of below it. + * When the component is rendered inside a control group, the tokens are rendered inside the trigger regardless of this property. */ inlineTokens?: boolean; /** diff --git a/src/select/interfaces.ts b/src/select/interfaces.ts index cbd1ebe000e..35dff8b27ad 100644 --- a/src/select/interfaces.ts +++ b/src/select/interfaces.ts @@ -178,6 +178,21 @@ export interface BaseSelectProps * Adds `aria-describedby` to the dropdown's listbox/dialog content. */ dropdownAriaDescribedby?: string; + + /** + * By default, the dropdown height is constrained to fit inside the height of its next scrollable container element. + * Enabling this property will allow the dropdown to extend beyond that container by using fixed positioning and + * [React Portals](https://reactjs.org/docs/portals.html). + * + * Set this property if the dropdown would otherwise be constrained by a scrollable container, + * for example inside table and split view layouts. + * + * We recommend you use discretion, and don't enable this property unless necessary + * because fixed positioning results in a slight, visible lag when scrolling complex pages. + * + * When the component is rendered in a control group, the dropdown will be rendered using this portal-based method regardless of this property. + */ + expandToViewport?: boolean; } export interface SelectProps extends BaseSelectProps { From e1e2bb3206dc5ea0d1f39af551d306b9d45f3781 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 22 Oct 2026 11:57:09 +0200 Subject: [PATCH 03/17] refactor: Promote control group to public component Promote the control-group component from an internal component to a fully public component, co-located following the segmented-control / tiles convention. - Move implementation to src/control-group/internal.tsx - Co-locate interfaces, styles, tests, and integ tests under src/control-group/ - Keep the shared grouped-control-styles helper internal under src/internal/components/control-group-styles/ - Repoint all tests and dev pages to the public component - Repoint test-utils to the public component sim: https://taskei.amazon.dev/tasks/control-group-release --- pages/control-group/in-app-layout.page.tsx | 2 +- pages/control-group/responsive.page.tsx | 2 +- pages/control-group/validation.page.tsx | 2 +- .../__tests__/control-group.test.tsx | 2 +- .../__integ__/control-group.test.ts | 4 +- .../control-group/__tests__/common.tsx | 2 +- .../__tests__/control-group.test.tsx | 12 ++-- src/control-group/index.tsx | 2 +- src/control-group/interfaces.ts | 59 +++++++++++++++++- .../index.tsx => control-group/internal.tsx} | 20 +++--- .../components => }/control-group/styles.scss | 8 +-- .../control-group/test-classes/styles.scss | 0 src/input/__tests__/control-group.test.tsx | 2 +- src/input/internal.tsx | 2 +- .../components/autosuggest-input/index.tsx | 2 +- .../components/button-trigger/index.tsx | 2 +- .../grouped-control-styles.ts | 4 ++ .../components/control-group/interfaces.ts | 61 ------------------- .../__tests__/control-group.test.tsx | 2 +- .../internal-segmented-control.tsx | 2 +- src/segmented-control/internal.tsx | 2 +- src/select/__tests__/control-group.test.tsx | 2 +- src/test-utils/dom/control-group/index.ts | 2 +- 23 files changed, 98 insertions(+), 100 deletions(-) rename src/{internal/components => }/control-group/__integ__/control-group.test.ts (96%) rename src/{internal/components => }/control-group/__tests__/common.tsx (82%) rename src/{internal/components => }/control-group/__tests__/control-group.test.tsx (95%) rename src/{internal/components/control-group/index.tsx => control-group/internal.tsx} (91%) rename src/{internal/components => }/control-group/styles.scss (96%) rename src/{internal/components => }/control-group/test-classes/styles.scss (100%) rename src/internal/components/{control-group => control-group-styles}/grouped-control-styles.ts (82%) delete mode 100644 src/internal/components/control-group/interfaces.ts diff --git a/pages/control-group/in-app-layout.page.tsx b/pages/control-group/in-app-layout.page.tsx index 3d9cffb0bca..05506c176a3 100644 --- a/pages/control-group/in-app-layout.page.tsx +++ b/pages/control-group/in-app-layout.page.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { Container, ContentLayout, Header, ItemCard, SpaceBetween } from '~components'; import AppLayout from '~components/app-layout'; -import ControlGroup from '~components/internal/components/control-group'; +import ControlGroup from '~components/control-group'; import labels from '../app-layout/utils/labels'; import ScreenshotArea from '../utils/screenshot-area'; diff --git a/pages/control-group/responsive.page.tsx b/pages/control-group/responsive.page.tsx index 03bcbc444b1..b6e2fe51349 100644 --- a/pages/control-group/responsive.page.tsx +++ b/pages/control-group/responsive.page.tsx @@ -3,9 +3,9 @@ import React from 'react'; import Box from '~components/box'; +import ControlGroup from '~components/control-group'; import FormField from '~components/form-field'; import Input from '~components/input'; -import ControlGroup from '~components/internal/components/control-group'; import RadioGroup from '~components/radio-group'; import Select from '~components/select'; import SpaceBetween from '~components/space-between'; diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 8021c8f5148..9f0aff7e6d6 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -4,9 +4,9 @@ import React from 'react'; import Autosuggest from '~components/autosuggest'; import Checkbox from '~components/checkbox'; +import ControlGroup, { ControlGroupProps } from '~components/control-group'; import FormField from '~components/form-field'; import Input from '~components/input'; -import ControlGroup, { ControlGroupProps } from '~components/internal/components/control-group'; import Multiselect from '~components/multiselect'; import RadioGroup from '~components/radio-group'; import SegmentedControl from '~components/segmented-control'; diff --git a/src/autosuggest/__tests__/control-group.test.tsx b/src/autosuggest/__tests__/control-group.test.tsx index b35cf0d0081..7286a4d4d8c 100644 --- a/src/autosuggest/__tests__/control-group.test.tsx +++ b/src/autosuggest/__tests__/control-group.test.tsx @@ -6,7 +6,7 @@ import { render } from '@testing-library/react'; import Autosuggest from '../../../lib/components/autosuggest'; import ControlGroup from '../../../lib/components/control-group'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; diff --git a/src/internal/components/control-group/__integ__/control-group.test.ts b/src/control-group/__integ__/control-group.test.ts similarity index 96% rename from src/internal/components/control-group/__integ__/control-group.test.ts rename to src/control-group/__integ__/control-group.test.ts index 7d637f61df1..5e4ccce8047 100644 --- a/src/internal/components/control-group/__integ__/control-group.test.ts +++ b/src/control-group/__integ__/control-group.test.ts @@ -3,8 +3,8 @@ import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; -import createWrapper from '../../../../../lib/components/test-utils/selectors'; -import ControlGroupWrapper from '../../../../../lib/components/test-utils/selectors/internal/control-group'; +import createWrapper from '../../../lib/components/test-utils/selectors'; +import ControlGroupWrapper from '../../../lib/components/test-utils/selectors/control-group'; const wrapperSelector = '[data-testid="control-group-wrapper"]'; diff --git a/src/internal/components/control-group/__tests__/common.tsx b/src/control-group/__tests__/common.tsx similarity index 82% rename from src/internal/components/control-group/__tests__/common.tsx rename to src/control-group/__tests__/common.tsx index 2dad08dcc68..1c82e2d23eb 100644 --- a/src/internal/components/control-group/__tests__/common.tsx +++ b/src/control-group/__tests__/common.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { useGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; +import { useGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; export function PositionProbe({ testId = 'probe' }: { testId?: string }) { const { position } = useGroupedControlContext(); diff --git a/src/internal/components/control-group/__tests__/control-group.test.tsx b/src/control-group/__tests__/control-group.test.tsx similarity index 95% rename from src/internal/components/control-group/__tests__/control-group.test.tsx rename to src/control-group/__tests__/control-group.test.tsx index 65d0c419037..be7b47ac85d 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/control-group/__tests__/control-group.test.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { render } from '@testing-library/react'; -import FormField from '../../../../../lib/components/form-field'; -import ControlGroup from '../../../../../lib/components/internal/components/control-group'; -import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; -import Select from '../../../../../lib/components/select'; -import createWrapper from '../../../../../lib/components/test-utils/dom'; -import ControlGroupWrapper from '../../../../../lib/components/test-utils/dom/control-group'; +import ControlGroup from '../../../lib/components/control-group'; +import FormField from '../../../lib/components/form-field'; +import { ResetGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; +import Select from '../../../lib/components/select'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import ControlGroupWrapper from '../../../lib/components/test-utils/dom/control-group'; import { DirectionProbe, PositionProbe } from './common'; function findControlGroup(container: HTMLElement) { diff --git a/src/control-group/index.tsx b/src/control-group/index.tsx index 426b89c771f..e46983d65da 100644 --- a/src/control-group/index.tsx +++ b/src/control-group/index.tsx @@ -3,10 +3,10 @@ 'use client'; import React from 'react'; -import InternalControlGroup from '../internal/components/control-group'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { ControlGroupProps } from './interfaces'; +import InternalControlGroup from './internal'; export { ControlGroupProps }; diff --git a/src/control-group/interfaces.ts b/src/control-group/interfaces.ts index 21626d40f11..72ed0d79c7a 100644 --- a/src/control-group/interfaces.ts +++ b/src/control-group/interfaces.ts @@ -1,3 +1,60 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { ControlGroupProps } from '../internal/components/control-group/interfaces'; +import { ButtonProps } from '../button/interfaces'; +import { IconProps } from '../icon/interfaces'; +import { BaseComponentProps } from '../types/base-component'; +import { CancelableEventHandler } from '../types/events'; + +export interface ControlGroupProps extends BaseComponentProps { + children?: React.ReactNode; + /** + * The axis along which the controls are laid out (`horizontal`, `vertical` or `auto`). + * When set to `auto` (default), the component decides the direction based on whether it + * fits in the padding box of its + * [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Display/Containing_block). + */ + direction?: ControlGroupProps.ControlGroupDirection; + inlineLabelText?: string; + /** + * Specifies an action button rendered next to the grouped controls. + */ + actionButton?: ControlGroupProps.ActionButton; +} + +export namespace ControlGroupProps { + // `'auto'` exists only for the control group but not for the inner grouped controls, + // which receive a definite value (`horizontal` or `vertical`). + export type ControlGroupDirection = 'horizontal' | 'vertical' | 'auto'; + + export interface ActionButton { + /** + * Called when the user clicks on the button and the button is not disabled. + */ + onClick?: CancelableEventHandler; + + /** + * Renders the button as disabled and prevents clicks. + */ + disabled?: boolean; + + /** + * Provides a reason why the button is disabled (only when `disabled` is `true`). + * If provided, the button becomes focusable. + */ + disabledReason?: string; + + /** + * Adds `aria-label` to the button element. + * The text will also be added to the `title` attribute of the button. + */ + ariaLabel: string; + + /** + * Adds `aria-describedby` to the button. + */ + ariaDescribedby?: string; + + /** Specifies the icon to display (for example `remove` or `close`). */ + iconName: IconProps.Name; + } +} diff --git a/src/internal/components/control-group/index.tsx b/src/control-group/internal.tsx similarity index 91% rename from src/internal/components/control-group/index.tsx rename to src/control-group/internal.tsx index 2474ae04cff..0fc1b2c9938 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/control-group/internal.tsx @@ -5,26 +5,24 @@ import clsx from 'clsx'; import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; -import { InternalButton } from '../../../button/internal'; -import { getBaseProps } from '../../base-component'; +import { InternalButton } from '../button/internal'; +import { getBaseProps } from '../internal/base-component'; import { GroupedControlContext, GroupedControlDirection, GroupedControlPosition, -} from '../../context/control-group-context'; -import { FormFieldContext, useFormFieldContext } from '../../context/form-field-context'; -import { InternalBaseComponentProps } from '../../hooks/use-base-component'; -import { useFitsInline } from '../../hooks/use-fits-inline'; -import { flattenChildren } from '../../utils/flatten-children'; -import { joinStrings } from '../../utils/strings/join-strings'; +} from '../internal/context/control-group-context'; +import { FormFieldContext, useFormFieldContext } from '../internal/context/form-field-context'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useFitsInline } from '../internal/hooks/use-fits-inline'; +import { flattenChildren } from '../internal/utils/flatten-children'; +import { joinStrings } from '../internal/utils/strings/join-strings'; import { ControlGroupProps } from './interfaces'; import styles from './styles.css.js'; import testUtilStyles from './test-classes/styles.css.js'; -export { ControlGroupProps }; - -export default function ControlGroup({ +export default function InternalControlGroup({ children, direction = 'auto', inlineLabelText, diff --git a/src/internal/components/control-group/styles.scss b/src/control-group/styles.scss similarity index 96% rename from src/internal/components/control-group/styles.scss rename to src/control-group/styles.scss index 1ecc6c36b3c..91909d2ff8f 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/control-group/styles.scss @@ -3,10 +3,10 @@ SPDX-License-Identifier: Apache-2.0 */ -@use '../../styles' as styles; -@use '../../styles/tokens' as awsui; -@use '../../styles/forms/mixins' as forms; -@use '../../styles/forms/constants' as constants; +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; +@use '../internal/styles/forms/mixins' as forms; +@use '../internal/styles/forms/constants' as constants; $margin-overlap: calc(-1 * #{awsui.$border-width-field}); diff --git a/src/internal/components/control-group/test-classes/styles.scss b/src/control-group/test-classes/styles.scss similarity index 100% rename from src/internal/components/control-group/test-classes/styles.scss rename to src/control-group/test-classes/styles.scss diff --git a/src/input/__tests__/control-group.test.tsx b/src/input/__tests__/control-group.test.tsx index 89675d9fdba..cba74a97deb 100644 --- a/src/input/__tests__/control-group.test.tsx +++ b/src/input/__tests__/control-group.test.tsx @@ -6,7 +6,7 @@ import { render } from '@testing-library/react'; import ControlGroup from '../../../lib/components/control-group'; import Input from '../../../lib/components/input'; import ControlGroupWrapper from '../../../lib/components/test-utils/dom/control-group'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; diff --git a/src/input/internal.tsx b/src/input/internal.tsx index 0c80cb30dc6..b80d2db9d4f 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -14,7 +14,7 @@ import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index b2f69e3b1ca..ad94ed0321d 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -38,7 +38,7 @@ import { getInputStylesCss } from '../../utils/input-styles'; import { nodeBelongs } from '../../utils/node-belongs'; import { processAttributes } from '../../utils/with-native-attributes'; import ButtonTrigger from '../button-trigger'; -import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../control-group-styles/grouped-control-styles'; import { InlineTokens, InlineTokensRef } from './inline-tokens'; import { useTokenOverflow } from './use-token-overflow'; diff --git a/src/internal/components/button-trigger/index.tsx b/src/internal/components/button-trigger/index.tsx index 4498e0c21b7..1dd5dab62d1 100644 --- a/src/internal/components/button-trigger/index.tsx +++ b/src/internal/components/button-trigger/index.tsx @@ -12,7 +12,7 @@ import { BaseKeyDetail, CancelableEventHandler } from '../../../types/events'; import { getBaseProps } from '../../base-component'; import { GroupedControlContextProps } from '../../context/control-group-context'; import { fireCancelableEvent, fireKeyboardEvent } from '../../events'; -import { getGroupedControlClassNames } from '../control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../control-group-styles/grouped-control-styles'; import { GeneratedAnalyticsMetadataButtonTriggerCollapse, GeneratedAnalyticsMetadataButtonTriggerExpand, diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group-styles/grouped-control-styles.ts similarity index 82% rename from src/internal/components/control-group/grouped-control-styles.ts rename to src/internal/components/control-group-styles/grouped-control-styles.ts index 9918bd179ba..4d57e7786c5 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group-styles/grouped-control-styles.ts @@ -4,6 +4,10 @@ import clsx from 'clsx'; import { GroupedControlContextProps } from '../../context/control-group-context'; +// STAYS INTERNAL: shared grouped-control styling helper used by the public ControlGroup +// implementation and other grouped controls (input, segmented-control, button-trigger, +// autosuggest-input). It is not part of the ControlGroup public surface. + /** * Builds the shared grouped-control CSS classes for a consumer. * diff --git a/src/internal/components/control-group/interfaces.ts b/src/internal/components/control-group/interfaces.ts deleted file mode 100644 index d4b8f252c94..00000000000 --- a/src/internal/components/control-group/interfaces.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { ButtonProps } from '../../../button/interfaces'; -import { IconProps } from '../../../icon/interfaces'; -import { BaseComponentProps } from '../../../types/base-component'; -import { GroupedControlDirection } from '../../context/control-group-context'; -import { CancelableEventHandler } from '../../events'; - -export interface ControlGroupProps extends BaseComponentProps { - children?: React.ReactNode; - /** - * The axis along which the controls are laid out (`horizontal`, `vertical` or `auto`). - * When set to `auto` (default), the component decides the direction based on whether it - * fits in the padding box of its - * [containing block](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Display/Containing_block). - */ - direction?: ControlGroupProps.ControlGroupDirection; - inlineLabelText?: string; - /** - * Specifies an action button rendered next to the grouped controls. - */ - actionButton?: ControlGroupProps.ActionButton; -} - -export namespace ControlGroupProps { - // `'auto'` exists only for the control group but not for the inner grouped controls, - // which receive a definite value (`horizontal` or `vertical`). - export type ControlGroupDirection = GroupedControlDirection | 'auto'; - - export interface ActionButton { - /** - * Called when the user clicks on the button and the button is not disabled. - */ - onClick?: CancelableEventHandler; - - /** - * Renders the button as disabled and prevents clicks. - */ - disabled?: boolean; - - /** - * Provides a reason why the button is disabled (only when `disabled` is `true`). - * If provided, the button becomes focusable. - */ - disabledReason?: string; - - /** - * Adds `aria-label` to the button element. - * The text will also be added to the `title` attribute of the button. - */ - ariaLabel: string; - - /** - * Adds `aria-describedby` to the button. - */ - ariaDescribedby?: string; - - /** Specifies the icon to display (for example `remove` or `close`). */ - iconName: IconProps.Name; - } -} diff --git a/src/multiselect/__tests__/control-group.test.tsx b/src/multiselect/__tests__/control-group.test.tsx index 004b9b13898..a02796fa019 100644 --- a/src/multiselect/__tests__/control-group.test.tsx +++ b/src/multiselect/__tests__/control-group.test.tsx @@ -6,7 +6,7 @@ import { render } from '@testing-library/react'; import ControlGroup from '../../../lib/components/control-group'; import Multiselect from '../../../lib/components/multiselect'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; const options = [{ value: '1', label: 'One' }]; const noop = () => {}; diff --git a/src/segmented-control/internal-segmented-control.tsx b/src/segmented-control/internal-segmented-control.tsx index 57e212deaeb..54466b59938 100644 --- a/src/segmented-control/internal-segmented-control.tsx +++ b/src/segmented-control/internal-segmented-control.tsx @@ -3,7 +3,7 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { GroupedControlContextProps } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; diff --git a/src/segmented-control/internal.tsx b/src/segmented-control/internal.tsx index aeb42491d7a..ec1f9c99bd5 100644 --- a/src/segmented-control/internal.tsx +++ b/src/segmented-control/internal.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import InternalFormField from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; +import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; diff --git a/src/select/__tests__/control-group.test.tsx b/src/select/__tests__/control-group.test.tsx index 0e61332794e..c18ad2a2920 100644 --- a/src/select/__tests__/control-group.test.tsx +++ b/src/select/__tests__/control-group.test.tsx @@ -6,7 +6,7 @@ import { render } from '@testing-library/react'; import ControlGroup from '../../../lib/components/control-group'; import Select from '../../../lib/components/select'; import createWrapper from '../../../lib/components/test-utils/dom'; -import { PositionProbe } from '../../internal/components/control-group/__tests__/common'; +import { PositionProbe } from '../../control-group/__tests__/common'; describe('Select control in control group', () => { test('resets the context for custom dropdown content', () => { diff --git a/src/test-utils/dom/control-group/index.ts b/src/test-utils/dom/control-group/index.ts index 96f9bff1c76..9213fc29a8e 100644 --- a/src/test-utils/dom/control-group/index.ts +++ b/src/test-utils/dom/control-group/index.ts @@ -4,7 +4,7 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils- import ButtonWrapper from '../button'; -import testUtilStyles from '../../../internal/components/control-group/test-classes/styles.selectors.js'; +import testUtilStyles from '../../../control-group/test-classes/styles.selectors.js'; export default class ControlGroupWrapper extends ComponentWrapper { static rootSelector: string = testUtilStyles.root; From 8729e84d0559af4e9bac9882169b2942b0cdc0f9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 22 Oct 2026 15:36:57 +0200 Subject: [PATCH 04/17] feat: Add form field control props to control group Port cloudscape-design/components#5173 onto the relocated public control-group component. The PR's original internal paths (src/internal/components/control-group/) are deleted on this branch; the component now lives at src/control-group/. - interfaces.ts: ControlGroupProps extends FormFieldControlProps, exposing controlId / ariaLabelledby / ariaDescribedby in the public API (imported from ../types/form-field, public-safe). - internal.tsx: wire the three props into useFormFieldContext and resolve a group id for the inline-label case (generated fallback so the inline label's htmlFor can reference the native group). - Add 4 unit tests covering controlId and the ARIA properties, including the inline-label + ariaLabelledby merge. - Regenerate the documenter snapshot to reflect the three new props. --- .../__snapshots__/documenter.test.ts.snap | 35 +++++++++++++ .../__tests__/control-group.test.tsx | 49 +++++++++++++++++++ src/control-group/interfaces.ts | 3 +- src/control-group/internal.tsx | 11 ++++- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 3d072e2721f..ed87e362b39 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -11054,6 +11054,31 @@ exports[`Components definition for control-group matches the snapshot: control-g "optional": true, "type": "ControlGroupProps.ActionButton", }, + { + "description": "Adds \`aria-describedby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for each element that you want to use as a description +and set the property to a string of each ID separated by spaces (for example, \`"id1 id2 id3"\`).", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the component. If you're using this component within a form field, +don't set this property because the form field component automatically sets it. + +Use this property if the component isn't surrounded by a form field, or you want to override the value +automatically set by the form field (for example, if you have two components within a single form field). + +To use it correctly, define an ID for the element you want to use as label and set the property to that ID.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, { "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", "description": "Adds the specified classes to the root element of the component.", @@ -11061,6 +11086,16 @@ exports[`Components definition for control-group matches the snapshot: control-g "optional": true, "type": "string", }, + { + "description": "Specifies the ID of the native form element. You can use it to relate +a label element's \`for\` attribute to this control. + +It defaults to an automatically generated ID that +is provided by its parent form field component.", + "name": "controlId", + "optional": true, + "type": "string", + }, { "defaultValue": "'auto'", "description": "The axis along which the controls are laid out (\`horizontal\`, \`vertical\` or \`auto\`). diff --git a/src/control-group/__tests__/control-group.test.tsx b/src/control-group/__tests__/control-group.test.tsx index be7b47ac85d..9b0b84a86cd 100644 --- a/src/control-group/__tests__/control-group.test.tsx +++ b/src/control-group/__tests__/control-group.test.tsx @@ -273,4 +273,53 @@ describe('Control group', () => { expect(secondId).toBeTruthy(); expect(firstId).not.toBe(secondId); }); + + test('controlId sets the id on the element with role="group"', () => { + const { getByRole } = render( + + + + + ); + + expect(getByRole('group').id).toBe('my-control-id'); + }); + + describe('ARIA properties', () => { + test('ariaLabelledby sets aria-labelledby on the group', () => { + const { getByRole } = render( + + + + + ); + + expect(getByRole('group').getAttribute('aria-labelledby')).toBe('external-label'); + }); + + test('ariaDescribedby sets aria-describedby on the group', () => { + const { getByRole } = render( + + + + + ); + + expect(getByRole('group').getAttribute('aria-describedby')).toBe('external-description'); + }); + + test('merges ariaLabelledby with the inline label id on the group', () => { + const { container, getByRole } = render( + + + + + ); + + const inlineLabelId = findControlGroup(container)!.findInlineLabel()!.getElement().id; + const labelledby = getByRole('group').getAttribute('aria-labelledby')!.split(' '); + expect(labelledby).toContain('external-label'); + expect(labelledby).toContain(inlineLabelId); + }); + }); }); diff --git a/src/control-group/interfaces.ts b/src/control-group/interfaces.ts index 72ed0d79c7a..a5921809176 100644 --- a/src/control-group/interfaces.ts +++ b/src/control-group/interfaces.ts @@ -4,8 +4,9 @@ import { ButtonProps } from '../button/interfaces'; import { IconProps } from '../icon/interfaces'; import { BaseComponentProps } from '../types/base-component'; import { CancelableEventHandler } from '../types/events'; +import { FormFieldControlProps } from '../types/form-field'; -export interface ControlGroupProps extends BaseComponentProps { +export interface ControlGroupProps extends BaseComponentProps, FormFieldControlProps { children?: React.ReactNode; /** * The axis along which the controls are laid out (`horizontal`, `vertical` or `auto`). diff --git a/src/control-group/internal.tsx b/src/control-group/internal.tsx index 0fc1b2c9938..dc32090c72a 100644 --- a/src/control-group/internal.tsx +++ b/src/control-group/internal.tsx @@ -27,13 +27,16 @@ export default function InternalControlGroup({ direction = 'auto', inlineLabelText, actionButton, + controlId, + ariaLabelledby, + ariaDescribedby, __internalRootRef, ...props }: ControlGroupProps & InternalBaseComponentProps) { const baseProps = getBaseProps(props); const labelId = useUniqueId('control-group-label'); - const formFieldContext = useFormFieldContext({}); + const formFieldContext = useFormFieldContext({ ariaLabelledby, ariaDescribedby, controlId }); const isInvalid = !!formFieldContext.invalid; const isWarning = !!formFieldContext.warning && !isInvalid; @@ -43,6 +46,11 @@ export default function InternalControlGroup({ const groupAriaLabelledby = joinStrings(formFieldContext.ariaLabelledby, inlineLabelText ? labelId : undefined); const groupAriaDescribedby = joinStrings(formFieldContext.ariaDescribedby); + // When an inline label is rendered, the native group must have an id so the + // label's htmlFor can reference it. Fall back to a generated id if none was provided. + const generatedControlId = useUniqueId('control-group'); + const resolvedControlId = formFieldContext.controlId ?? (inlineLabelText ? generatedControlId : undefined); + const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; @@ -119,6 +127,7 @@ export default function InternalControlGroup({ ? {} : { role: 'group', + id: resolvedControlId, 'aria-labelledby': groupAriaLabelledby, 'aria-describedby': groupAriaDescribedby, 'aria-invalid': isInvalid ? 'true' : undefined, From 34fdd012c4cdce81801f78eac3b39c67623b5fa1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 22 Oct 2026 17:52:20 +0200 Subject: [PATCH 05/17] feat: Add grouped-control disabled styles and dev warnings Port GitHub PRs #5182 and #5172 onto the relocated public control-group component (src/control-group/; the PRs' internal paths under src/internal/components/control-group/ are deleted on this branch). #5182: disabled grouped-control styles (grouped-control-disabled mixin), parameterized grouped-control validation-border padding, grouped-control CSS class renames (with-error/with-warning -> grouped-with-error/ grouped-with-warning, grouped-validation-first -> grouped-row-first) across the shared mixin, grouped-control-styles helper, input, autosuggest-input, button-trigger and segmented-control; base border now color-border-control-group-default; segmented-control wrapper drops the grouped-control class helper; plus an autosuggest inline-tokens validation dev-page example. #5172: a shared grouped-control dev-warnings module, its test, and consumer wiring in autosuggest, input, multiselect and select. Path remaps: grouped-control-styles helper and the new dev-warnings module live under src/internal/components/control-group-styles/; the dev-warnings test lives under the public src/control-group/__tests__/. The multiselect inlineTokens destructured default is dropped (false -> no default) so an explicit false is distinguishable from undefined for the warning. --- pages/control-group/validation.page.tsx | 25 ++- src/autosuggest/internal.tsx | 11 ++ .../__tests__/dev-warnings.test.tsx | 169 ++++++++++++++++++ src/input/internal.tsx | 9 + src/input/styles.scss | 10 +- .../components/autosuggest-input/styles.scss | 3 + .../components/button-trigger/styles.scss | 5 +- .../control-group-styles/dev-warnings.ts | 89 +++++++++ .../grouped-control-styles.ts | 7 +- .../styles/forms/grouped-control.scss | 59 ++++-- src/multiselect/internal.tsx | 13 +- src/segmented-control/internal.tsx | 3 +- src/segmented-control/styles.scss | 4 +- src/select/internal.tsx | 10 ++ 14 files changed, 386 insertions(+), 31 deletions(-) create mode 100644 src/control-group/__tests__/dev-warnings.test.tsx create mode 100644 src/internal/components/control-group-styles/dev-warnings.ts diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 9f0aff7e6d6..a61d23e29e9 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -22,7 +22,7 @@ import { Direction, multiOptions, noop, operators, segments, suggestions } from type ValidationState = 'none' | 'error' | 'warning'; // The individual controls that can be individually disabled. -type StatefulControl = 'input' | 'autosuggest' | 'select' | 'multiselect'; +type StatefulControl = 'input' | 'autosuggest' | 'autosuggest-tokens' | 'select' | 'multiselect'; // Control kinds that can appear inside a group. The stateful ones can be disabled; // segmentedControl cannot. @@ -31,7 +31,14 @@ type ControlKind = StatefulControl | 'segmentedControl'; // Things that can appear in the `disabled` list: the stateful controls plus the action button. type Disableable = StatefulControl | 'actionbutton'; -const disableableItems: ReadonlyArray = ['input', 'autosuggest', 'select', 'multiselect', 'actionbutton']; +const disableableItems: ReadonlyArray = [ + 'input', + 'autosuggest', + 'autosuggest-tokens', + 'select', + 'multiselect', + 'actionbutton', +]; interface ControlGroupValidationSettings { // The group-as-a-unit validation, applied through the surrounding FormField. @@ -82,6 +89,17 @@ function renderControl(kind: ControlKind, disabled: ReadonlySet) { {...stateProps('autosuggest', disabled)} /> ); + case 'autosuggest-tokens': + return ( + + ); case 'select': return ( ], + ['Autosuggest', ], + ['Select', ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('warning prop', () => { + test.each([ + ['Input', ], + [ + 'Autosuggest', + ''} warning={true} />, + ], + ['Select', ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('readOnly prop', () => { + test.each([ + ['Input', ], + ['Autosuggest', ], + ['Select', ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('multiselect inlineTokens prop', () => { + test('warns when inlineTokens is false inside a control group', () => { + render( + + + + ); + expect(warnOnce).toHaveBeenCalledWith( + 'Multiselect', + 'The `inlineTokens` prop is ignored inside a control group; tokens are always shown inline.' + ); + }); + + test('does not warn when inlineTokens is true inside a control group', () => { + render( + + + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when inlineTokens is undefined inside a control group', () => { + render( + + + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when inlineTokens is false for a standalone multiselect', () => { + render(); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); + + describe('expandToViewport prop', () => { + test.each([ + ['Autosuggest', ], + ['Select', + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + + test('does not warn when expandToViewport is undefined inside a control group', () => { + render( + + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/input/internal.tsx b/src/input/internal.tsx index b80d2db9d4f..351325e9e4c 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -14,6 +14,7 @@ import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; @@ -153,6 +154,14 @@ function InternalInput( const generatedControlId = useUniqueId('input'); const controlId = controlIdFromFormFieldContext ?? (inlineLabelText ? generatedControlId : undefined); + warnOnUnsupportedGroupedControlProps({ + isGrouped: !!groupedControlProps.position, + componentName: 'Input', + invalid: invalid, + warning: warning, + readOnly, + }); + const hasPrefix = !!prefix; const hasSuffix = !!suffix; const hasAdornments = hasPrefix || hasSuffix; diff --git a/src/input/styles.scss b/src/input/styles.scss index 8a41f910ae4..4e64c96e0e9 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -228,11 +228,11 @@ } &.grouped { - @include styles.grouped-control; - - // Unlike other controls, input's padding is rendered by the inner input element and not by its wrapper. - &.grouped-validation-first { - padding-inline-start: awsui.$border-width-field; + // Unlike other controls, input's padding is rendered by the inner input element and not by its wrapper, + // so we should not add extra space to the wrapper for the validation border. + @include styles.grouped-control($validation-border-padding: awsui.$border-width-field); + &.input-field-disabled { + @include styles.grouped-control-disabled; } } } diff --git a/src/internal/components/autosuggest-input/styles.scss b/src/internal/components/autosuggest-input/styles.scss index 1405197d968..a20aaf4e145 100644 --- a/src/internal/components/autosuggest-input/styles.scss +++ b/src/internal/components/autosuggest-input/styles.scss @@ -89,6 +89,9 @@ &.grouped { @include styles.grouped-control; + &.token-field-disabled { + @include styles.grouped-control-disabled; + } } } diff --git a/src/internal/components/button-trigger/styles.scss b/src/internal/components/button-trigger/styles.scss index 04048ad11e4..a451d49ee07 100644 --- a/src/internal/components/button-trigger/styles.scss +++ b/src/internal/components/button-trigger/styles.scss @@ -127,7 +127,7 @@ $icon-offset: awsui.$space-xxxs; text-decoration: none; } - &:not(.in-filtering-token):not(.compact):not(.grouped.with-error):not(.grouped.with-warning) { + &:not(.in-filtering-token):not(.compact):not(.grouped-with-error):not(.grouped-with-warning) { @include focus-visible.when-visible { @include styles.form-focus-element(); } @@ -190,5 +190,8 @@ $icon-offset: awsui.$space-xxxs; &.grouped { @include styles.grouped-control; + &.disabled { + @include styles.grouped-control-disabled; + } } } diff --git a/src/internal/components/control-group-styles/dev-warnings.ts b/src/internal/components/control-group-styles/dev-warnings.ts new file mode 100644 index 00000000000..f2b67b14a01 --- /dev/null +++ b/src/internal/components/control-group-styles/dev-warnings.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { isDevelopment } from '../../is-development'; + +export interface GroupedControlWarningsProps { + /** + * Whether the control is currently rendered inside a control group. + */ + isGrouped: boolean; + /** + * The public component name used as the prefix in the dev warning + * (for example `'Input'` or `'Multiselect'`). + */ + componentName: string; + /** + * The resolved value of the control's `invalid` prop. + */ + invalid?: boolean; + /** + * The resolved value of the control's `warning` prop. + */ + warning?: boolean; + /** + * The resolved value of the control's `readOnly` prop. + */ + readOnly?: boolean; + /** + * The resolved value of the multiselect's `inlineTokens` prop. Only + * meaningful for multiselect. + */ + inlineTokens?: boolean; + /** + * The resolved value of the control's `expandToViewport` prop. Only + * meaningful for the dropdown controls (autosuggest, select, multiselect);. + */ + expandToViewport?: boolean; +} + +/** + * Emits development-only warnings for props that are unsupported when one of the + * grouped-aware controls is rendered inside a control group. + */ +export function warnOnUnsupportedGroupedControlProps({ + isGrouped, + componentName, + invalid, + warning, + readOnly, + inlineTokens, + expandToViewport, +}: GroupedControlWarningsProps): void { + if (!isDevelopment || !isGrouped) { + return; + } + + if (invalid) { + warnOnce( + componentName, + 'The `invalid` prop is ignored on controls inside a control group. Set the validation state on the control group instead.' + ); + } + + if (warning) { + warnOnce( + componentName, + 'The `warning` prop is ignored on controls inside a control group. Set the validation state on the control group instead.' + ); + } + + if (readOnly) { + warnOnce(componentName, 'The `readOnly` prop is not supported on controls inside a control group.'); + } + + if (inlineTokens === false) { + warnOnce( + componentName, + 'The `inlineTokens` prop is ignored inside a control group; tokens are always shown inline.' + ); + } + + if (expandToViewport === false) { + warnOnce( + componentName, + 'The `expandToViewport` prop is ignored inside a control group; the dropdown is always expanded to the viewport.' + ); + } +} diff --git a/src/internal/components/control-group-styles/grouped-control-styles.ts b/src/internal/components/control-group-styles/grouped-control-styles.ts index 4d57e7786c5..ba21c1be6f6 100644 --- a/src/internal/components/control-group-styles/grouped-control-styles.ts +++ b/src/internal/components/control-group-styles/grouped-control-styles.ts @@ -22,13 +22,12 @@ export function getGroupedControlClassNames( if (!position) { return ''; } - // Only the leading (inline-start) controls draw the thick validation border - const isLeading = direction === 'vertical' || position === 'first' || position === 'only'; + const isFirstInRow = direction === 'vertical' || position === 'first' || position === 'only'; return clsx( styles.grouped, styles[`grouped-${direction}-${position}`], hasAction && styles['grouped-with-action'], - validation && (validation === 'error' ? styles['with-error'] : styles['with-warning']), - validation && isLeading && styles['grouped-validation-first'] + validation && (validation === 'error' ? styles['grouped-with-error'] : styles['grouped-with-warning']), + isFirstInRow && styles['grouped-row-first'] ); } diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index f827fc04461..e6538305ee5 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -12,10 +12,7 @@ $grouped-control-border-radius: constants.$control-border-radius; @mixin grouped-control-base { @include styles.logical-radius($grouped-control-border-radius); - border-inline-start-color: awsui.$color-border-input-default; - border-inline-end-color: awsui.$color-border-input-default; - border-block-start-color: awsui.$color-border-input-default; - border-block-end-color: awsui.$color-border-input-default; + border-color: awsui.$color-border-control-group-default; } @mixin grouped-control-horizontal-first { @@ -105,7 +102,42 @@ $grouped-control-border-radius: constants.$control-border-radius; } } -@mixin grouped-control { +@mixin grouped-control-disabled { + &:not(.grouped-with-error):not(.grouped-with-warning) { + &.grouped-horizontal-first, + &.grouped-horizontal-middle, + &.grouped-horizontal-last, + &.grouped-horizontal-only { + border-block-color: transparent; + } + &.grouped-vertical-first, + &.grouped-vertical-only { + border-block-start-color: transparent; + } + &.grouped-vertical-last, + &.grouped-vertical-only { + border-block-end-color: transparent; + } + &.grouped-row-first { + border-inline-start-color: transparent; + } + &:not(.grouped-with-action) { + &.grouped-vertical-first, + &.grouped-vertical-middle, + &.grouped-vertical-only, + &.grouped-horizontal-last, + &.grouped-horizontal-only { + border-inline-end-color: transparent; + } + } + } +} + +@mixin grouped-control( + $validation-border-padding: calc( + #{constants.$invalid-control-left-border} + #{constants.$invalid-control-left-padding} + ) +) { @include grouped-control-base; &-horizontal { @@ -137,15 +169,19 @@ $grouped-control-border-radius: constants.$control-border-radius; border-end-end-radius: 0; } - &.with-error { + &-with-error { @include grouped-control-status-edges(awsui.$color-text-status-error); } - &.with-warning { + &-with-warning { @include grouped-control-status-edges(awsui.$color-text-status-warning); } - &.with-error, - &.with-warning { + &-with-error, + &-with-warning { + &.grouped-row-first { + // Room for the validation thick border, which is rendered from the control group wrapper + padding-inline-start: $validation-border-padding; + } @include focus-visible.when-visible { @include mixins.windows-high-contrast-outline; box-shadow: foundation.$box-shadow-focused-light-invalid; @@ -155,9 +191,4 @@ $grouped-control-border-radius: constants.$control-border-radius; box-shadow: foundation.$box-shadow-focused-light-invalid; } } - - &.grouped-validation-first { - // Room for the validation thick border, which is rendered from the control group wrapper - padding-inline-start: calc(#{constants.$invalid-control-left-border} + #{constants.$invalid-control-left-padding}); - } } diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index d72ad0a38be..629003a992e 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -9,6 +9,7 @@ import Dropdown from '../dropdown/internal'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { getBreakpointValue } from '../internal/breakpoints'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; import DropdownFooter from '../internal/components/dropdown-footer/index.js'; import ScreenreaderOnly from '../internal/components/screenreader-only'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; @@ -56,7 +57,7 @@ const InternalMultiselect = React.forwardRef( tokenLimit, i18nStrings, virtualScroll, - inlineTokens = false, + inlineTokens, hideTokens, expandToViewport, tokenLimitShowFewerAriaLabel, @@ -80,6 +81,16 @@ const InternalMultiselect = React.forwardRef( const isGrouped = !!groupedControlProps.position; + warnOnUnsupportedGroupedControlProps({ + isGrouped, + componentName: 'Multiselect', + invalid: formFieldContext.invalid, + warning: formFieldContext.warning, + readOnly, + inlineTokens, + expandToViewport, + }); + // When rendered inside a control group, tokens are always shown inline in the // trigger regardless of the `inlineTokens` prop, since there is no room to // display tokens below the control within a group. diff --git a/src/segmented-control/internal.tsx b/src/segmented-control/internal.tsx index ec1f9c99bd5..aabe9595020 100644 --- a/src/segmented-control/internal.tsx +++ b/src/segmented-control/internal.tsx @@ -5,7 +5,6 @@ import clsx from 'clsx'; import InternalFormField from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -58,7 +57,7 @@ export default function InternalSegmentedControl({ style={style} groupedControlProps={groupedControlProps} /> -
+
{ariaLabelledby && } {!ariaLabelledby && label && ( diff --git a/src/segmented-control/styles.scss b/src/segmented-control/styles.scss index 11b8439e46c..76eacb96b0e 100644 --- a/src/segmented-control/styles.scss +++ b/src/segmented-control/styles.scss @@ -72,8 +72,8 @@ } // The focused inner segment option draws its own ring, so suppress the group's. - &.with-error, - &.with-warning { + &-with-error, + &-with-warning { &:focus-within { box-shadow: none; } diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 945795de6b9..58bbfeeafdf 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -9,6 +9,7 @@ import Dropdown from '../dropdown/internal'; import { useInternalI18n } from '../i18n/context.js'; import { getBaseProps } from '../internal/base-component'; import { getBreakpointValue } from '../internal/breakpoints'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; import { prepareOptions } from '../internal/components/option/utils/prepare-options.js'; @@ -85,6 +86,15 @@ const InternalSelect = React.forwardRef( const formFieldContext = useFormFieldContext(restProps); const groupedControlProps = useGroupedControlContext(); + warnOnUnsupportedGroupedControlProps({ + isGrouped: !!groupedControlProps.position, + componentName: 'Select', + invalid: formFieldContext.invalid, + warning: formFieldContext.warning, + readOnly, + expandToViewport, + }); + // When grouped, the dropdown is portaled regardless of the public // `expandToViewport` prop so it escapes the control slot's stacking context // and paints over the group's inline label (which the focus ring stays under). From 7d1e27b0861a007b594ad9d0f41134643a378bbc Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 19:34:37 +0200 Subject: [PATCH 06/17] fix: Exclude controlId from control group props Port the latest commits of PR #5173 onto the public control-group layout. ControlGroupProps now only picks ariaLabelledby and ariaDescribedby from FormFieldControlProps; the group id still comes from a surrounding FormField context. Regenerate the documenter snapshot. --- .../__snapshots__/documenter.test.ts.snap | 10 ---------- src/control-group/__tests__/control-group.test.tsx | 11 ----------- src/control-group/interfaces.ts | 4 +++- src/control-group/internal.tsx | 3 +-- 4 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ed87e362b39..cfd0cf2ff8e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -11086,16 +11086,6 @@ To use it correctly, define an ID for the element you want to use as label and s "optional": true, "type": "string", }, - { - "description": "Specifies the ID of the native form element. You can use it to relate -a label element's \`for\` attribute to this control. - -It defaults to an automatically generated ID that -is provided by its parent form field component.", - "name": "controlId", - "optional": true, - "type": "string", - }, { "defaultValue": "'auto'", "description": "The axis along which the controls are laid out (\`horizontal\`, \`vertical\` or \`auto\`). diff --git a/src/control-group/__tests__/control-group.test.tsx b/src/control-group/__tests__/control-group.test.tsx index 9b0b84a86cd..bc7bda44666 100644 --- a/src/control-group/__tests__/control-group.test.tsx +++ b/src/control-group/__tests__/control-group.test.tsx @@ -274,17 +274,6 @@ describe('Control group', () => { expect(firstId).not.toBe(secondId); }); - test('controlId sets the id on the element with role="group"', () => { - const { getByRole } = render( - - - - - ); - - expect(getByRole('group').id).toBe('my-control-id'); - }); - describe('ARIA properties', () => { test('ariaLabelledby sets aria-labelledby on the group', () => { const { getByRole } = render( diff --git a/src/control-group/interfaces.ts b/src/control-group/interfaces.ts index a5921809176..4a35334dcef 100644 --- a/src/control-group/interfaces.ts +++ b/src/control-group/interfaces.ts @@ -6,7 +6,9 @@ import { BaseComponentProps } from '../types/base-component'; import { CancelableEventHandler } from '../types/events'; import { FormFieldControlProps } from '../types/form-field'; -export interface ControlGroupProps extends BaseComponentProps, FormFieldControlProps { +export interface ControlGroupProps + extends BaseComponentProps, + Pick { children?: React.ReactNode; /** * The axis along which the controls are laid out (`horizontal`, `vertical` or `auto`). diff --git a/src/control-group/internal.tsx b/src/control-group/internal.tsx index dc32090c72a..6222345cb80 100644 --- a/src/control-group/internal.tsx +++ b/src/control-group/internal.tsx @@ -27,7 +27,6 @@ export default function InternalControlGroup({ direction = 'auto', inlineLabelText, actionButton, - controlId, ariaLabelledby, ariaDescribedby, __internalRootRef, @@ -36,7 +35,7 @@ export default function InternalControlGroup({ const baseProps = getBaseProps(props); const labelId = useUniqueId('control-group-label'); - const formFieldContext = useFormFieldContext({ ariaLabelledby, ariaDescribedby, controlId }); + const formFieldContext = useFormFieldContext({ ariaLabelledby, ariaDescribedby }); const isInvalid = !!formFieldContext.invalid; const isWarning = !!formFieldContext.warning && !isInvalid; From 139819130d83e8eb0365c458353949b5119ddfd1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 19:34:43 +0200 Subject: [PATCH 07/17] fix: Fix grouped control borders and select mode Port the latest commits of PR #5182 onto the public control-group layout: add a grouped- class, replace grouped-row-first with direction/position selectors to fix the vertical-last border, and restore grouped classes on the segmented control select mode. --- .../grouped-control-styles.ts | 5 ++--- src/internal/styles/forms/grouped-control.scss | 17 ++++++++--------- src/segmented-control/internal.tsx | 3 ++- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/internal/components/control-group-styles/grouped-control-styles.ts b/src/internal/components/control-group-styles/grouped-control-styles.ts index ba21c1be6f6..5c307605f53 100644 --- a/src/internal/components/control-group-styles/grouped-control-styles.ts +++ b/src/internal/components/control-group-styles/grouped-control-styles.ts @@ -22,12 +22,11 @@ export function getGroupedControlClassNames( if (!position) { return ''; } - const isFirstInRow = direction === 'vertical' || position === 'first' || position === 'only'; return clsx( styles.grouped, + styles[`grouped-${direction}`], styles[`grouped-${direction}-${position}`], hasAction && styles['grouped-with-action'], - validation && (validation === 'error' ? styles['grouped-with-error'] : styles['grouped-with-warning']), - isFirstInRow && styles['grouped-row-first'] + validation && (validation === 'error' ? styles['grouped-with-error'] : styles['grouped-with-warning']) ); } diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index e6538305ee5..8c9e5f9a328 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -104,10 +104,7 @@ $grouped-control-border-radius: constants.$control-border-radius; @mixin grouped-control-disabled { &:not(.grouped-with-error):not(.grouped-with-warning) { - &.grouped-horizontal-first, - &.grouped-horizontal-middle, - &.grouped-horizontal-last, - &.grouped-horizontal-only { + &.grouped-horizontal { border-block-color: transparent; } &.grouped-vertical-first, @@ -118,13 +115,13 @@ $grouped-control-border-radius: constants.$control-border-radius; &.grouped-vertical-only { border-block-end-color: transparent; } - &.grouped-row-first { + &.grouped-horizontal-first, + &.grouped-horizontal-only, + &.grouped-vertical { border-inline-start-color: transparent; } &:not(.grouped-with-action) { - &.grouped-vertical-first, - &.grouped-vertical-middle, - &.grouped-vertical-only, + &.grouped-vertical, &.grouped-horizontal-last, &.grouped-horizontal-only { border-inline-end-color: transparent; @@ -178,7 +175,9 @@ $grouped-control-border-radius: constants.$control-border-radius; &-with-error, &-with-warning { - &.grouped-row-first { + &.grouped-horizontal-first, + &.grouped-horizontal-only, + &.grouped-vertical { // Room for the validation thick border, which is rendered from the control group wrapper padding-inline-start: $validation-border-padding; } diff --git a/src/segmented-control/internal.tsx b/src/segmented-control/internal.tsx index aabe9595020..ec1f9c99bd5 100644 --- a/src/segmented-control/internal.tsx +++ b/src/segmented-control/internal.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import InternalFormField from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; +import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -57,7 +58,7 @@ export default function InternalSegmentedControl({ style={style} groupedControlProps={groupedControlProps} /> -
+
{ariaLabelledby && } {!ariaLabelledby && label && ( From 63a873c69c8c69da38d758a5637e690ad9ed6318 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 19:34:46 +0200 Subject: [PATCH 08/17] fix: Prevent autosuggest borders in validation Port PR #5186 onto the public control-group layout: wrap the autosuggest token field in ResetGroupedControlContext so nested controls do not pick up grouped borders in validation state. --- .../components/autosuggest-input/index.tsx | 118 +++++++++--------- 1 file changed, 60 insertions(+), 58 deletions(-) diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index ad94ed0321d..5a56ad505b6 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -434,66 +434,68 @@ const AutosuggestInput = React.forwardRef( }; const tokenField = isTokenMode && ( -
- - - - {tokenList.length > 0 && ( - { - if (isOpen) { - closeDropdown(); - } - setTokensOverflowOpen(isOpen); - }} - onDismiss={removeToken} - onFocusEnter={() => (preventOpenOnFocusRef.current = true)} - onNavigateToInput={() => { - setTokensOverflowOpen(false); - inputRef.current?.focus(); - }} - /> - )} -
- { - inputRef.current?.focus(); - clearValue(); - }, + +
+ + + + {tokenList.length > 0 && ( + { + if (isOpen) { + closeDropdown(); } - : {})} - {...formFieldContext} - invalid={false} - warning={false} - /> + setTokensOverflowOpen(isOpen); + }} + onDismiss={removeToken} + onFocusEnter={() => (preventOpenOnFocusRef.current = true)} + onNavigateToInput={() => { + setTokensOverflowOpen(false); + inputRef.current?.focus(); + }} + /> + )} +
+ { + inputRef.current?.focus(); + clearValue(); + }, + } + : {})} + {...formFieldContext} + invalid={false} + warning={false} + /> +
-
+ ); const dropdown = ( From 3335359f6cf3191eabbf0d57d0438f048c4306a4 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 19:45:33 +0200 Subject: [PATCH 09/17] Add more analytics metadata --- src/control-group/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/control-group/index.tsx b/src/control-group/index.tsx index e46983d65da..e7c0c828e88 100644 --- a/src/control-group/index.tsx +++ b/src/control-group/index.tsx @@ -11,7 +11,9 @@ import InternalControlGroup from './internal'; export { ControlGroupProps }; export default function ControlGroup({ direction = 'auto', ...props }: ControlGroupProps) { - const baseComponentProps = useBaseComponent('ControlGroup', { props: { direction } }); + const baseComponentProps = useBaseComponent('ControlGroup', { + props: { direction, inlineLabelText: props.inlineLabelText, actionButton: !!props.actionButton }, + }); return ; } From ee39150327e70f33bffa4d266bcd68b505599972 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 22:54:42 +0200 Subject: [PATCH 10/17] Update test selector snapshot --- .../__snapshots__/test-utils-selectors.test.tsx.snap | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 984b0459c00..afd62836ff5 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -208,6 +208,12 @@ exports[`test-utils selectors 1`] = ` "awsui_notifications_64tge", "awsui_secondary-header_64tge", ], + "control-group": [ + "awsui_action-button_g8no8", + "awsui_control_g8no8", + "awsui_inline-label_g8no8", + "awsui_root_g8no8", + ], "copy-to-clipboard": [ "awsui_root_ljpwc", "awsui_text-to-copy_ljpwc", @@ -393,7 +399,6 @@ exports[`test-utils selectors 1`] = ` "awsui_root_2rhyz", ], "internal": [ - "awsui_action-button_1gp1v", "awsui_actions_x6dl3", "awsui_application_1fcus", "awsui_axis-label--x_f0fot", @@ -403,7 +408,6 @@ exports[`test-utils selectors 1`] = ` "awsui_button_m5h9f", "awsui_chart-filter_1px7g", "awsui_content_x6dl3", - "awsui_control_1gp1v", "awsui_control_1wepg", "awsui_custom-content_1p2cx", "awsui_description_1p2cx", @@ -422,7 +426,6 @@ exports[`test-utils selectors 1`] = ` "awsui_header_dgs8z", "awsui_highlighted_15o6u", "awsui_icon_x6dl3", - "awsui_inline-label_1gp1v", "awsui_inner-list-item_10ipo", "awsui_key_10ipo", "awsui_label-tag_1p2cx", @@ -442,7 +445,6 @@ exports[`test-utils selectors 1`] = ` "awsui_root_15oj2", "awsui_root_1b522", "awsui_root_1fcus", - "awsui_root_1gp1v", "awsui_root_1kjc7", "awsui_root_1om0h", "awsui_root_1qprf", From 89fe59f53e8fc09b0730c59acfa1d7191e6b7b0e Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 23:33:28 +0200 Subject: [PATCH 11/17] Refactor --- src/autosuggest/internal.tsx | 2 +- src/input/internal.tsx | 4 ++-- src/internal/components/autosuggest-input/index.tsx | 2 +- src/internal/components/button-trigger/index.tsx | 2 +- .../dev-warnings.ts | 2 +- .../grouped-control-styles.ts | 6 +----- src/multiselect/internal.tsx | 2 +- src/segmented-control/internal-segmented-control.tsx | 2 +- src/segmented-control/internal.tsx | 2 +- src/select/internal.tsx | 2 +- 10 files changed, 11 insertions(+), 15 deletions(-) rename src/internal/{components/control-group-styles => grouped-control}/dev-warnings.ts (97%) rename src/internal/{components/control-group-styles => grouped-control}/grouped-control-styles.ts (73%) diff --git a/src/autosuggest/internal.tsx b/src/autosuggest/internal.tsx index 2704a11fda3..c0c82e078b9 100644 --- a/src/autosuggest/internal.tsx +++ b/src/autosuggest/internal.tsx @@ -11,12 +11,12 @@ import { OptionsLoadItemsDetail } from '../dropdown/interfaces'; import { useInternalI18n } from '../i18n/context'; import { BaseChangeDetail } from '../input/interfaces'; import AutosuggestInput, { AutosuggestInputRef } from '../internal/components/autosuggest-input'; -import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; import { checkOptionValueField } from '../internal/components/listbox/utils/check-option-value-field'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; import checkControlled from '../internal/hooks/check-controlled'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { BaseKeyDetail, NonCancelableCustomEvent } from '../types/events'; diff --git a/src/input/internal.tsx b/src/input/internal.tsx index e032cd43d53..d8662a9b66e 100644 --- a/src/input/internal.tsx +++ b/src/input/internal.tsx @@ -14,11 +14,11 @@ import { useInternalI18n } from '../i18n/context'; import { IconProps } from '../icon/interfaces'; import InternalIcon from '../icon/internal'; import { getBaseProps } from '../internal/base-component'; -import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; -import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireKeyboardEvent, fireNonCancelableEvent } from '../internal/events'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; +import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useDebounceCallback } from '../internal/hooks/use-debounce-callback'; import { isDevelopment } from '../internal/is-development'; diff --git a/src/internal/components/autosuggest-input/index.tsx b/src/internal/components/autosuggest-input/index.tsx index 5a56ad505b6..c2797102547 100644 --- a/src/internal/components/autosuggest-input/index.tsx +++ b/src/internal/components/autosuggest-input/index.tsx @@ -29,6 +29,7 @@ import { getBreakpointValue } from '../../breakpoints'; import { ResetGroupedControlContext, useGroupedControlContext } from '../../context/control-group-context'; import { useFormFieldContext } from '../../context/form-field-context'; import { fireCancelableEvent, fireNonCancelableEvent } from '../../events'; +import { getGroupedControlClassNames } from '../../grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../../hooks/use-base-component'; import { useIMEComposition } from '../../hooks/use-ime-composition'; import { useListFocusController } from '../../hooks/use-list-focus-controller'; @@ -38,7 +39,6 @@ import { getInputStylesCss } from '../../utils/input-styles'; import { nodeBelongs } from '../../utils/node-belongs'; import { processAttributes } from '../../utils/with-native-attributes'; import ButtonTrigger from '../button-trigger'; -import { getGroupedControlClassNames } from '../control-group-styles/grouped-control-styles'; import { InlineTokens, InlineTokensRef } from './inline-tokens'; import { useTokenOverflow } from './use-token-overflow'; diff --git a/src/internal/components/button-trigger/index.tsx b/src/internal/components/button-trigger/index.tsx index 1dd5dab62d1..c4b9c6d3b81 100644 --- a/src/internal/components/button-trigger/index.tsx +++ b/src/internal/components/button-trigger/index.tsx @@ -12,7 +12,7 @@ import { BaseKeyDetail, CancelableEventHandler } from '../../../types/events'; import { getBaseProps } from '../../base-component'; import { GroupedControlContextProps } from '../../context/control-group-context'; import { fireCancelableEvent, fireKeyboardEvent } from '../../events'; -import { getGroupedControlClassNames } from '../control-group-styles/grouped-control-styles'; +import { getGroupedControlClassNames } from '../../grouped-control/grouped-control-styles'; import { GeneratedAnalyticsMetadataButtonTriggerCollapse, GeneratedAnalyticsMetadataButtonTriggerExpand, diff --git a/src/internal/components/control-group-styles/dev-warnings.ts b/src/internal/grouped-control/dev-warnings.ts similarity index 97% rename from src/internal/components/control-group-styles/dev-warnings.ts rename to src/internal/grouped-control/dev-warnings.ts index f2b67b14a01..b0c0b16b91a 100644 --- a/src/internal/components/control-group-styles/dev-warnings.ts +++ b/src/internal/grouped-control/dev-warnings.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; -import { isDevelopment } from '../../is-development'; +import { isDevelopment } from '../is-development'; export interface GroupedControlWarningsProps { /** diff --git a/src/internal/components/control-group-styles/grouped-control-styles.ts b/src/internal/grouped-control/grouped-control-styles.ts similarity index 73% rename from src/internal/components/control-group-styles/grouped-control-styles.ts rename to src/internal/grouped-control/grouped-control-styles.ts index 5c307605f53..d683fe09549 100644 --- a/src/internal/components/control-group-styles/grouped-control-styles.ts +++ b/src/internal/grouped-control/grouped-control-styles.ts @@ -2,11 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import clsx from 'clsx'; -import { GroupedControlContextProps } from '../../context/control-group-context'; - -// STAYS INTERNAL: shared grouped-control styling helper used by the public ControlGroup -// implementation and other grouped controls (input, segmented-control, button-trigger, -// autosuggest-input). It is not part of the ControlGroup public surface. +import { GroupedControlContextProps } from '../context/control-group-context'; /** * Builds the shared grouped-control CSS classes for a consumer. diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index 629003a992e..4f31667b5aa 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -9,11 +9,11 @@ import Dropdown from '../dropdown/internal'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { getBreakpointValue } from '../internal/breakpoints'; -import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; import DropdownFooter from '../internal/components/dropdown-footer/index.js'; import ScreenreaderOnly from '../internal/components/screenreader-only'; import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component/index.js'; import { SomeRequired } from '../internal/types'; import { getDropdownMinWidth } from '../internal/utils/get-dropdown-min-width'; diff --git a/src/segmented-control/internal-segmented-control.tsx b/src/segmented-control/internal-segmented-control.tsx index 54466b59938..c070a6843a2 100644 --- a/src/segmented-control/internal-segmented-control.tsx +++ b/src/segmented-control/internal-segmented-control.tsx @@ -3,9 +3,9 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; -import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { GroupedControlContextProps } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; +import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { KeyCode } from '../internal/keycode'; import handleKey from '../internal/utils/handle-key'; diff --git a/src/segmented-control/internal.tsx b/src/segmented-control/internal.tsx index ec1f9c99bd5..3078647e003 100644 --- a/src/segmented-control/internal.tsx +++ b/src/segmented-control/internal.tsx @@ -5,9 +5,9 @@ import clsx from 'clsx'; import InternalFormField from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; -import { getGroupedControlClassNames } from '../internal/components/control-group-styles/grouped-control-styles'; import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireNonCancelableEvent } from '../internal/events'; +import { getGroupedControlClassNames } from '../internal/grouped-control/grouped-control-styles'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import InternalSelect, { InternalSelectProps } from '../select/internal'; import { SegmentedControlProps } from './interfaces'; diff --git a/src/select/internal.tsx b/src/select/internal.tsx index b15baf41421..3146b6335c8 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -9,7 +9,6 @@ import Dropdown from '../dropdown/internal'; import { useInternalI18n } from '../i18n/context.js'; import { getBaseProps } from '../internal/base-component'; import { getBreakpointValue } from '../internal/breakpoints'; -import { warnOnUnsupportedGroupedControlProps } from '../internal/components/control-group-styles/dev-warnings'; import DropdownFooter from '../internal/components/dropdown-footer'; import { useDropdownStatus } from '../internal/components/dropdown-status'; import { useAnnouncement } from '../internal/components/listbox/use-announcement'; @@ -18,6 +17,7 @@ import { prepareOptions } from '../internal/components/option/utils/prepare-opti import { ResetGroupedControlContext, useGroupedControlContext } from '../internal/context/control-group-context'; import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireNonCancelableEvent } from '../internal/events'; +import { warnOnUnsupportedGroupedControlProps } from '../internal/grouped-control/dev-warnings'; import checkControlled from '../internal/hooks/check-controlled'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { SomeRequired } from '../internal/types'; From b792a4a8390c2e4cf8beeacb5d852588f33fbb22 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 23 Oct 2026 23:41:02 +0200 Subject: [PATCH 12/17] Use public findControlGroup test util --- .../__tests__/control-group.test.tsx | 34 ++++++++----------- src/input/__tests__/control-group.test.tsx | 12 +++---- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/control-group/__tests__/control-group.test.tsx b/src/control-group/__tests__/control-group.test.tsx index bc7bda44666..7754e90205b 100644 --- a/src/control-group/__tests__/control-group.test.tsx +++ b/src/control-group/__tests__/control-group.test.tsx @@ -8,14 +8,8 @@ import FormField from '../../../lib/components/form-field'; import { ResetGroupedControlContext } from '../../../lib/components/internal/context/control-group-context'; import Select from '../../../lib/components/select'; import createWrapper from '../../../lib/components/test-utils/dom'; -import ControlGroupWrapper from '../../../lib/components/test-utils/dom/control-group'; import { DirectionProbe, PositionProbe } from './common'; -function findControlGroup(container: HTMLElement) { - const element = container.querySelector(`.${ControlGroupWrapper.rootSelector}`); - return element && new ControlGroupWrapper(element); -} - describe('Control group', () => { test('keeps focus on a control when the children are reordered', () => { const alpha = ; @@ -23,7 +17,7 @@ describe('Control group', () => { const { container, rerender } = render({[alpha, beta]}); - const alphaInput = findControlGroup(container)!.findControls()[0].find('input')!.getElement(); + const alphaInput = createWrapper(container).findControlGroup()!.findControls()[0].find('input')!.getElement(); alphaInput.focus(); expect(document.activeElement).toBe(alphaInput); @@ -31,7 +25,7 @@ describe('Control group', () => { rerender({[beta, alpha]}); // The same DOM node is still focused; it was moved, not remounted. - expect(findControlGroup(container)!.findControls()[1].find('input')!.getElement()).toBe(alphaInput); + expect(createWrapper(container).findControlGroup()!.findControls()[1].find('input')!.getElement()).toBe(alphaInput); expect(document.activeElement).toBe(alphaInput); }); @@ -43,7 +37,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('only'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('only'); }); test('exposes first / middle / last positions to each child in order', () => { @@ -55,7 +49,7 @@ describe('Control group', () => { ); - const controls = findControlGroup(container)!.findControls(); + const controls = createWrapper(container).findControlGroup()!.findControls(); expect(controls[0].getElement()).toHaveTextContent('first'); expect(controls[1].getElement()).toHaveTextContent('middle'); expect(controls[2].getElement()).toHaveTextContent('last'); @@ -72,7 +66,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('none'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('none'); }); }); @@ -84,7 +78,7 @@ describe('Control group', () => { ); - const actionButton = findControlGroup(container)!.findActionButton(); + const actionButton = createWrapper(container).findControlGroup()!.findActionButton(); expect(actionButton).not.toBeNull(); expect(actionButton!.getElement()).toHaveAccessibleName('Remove'); }); @@ -96,7 +90,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findActionButton()).toBeNull(); + expect(createWrapper(container).findControlGroup()!.findActionButton()).toBeNull(); }); test('calls onClick when the action button is clicked', () => { @@ -107,7 +101,7 @@ describe('Control group', () => { ); - const actionButton = findControlGroup(container)!.findActionButton()!; + const actionButton = createWrapper(container).findControlGroup()!.findActionButton()!; actionButton.click(); expect(onClick).toHaveBeenCalledTimes(1); }); @@ -123,7 +117,7 @@ describe('Control group', () => { ); - const controls = findControlGroup(container)!.findControls(); + const controls = createWrapper(container).findControlGroup()!.findControls(); expect(controls[0].getElement()).toHaveTextContent('vertical'); expect(controls[1].getElement()).toHaveTextContent('vertical'); expect(controls[2].getElement()).toHaveTextContent('vertical'); @@ -138,7 +132,7 @@ describe('Control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('vertical'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('vertical'); }); }); @@ -151,7 +145,7 @@ describe('Control group', () => { ); const group = getByRole('group'); - const label = findControlGroup(container)!.findInlineLabel()!.getElement(); + const label = createWrapper(container).findControlGroup()!.findInlineLabel()!.getElement(); expect(label).toHaveTextContent('Threshold'); expect(label.id).toBeTruthy(); expect(group.getAttribute('aria-labelledby')).toBe(label.id); @@ -166,7 +160,7 @@ describe('Control group', () => { ); expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); - expect(findControlGroup(container)!.findInlineLabel()).toBeNull(); + expect(createWrapper(container).findControlGroup()!.findInlineLabel()).toBeNull(); }); describe('FormField validation association', () => { @@ -231,7 +225,7 @@ describe('Control group', () => { inlineLabelText: 'Threshold', }); const group = getByRole('group'); - const inlineLabelId = findControlGroup(container)!.findInlineLabel()!.getElement().id; + const inlineLabelId = createWrapper(container).findControlGroup()!.findInlineLabel()!.getElement().id; const labelledby = group.getAttribute('aria-labelledby')!.split(' '); expect(labelledby).toContain(inlineLabelId); @@ -305,7 +299,7 @@ describe('Control group', () => { ); - const inlineLabelId = findControlGroup(container)!.findInlineLabel()!.getElement().id; + const inlineLabelId = createWrapper(container).findControlGroup()!.findInlineLabel()!.getElement().id; const labelledby = getByRole('group').getAttribute('aria-labelledby')!.split(' '); expect(labelledby).toContain('external-label'); expect(labelledby).toContain(inlineLabelId); diff --git a/src/input/__tests__/control-group.test.tsx b/src/input/__tests__/control-group.test.tsx index cba74a97deb..3a53b8b5f06 100644 --- a/src/input/__tests__/control-group.test.tsx +++ b/src/input/__tests__/control-group.test.tsx @@ -3,18 +3,14 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { createWrapper } from '@cloudscape-design/test-utils-core/dom'; + import ControlGroup from '../../../lib/components/control-group'; import Input from '../../../lib/components/input'; -import ControlGroupWrapper from '../../../lib/components/test-utils/dom/control-group'; import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; -function findControlGroup(container: HTMLElement) { - const element = container.querySelector(`.${ControlGroupWrapper.rootSelector}`); - return element && new ControlGroupWrapper(element); -} - describe('Input in control group', () => { test('resets the context for prefix content', () => { const { container } = render( @@ -23,7 +19,7 @@ describe('Input in control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('none'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('none'); }); test('resets the context for suffix content', () => { @@ -33,6 +29,6 @@ describe('Input in control group', () => { ); - expect(findControlGroup(container)!.findControls()[0].getElement()).toHaveTextContent('none'); + expect(createWrapper(container).findControlGroup()!.findControls()[0].getElement()).toHaveTextContent('none'); }); }); From 652494c1ab110d861b8460f578d702f830e98ac1 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 26 Oct 2026 08:33:59 +0100 Subject: [PATCH 13/17] Refine API doc for contorlId --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 4 ++-- src/form-field/interfaces.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ecc8553241a..23d9e339eeb 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -15817,8 +15817,8 @@ for screen reader users.", "description": "The ID of the primary form control. You can use this to set the \`for\` attribute of a label for accessibility. -If you don't set this property, the control group automatically sets -the label to the ID of an inner form control (for example, an [input](/components/input) component). +If you don't set this property, the form field automatically generates an ID +and assigns it to the inner form control (for example, an [input](/components/input) component). This only works well if you're using a single control in the form field.", "name": "controlId", "optional": true, diff --git a/src/form-field/interfaces.ts b/src/form-field/interfaces.ts index 30e5f88b35d..7611a3eaa87 100644 --- a/src/form-field/interfaces.ts +++ b/src/form-field/interfaces.ts @@ -10,8 +10,8 @@ export interface FormFieldProps extends BaseComponentProps { * The ID of the primary form control. You can use this to set the * `for` attribute of a label for accessibility. * - * If you don't set this property, the control group automatically sets - * the label to the ID of an inner form control (for example, an [input](/components/input) component). + * If you don't set this property, the form field automatically generates an ID + * and assigns it to the inner form control (for example, an [input](/components/input) component). * This only works well if you're using a single control in the form field. */ controlId?: string; From f54e4b155e502f19769489f74e69078997867565 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 26 Oct 2026 08:35:14 +0100 Subject: [PATCH 14/17] Fix import --- src/input/__tests__/control-group.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/input/__tests__/control-group.test.tsx b/src/input/__tests__/control-group.test.tsx index 3a53b8b5f06..d0e7d6e7fea 100644 --- a/src/input/__tests__/control-group.test.tsx +++ b/src/input/__tests__/control-group.test.tsx @@ -3,10 +3,9 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { createWrapper } from '@cloudscape-design/test-utils-core/dom'; - import ControlGroup from '../../../lib/components/control-group'; import Input from '../../../lib/components/input'; +import createWrapper from '../../../lib/components/test-utils/dom'; import { PositionProbe } from '../../control-group/__tests__/common'; const noop = () => {}; From 3445ca3316b8114803e96db1b1fe598f15488a9f Mon Sep 17 00:00:00 2001 From: Joan Perals Date: Mon, 26 Oct 2026 09:56:56 +0100 Subject: [PATCH 15/17] Refactor ControlGroup component metadata handling Change ControlGroup to include metadata for the presence of inline label, instead of its content. Refactor handling of action button metadata to match. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/control-group/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/control-group/index.tsx b/src/control-group/index.tsx index e7c0c828e88..1bc304f138b 100644 --- a/src/control-group/index.tsx +++ b/src/control-group/index.tsx @@ -12,7 +12,11 @@ export { ControlGroupProps }; export default function ControlGroup({ direction = 'auto', ...props }: ControlGroupProps) { const baseComponentProps = useBaseComponent('ControlGroup', { - props: { direction, inlineLabelText: props.inlineLabelText, actionButton: !!props.actionButton }, + props: { direction }, + metadata: { + hasInlineLabel: Boolean(props.inlineLabelText), + hasActionButton: Boolean(props.actionButton), + }, }); return ; } From f46c856b7fb161f152650d3bcf1751ffb0552691 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 26 Oct 2026 10:00:37 +0100 Subject: [PATCH 16/17] Refine dev warning --- src/control-group/__tests__/dev-warnings.test.tsx | 4 ++-- src/internal/grouped-control/dev-warnings.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/control-group/__tests__/dev-warnings.test.tsx b/src/control-group/__tests__/dev-warnings.test.tsx index 0d22c105e7d..1c358011266 100644 --- a/src/control-group/__tests__/dev-warnings.test.tsx +++ b/src/control-group/__tests__/dev-warnings.test.tsx @@ -34,7 +34,7 @@ describe('Grouped control dev warnings', () => { render({control}); expect(warnOnce).toHaveBeenCalledWith( componentName, - 'The `invalid` prop is ignored on controls inside a control group. Set the validation state on the control group instead.' + 'The `invalid` prop is ignored on controls inside a control group. Set the validation state on the control group instead by wrapping it in a form field.' ); }); @@ -57,7 +57,7 @@ describe('Grouped control dev warnings', () => { render({control}); expect(warnOnce).toHaveBeenCalledWith( componentName, - 'The `warning` prop is ignored on controls inside a control group. Set the validation state on the control group instead.' + 'The `warning` prop is ignored on controls inside a control group. Set the validation state on the control group instead by wrapping it in a form field.' ); }); diff --git a/src/internal/grouped-control/dev-warnings.ts b/src/internal/grouped-control/dev-warnings.ts index b0c0b16b91a..612ee712fa9 100644 --- a/src/internal/grouped-control/dev-warnings.ts +++ b/src/internal/grouped-control/dev-warnings.ts @@ -58,14 +58,14 @@ export function warnOnUnsupportedGroupedControlProps({ if (invalid) { warnOnce( componentName, - 'The `invalid` prop is ignored on controls inside a control group. Set the validation state on the control group instead.' + 'The `invalid` prop is ignored on controls inside a control group. Set the validation state on the control group instead by wrapping it in a form field.' ); } if (warning) { warnOnce( componentName, - 'The `warning` prop is ignored on controls inside a control group. Set the validation state on the control group instead.' + 'The `warning` prop is ignored on controls inside a control group. Set the validation state on the control group instead by wrapping it in a form field.' ); } From 2ebf7d9abb250647ee8f7d0ddf82df075cdfd847 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Mon, 26 Oct 2026 16:32:04 +0100 Subject: [PATCH 17/17] Refine API doc comment --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 4 ++-- src/select/interfaces.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 23d9e339eeb..faa0a22efec 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -20540,7 +20540,7 @@ for example inside table and split view layouts. We recommend you use discretion, and don't enable this property unless necessary because fixed positioning results in a slight, visible lag when scrolling complex pages. -When the component is rendered in a control group, the dropdown will be rendered using this portal-based method regardless of this property.", +When the component is rendered in a control group, the dropdown will be rendered expanded to the viewport regardless of this property.", "name": "expandToViewport", "optional": true, "type": "boolean", @@ -26989,7 +26989,7 @@ for example inside table and split view layouts. We recommend you use discretion, and don't enable this property unless necessary because fixed positioning results in a slight, visible lag when scrolling complex pages. -When the component is rendered in a control group, the dropdown will be rendered using this portal-based method regardless of this property.", +When the component is rendered in a control group, the dropdown will be rendered expanded to the viewport regardless of this property.", "name": "expandToViewport", "optional": true, "type": "boolean", diff --git a/src/select/interfaces.ts b/src/select/interfaces.ts index cdd347724dc..1ebf78f5348 100644 --- a/src/select/interfaces.ts +++ b/src/select/interfaces.ts @@ -190,7 +190,7 @@ export interface BaseSelectProps * We recommend you use discretion, and don't enable this property unless necessary * because fixed positioning results in a slight, visible lag when scrolling complex pages. * - * When the component is rendered in a control group, the dropdown will be rendered using this portal-based method regardless of this property. + * When the component is rendered in a control group, the dropdown will be rendered expanded to the viewport regardless of this property. */ expandToViewport?: boolean; }