From 70fa8064b3a279c5750214294ce598177f0a9b0a Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 17:23:08 +0200 Subject: [PATCH 1/8] feat: Add action slot to internal ControlGroup Add an optional `action?: React.ReactNode` slot to InternalControlGroup. When provided, the action renders as a sibling of the fused control row via an outer .layout wrapper, so in vertical direction it stays on the side rather than stacking with the controls. The action is a plain slot: it is not passed through flattenChildren, not wrapped in GroupedControlContext, and not counted toward control positions. When action is absent, the controls' markup is unchanged (role=group div remains the root). Adds .layout / .action-slot SCSS (space-xs gap, flex-end alignment), dev permutations exercising an icon Button action in both directions, and unit tests covering action rendering, absence, and position-count isolation. Verification blocked: this workspace has no node_modules or built lib/ artifacts, and the running watch job targets a sibling workspace, so jest (which imports from lib/components) could not be run here. --- pages/control-group/permutations.page.tsx | 18 +++++++ .../__tests__/control-group.test.tsx | 50 +++++++++++++++++++ .../components/control-group/index.tsx | 25 +++++++++- .../components/control-group/styles.scss | 19 +++++++ 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/pages/control-group/permutations.page.tsx b/pages/control-group/permutations.page.tsx index 435e78be2f..02d6790ca6 100644 --- a/pages/control-group/permutations.page.tsx +++ b/pages/control-group/permutations.page.tsx @@ -3,6 +3,7 @@ import React from 'react'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import Button from '~components/button'; import FormField from '~components/form-field'; import Input from '~components/input'; import ControlGroup, { InternalControlGroupProps } from '~components/internal/components/control-group'; @@ -41,6 +42,8 @@ const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { v const enteredTextLabel = (value: string) => `Use: ${value}`; +const removeAction = }> + + + ); + + const actionSlot = container.querySelector(`.${styles['action-slot']}`); + expect(actionSlot).not.toBeNull(); + expect(actionSlot).toContainElement(getByTestId('action')); + }); + + test('renders no action-slot element when action is absent', () => { + const { container } = render( + + + + ); + + expect(container.querySelector(`.${styles['action-slot']}`)).toBeNull(); + }); + + test('does not count the action as a control for a single-control group', () => { + const { getByTestId } = render( + Remove}> + + + ); + + expect(getByTestId('probe')).toHaveTextContent('only'); + }); + + test('does not count the action as a control when positions are assigned', () => { + const { getByTestId } = render( + Remove}> + + + + + ); + + expect(getByTestId('a')).toHaveTextContent('first'); + expect(getByTestId('b')).toHaveTextContent('middle'); + expect(getByTestId('c')).toHaveTextContent('last'); + }); + }); + describe('direction', () => { test('defaults the direction to "horizontal" and exposes it to each child', () => { const { getByTestId } = render( diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 1ee3ed8122..8c864d6f42 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -17,11 +17,17 @@ import styles from './styles.css.js'; export interface InternalControlGroupProps extends BaseComponentProps { children?: React.ReactNode; direction?: GroupedControlDirection; + /** + * Slot to render a button next to the grouped controls. The action is a sibling + * of the fused controls and stays on the side rather than stacking with them. + */ + action?: React.ReactNode; } export default function InternalControlGroup({ children, direction = 'horizontal', + action, ...props }: InternalControlGroupProps) { const baseProps = getBaseProps(props); @@ -29,8 +35,12 @@ export default function InternalControlGroup({ const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; - return ( -
+ const controls = ( +
{flattenedChildren.map((child, index) => { const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; const position: GroupedControlPosition = @@ -46,4 +56,15 @@ export default function InternalControlGroup({ })}
); + + if (!action) { + return controls; + } + + return ( +
+ {controls} +
{action}
+
+ ); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index 54c7f93695..9e7d6f982d 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -26,6 +26,25 @@ } } +.layout { + display: flex; + flex-direction: row; + // Line the action's bottom edge up with the controls' field boxes, matching + // the horizontal root's bottom-edge alignment. + align-items: flex-end; + column-gap: awsui.$space-xs; + + &-horizontal, + &-vertical { + // Both directions lay the controls group and the action side-by-side on the + // inline axis, so in vertical direction the action sits beside the column. + } +} + +.action-slot { + // Plain slot: no seam overlap or isolation — the action is not a fused control. +} + .control { // Let slots shrink below their content so a narrow group compresses, not overflows. min-inline-size: 0; From fa704d386643ccbfd8785df0f3cefce3ac6b552c Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 17:49:52 +0200 Subject: [PATCH 2/8] fix: Emit action-slot and layout CSS-module classes The .action-slot, .layout-horizontal, and .layout-vertical rulesets held only Sass '//' line comments. Sass strips those comments and omits the resulting empty selectors, so no class keys were generated in the CSS module and styles['action-slot'] evaluated to undefined, making the action rendering test build the selector '.undefined'. Use retained CSS block comments ('/* */') as the repository's selector-only pattern (see src/flashbar/styles.scss) so the selectors survive generation. Confirmed the keys now appear in the generated styles.css.js. Verification: - quick-build regenerated lib/ (one-shot, no watcher). - TZ=UTC jest -c jest.unit.config.js src/internal/components/control-group -> 11/11 pass, including the 4 action tests. - tsc --noEmit -p tsconfig.json -> clean (no type errors). --- src/internal/components/control-group/styles.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index 9e7d6f982d..fae610fd00 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -36,13 +36,15 @@ &-horizontal, &-vertical { - // Both directions lay the controls group and the action side-by-side on the - // inline axis, so in vertical direction the action sits beside the column. + /* Both directions lay the controls group and the action side-by-side on the + inline axis, so in vertical direction the action sits beside the column. + Retained block comment keeps these selectors in the generated CSS module. */ } } .action-slot { - // Plain slot: no seam overlap or isolation — the action is not a fused control. + /* Plain slot: no seam overlap or isolation — the action is not a fused control. + Retained block comment keeps this selector in the generated CSS module. */ } .control { From 583d8456d7bc003e71701406eeb1d1b446ca95bb Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 18:33:30 +0200 Subject: [PATCH 3/8] feat: Fuse ControlGroup action button onto controls Style the action slot so a button fuses with the grouped controls as if it were the last control, in both directions, with per-variant borders: - Icon (borderless) variant: slot supplies the shared gray control border; normal/primary keep their own border, radius + seam only. - Flat inline-start corners, rounded inline-end corners, seam overlap reusing the control negative-margin pattern. Column-gap removed. - Vertical: action spans the stacked column's full height on the inline-end; the column's inline-end corners are squared where it meets the action (scoped to the vertical+action case only). Variant is read defensively from the action element at the ControlGroup level. No control component is touched; the action stays ReactNode-only, is not wrapped in GroupedControlContext, and is not counted as a control. The no-action render path is unchanged. Moves the action permutations to a dedicated action.page.tsx covering icon/normal/primary across both directions; permutations.page.tsx is restored to origin/main. Adds unit tests for the icon vs non-icon border branch. --- pages/control-group/action.page.tsx | 117 ++++++++++++++++++ pages/control-group/permutations.page.tsx | 18 --- .../__tests__/control-group.test.tsx | 27 ++++ .../components/control-group/index.tsx | 19 ++- .../components/control-group/styles.scss | 94 ++++++++++++-- 5 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 pages/control-group/action.page.tsx diff --git a/pages/control-group/action.page.tsx b/pages/control-group/action.page.tsx new file mode 100644 index 0000000000..bf9430af20 --- /dev/null +++ b/pages/control-group/action.page.tsx @@ -0,0 +1,117 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import Button from '~components/button'; +import FormField from '~components/form-field'; +import Input from '~components/input'; +import ControlGroup, { InternalControlGroupProps } from '~components/internal/components/control-group'; +import Multiselect, { MultiselectProps } from '~components/multiselect'; +import RadioGroup from '~components/radio-group'; +import Select, { SelectProps } from '~components/select'; + +import { useAppContext } from '../app/app-context'; +import { PermutationsPage } from '../app/templates'; +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; + +type Direction = NonNullable; + +const noop = () => { + /* empty handler to suppress controlled-component warnings */ +}; + +const operators: SelectProps.Option[] = [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, +]; + +const multiOptions: MultiselectProps.Option[] = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, +]; + +const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { value: 'MemoryUtilization' }]; + +const enteredTextLabel = (value: string) => `Use: ${value}`; + +// Three border cases: icon (borderless, slot supplies the gray control border), +// normal and primary (keep their own border). +const removeAction = ; +const primaryAction = ; + +const input = ; +const select = ; const select = + + ); + + const actionSlot = container.querySelector(`.${styles['action-slot']}`); + expect(actionSlot).not.toBeNull(); + // Borderless icon buttons get the slot's added gray control border. + expect(actionSlot!.classList.contains(styles['action-slot-bordered'])).toBe(true); + }); + + test('does not add the shared control border to the action slot for a normal-variant button', () => { + const { container } = render( + Apply}> + + + ); + + const actionSlot = container.querySelector(`.${styles['action-slot']}`); + expect(actionSlot).not.toBeNull(); + // Normal/primary buttons keep their own border, so the slot omits the added border. + expect(actionSlot!.classList.contains(styles['action-slot-bordered'])).toBe(false); + }); }); describe('direction', () => { diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 8c864d6f42..58c15ed5be 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -35,11 +35,26 @@ export default function InternalControlGroup({ const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; + // Inspect the passed element's `variant` prop so the slot can supply the shared + // gray control border for the borderless icon variant. The read is defensive: + // a non-element or a missing variant yields `false` (treated as bordered, which + // matches Button's default `normal` variant). The action is not required to be a + // Button, and this never wraps the action in grouped-control context. + const actionVariant = React.isValidElement(action) ? (action.props as { variant?: string }).variant : undefined; + const isIconAction = actionVariant === 'icon'; + const controls = (
{flattenedChildren.map((child, index) => { const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; @@ -64,7 +79,7 @@ export default function InternalControlGroup({ return (
{controls} -
{action}
+
{action}
); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index fae610fd00..ac2428b206 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -29,22 +29,94 @@ .layout { display: flex; flex-direction: row; - // Line the action's bottom edge up with the controls' field boxes, matching - // the horizontal root's bottom-edge alignment. + // The controls group and the action always sit side-by-side on the inline axis, + // so in vertical direction the action sits beside the stacked column. + // No column-gap: the action fuses onto the controls as if it were the last + // control, with its inline-start border forming a single seam with the row/column. +} + +.action-slot { + // The action fuses onto the controls like the last control: flat corners on the + // inline-start (where it seams onto the controls) and the control corner radius on + // the inline-end (the outer edge of the fused strip). The slot's radius is pulled + // from the forms constants so it matches the controls' grouped-control radius, not + // the button's own radius token. + border-start-start-radius: 0; + border-end-start-radius: 0; + border-start-end-radius: styles.$control-border-radius; + border-end-end-radius: styles.$control-border-radius; + // Size to the button and let the border/radius wrap the visible button. Not + // flex: 1 — the slot must size to the action, not grow to fill the row. + display: flex; + // Establish a stacking context and raise the slot on focus so the button's focus + // ring paints over the seam border, mirroring the `.control` treatment below. + isolation: isolate; + &:focus-within { + z-index: 1; + } +} + +.action-slot-bordered { + // Icon-variant (borderless) actions get the SAME gray control border as the other + // controls, using the same token pair the controls draw their field border with + // (see %input-default-styles), so the fused strip is uniform. Combined with the + // inline-start seam margin below, the slot's inline-start border coincides with the + // last control's inline-end border as a single seam line. Normal/primary variants + // keep their own border and omit this class. + border-block: awsui.$border-width-field solid awsui.$color-border-input-default; + border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; +} + +.layout-horizontal { + // Line the action's bottom edge up with the controls' field boxes, matching the + // horizontal root's bottom-edge alignment. align-items: flex-end; - column-gap: awsui.$space-xs; - &-horizontal, - &-vertical { - /* Both directions lay the controls group and the action side-by-side on the - inline axis, so in vertical direction the action sits beside the column. - Retained block comment keeps these selectors in the generated CSS module. */ + // Overlap the action onto the control row by one border width so the action's + // inline-start border coincides with the last control's inline-end border as a + // single seam, reusing the control seam-overlap pattern. Scoped under the layout + // with a child combinator (the repo forbids the descendant space combinator). + > .action-slot { + margin-inline-start: calc(-1 * #{awsui.$border-width-field}); } } -.action-slot { - /* Plain slot: no seam overlap or isolation — the action is not a fused control. - Retained block comment keeps this selector in the generated CSS module. */ +.layout-vertical { + // The action spans the full block height of the stacked column, so stretch it to + // match the column's height rather than aligning to a single edge. + align-items: stretch; + + // Seam the action onto the column's inline-end edge (same overlap pattern) and let + // the inner button fill the slot vertically so the border/radius spans the full + // column height. The base rule already gives flat inline-start / rounded inline-end + // corners, which is correct for the full-height action. + > .action-slot { + margin-inline-start: calc(-1 * #{awsui.$border-width-field}); + align-items: stretch; + } +} + +// Square the stacked column's inline-end corners where it meets the full-height +// action, so the column's trailing edge reads flat. The grouped corner radius is +// painted on each control's OWN element (CSS-module-hashed per package, at a +// control-type-dependent depth), unreachable by class name from here, so it is +// overridden through the slot's single rendered child via `> :first-child` (the same +// child + pseudo-class pattern used elsewhere, e.g. breadcrumb-group / side-navigation, +// which the repo allows where the universal `*` and descendant space combinators are +// forbidden). Scoped to the vertical slot classes and emitted only when an action is +// present (root-has-end-action is added only in the action branch), so a group +// without an action is completely unaffected. +.root-has-end-action { + > .control-first-vertical > :first-child { + border-start-end-radius: 0; + } + > .control-last-vertical > :first-child { + border-end-end-radius: 0; + } + > .control-only-vertical > :first-child { + border-start-end-radius: 0; + border-end-end-radius: 0; + } } .control { From 1efeb9f0c960b22705350c1853d395ba3ad69024 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 18:57:51 +0200 Subject: [PATCH 4/8] fix: Fuse action button via grouped-control context --- src/button/internal.tsx | 32 ++++++--- src/button/styles.scss | 21 ++++++ .../control-group/__tests__/common.tsx | 5 ++ .../__tests__/control-group.test.tsx | 68 ++++++++++++++++--- .../control-group/grouped-control-styles.ts | 10 ++- .../components/control-group/index.tsx | 43 +++++++----- .../components/control-group/styles.scss | 56 ++++----------- .../context/control-group-context.tsx | 11 +++ src/internal/styles/forms/mixins.scss | 28 ++++++++ 9 files changed, 190 insertions(+), 84 deletions(-) diff --git a/src/button/internal.tsx b/src/button/internal.tsx index 79c098b8a6..813d869869 100644 --- a/src/button/internal.tsx +++ b/src/button/internal.tsx @@ -21,7 +21,9 @@ import { getSubStepAllSelector, getTextFromSelector, } from '../internal/analytics/selectors'; +import { getGroupedControlClassNames } from '../internal/components/control-group/grouped-control-styles'; import { useButtonContext } from '../internal/context/button-context'; +import { useGroupedControlContext } from '../internal/context/control-group-context'; import { fireCancelableEvent, isPlainLeftClick } from '../internal/events'; import useForwardFocus from '../internal/hooks/forward-focus'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -145,6 +147,11 @@ export const InternalButton = React.forwardRef( useForwardFocus(ref, buttonRef); const buttonContext = useButtonContext(); + // Read the grouped-control context so a button used as a control group `action` + // fuses onto the controls like the other grouped controls (Input, Select, …): + // the shared corner mixin flattens the facing corners on the button's own root, + // where its border + radius are actually painted. + const groupedControlProps = useGroupedControlContext(); const i18n = useInternalI18n('button'); const uniqueId = useUniqueId('button'); @@ -199,15 +206,22 @@ export const InternalButton = React.forwardRef( }; const stylingClassName = styleClassNames?.root; - const buttonClass = clsx(props.className, stylingClassName, styles.button, styles[`variant-${variant}`], { - [styles.disabled]: isNotInteractive, - [styles['disabled-with-reason']]: isDisabledWithReason, - [styles['button-no-wrap']]: !wrapText, - [styles['button-no-text']]: !shouldHaveContent, - [styles['full-width']]: shouldHaveContent && fullWidth, - [styles['button-compact']]: __compact, - [styles.link]: isAnchor, - }); + const buttonClass = clsx( + props.className, + stylingClassName, + styles.button, + styles[`variant-${variant}`], + ...getGroupedControlClassNames(styles, groupedControlProps), + { + [styles.disabled]: isNotInteractive, + [styles['disabled-with-reason']]: isDisabledWithReason, + [styles['button-no-wrap']]: !wrapText, + [styles['button-no-text']]: !shouldHaveContent, + [styles['full-width']]: shouldHaveContent && fullWidth, + [styles['button-compact']]: __compact, + [styles.link]: isAnchor, + } + ); const explicitTabIndex = nativeButtonAttributes?.tabIndex ?? nativeAnchorAttributes?.tabIndex; const { tabIndex } = useSingleTabStopNavigation(buttonRef, { diff --git a/src/button/styles.scss b/src/button/styles.scss index 318254767e..4ad0f0aee3 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -123,6 +123,27 @@ $tokens: style-api.resolve($props, carrier); } } + // Fuse the button onto a surrounding control group's controls when it is used as the + // group `action`. The button reads the grouped-control context like the other grouped + // controls (Input, Select, …) and applies the shared corner mixin to its OWN root, + // where the border and radius are painted — so the facing corners flatten correctly + // regardless of variant. + &.grouped { + @include styles.grouped-control; + + // The seam overlap (negative inline-start margin) lives on the control-group + // `.action-slot`, mirroring how the other controls get their seam from the + // control-group slot rather than from the control itself. + + // The icon variant is visually borderless (transparent border). As a fused control + // it needs the SAME gray control border the other controls draw, so the fused strip + // reads as one continuous outline. Normal/primary keep their own border. + &.variant-icon { + border-block: awsui.$border-width-field solid awsui.$color-border-input-default; + border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; + } + } + &:focus { outline: none; text-decoration: none; diff --git a/src/internal/components/control-group/__tests__/common.tsx b/src/internal/components/control-group/__tests__/common.tsx index 2dad08dcc6..ec8a892fe1 100644 --- a/src/internal/components/control-group/__tests__/common.tsx +++ b/src/internal/components/control-group/__tests__/common.tsx @@ -13,3 +13,8 @@ export function DirectionProbe({ testId = 'direction-probe' }: { testId?: string const { direction } = useGroupedControlContext(); return {direction}; } + +export function EndActionProbe({ testId = 'end-action-probe' }: { testId?: string }) { + const { endAction } = useGroupedControlContext(); + return {endAction ? 'yes' : 'no'}; +} diff --git a/src/internal/components/control-group/__tests__/control-group.test.tsx b/src/internal/components/control-group/__tests__/control-group.test.tsx index 3621a6b906..9f2f86006f 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -6,8 +6,9 @@ import { render } from '@testing-library/react'; import Button from '../../../../../lib/components/button'; import ControlGroup from '../../../../../lib/components/internal/components/control-group'; import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; -import { DirectionProbe, PositionProbe } from './common'; +import { DirectionProbe, EndActionProbe, PositionProbe } from './common'; +import buttonStyles from '../../../../../lib/components/button/styles.css.js'; import styles from '../../../../../lib/components/internal/components/control-group/styles.css.js'; describe('Control group', () => { @@ -116,30 +117,75 @@ describe('Control group', () => { expect(getByTestId('c')).toHaveTextContent('last'); }); - test('adds the shared control border to the action slot for an icon-variant button', () => { + test('renders the action button as a grouped-last control so it fuses onto the controls', () => { + const { container } = render( + Apply}> + + + ); + + // The button reads the grouped-control context and applies the shared grouped + // classes to its own root, where its border + radius are painted. The action is + // always `last` in the horizontal direction (it is the trailing fused element, + // beside the stacked column in vertical direction). + const button = container.querySelector(`.${buttonStyles.button}`); + expect(button).not.toBeNull(); + expect(button!.classList.contains(buttonStyles.grouped)).toBe(true); + expect(button!.classList.contains(buttonStyles['grouped-horizontal-last'])).toBe(true); + }); + + test('an icon-variant action button fuses as a grouped-last control', () => { const { container } = render( }> ); - const actionSlot = container.querySelector(`.${styles['action-slot']}`); - expect(actionSlot).not.toBeNull(); - // Borderless icon buttons get the slot's added gray control border. - expect(actionSlot!.classList.contains(styles['action-slot-bordered'])).toBe(true); + const button = container.querySelector(`.${buttonStyles.button}`); + expect(button).not.toBeNull(); + // The borderless icon variant gets the shared gray control border via its own + // grouped + variant-icon styling (asserted here by the grouped class membership). + expect(button!.classList.contains(buttonStyles.grouped)).toBe(true); + expect(button!.classList.contains(buttonStyles['variant-icon'])).toBe(true); + }); + + test('signals endAction to the controls so they square the corners facing the action', () => { + const { getByTestId } = render( + Remove}> + + + + ); + + expect(getByTestId('a')).toHaveTextContent('yes'); + expect(getByTestId('b')).toHaveTextContent('yes'); }); - test('does not add the shared control border to the action slot for a normal-variant button', () => { + test('does not signal endAction to the controls when no action is present', () => { + const { getByTestId } = render( + + + + + ); + + expect(getByTestId('a')).toHaveTextContent('no'); + expect(getByTestId('b')).toHaveTextContent('no'); + }); + + test('a normal-variant action button keeps its own border and fuses as grouped-last', () => { const { container } = render( Apply}> ); - const actionSlot = container.querySelector(`.${styles['action-slot']}`); - expect(actionSlot).not.toBeNull(); - // Normal/primary buttons keep their own border, so the slot omits the added border. - expect(actionSlot!.classList.contains(styles['action-slot-bordered'])).toBe(false); + const button = container.querySelector(`.${buttonStyles.button}`); + expect(button).not.toBeNull(); + // Normal/primary buttons keep their own border; they are still grouped so the + // shared corner mixin flattens their inline-start corners against the seam. + expect(button!.classList.contains(buttonStyles.grouped)).toBe(true); + expect(button!.classList.contains(buttonStyles['variant-normal'])).toBe(true); }); }); diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group/grouped-control-styles.ts index aed1187a07..0de6cb5bb4 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group/grouped-control-styles.ts @@ -11,10 +11,16 @@ import { GroupedControlContextProps } from '../../context/control-group-context' */ export function getGroupedControlClassNames( styles: Record, - { position, direction }: GroupedControlContextProps + { position, direction, endAction }: GroupedControlContextProps ): string[] { if (!position) { return []; } - return [styles.grouped, styles[`grouped-${direction}-${position}`]]; + const classNames = [styles.grouped, styles[`grouped-${direction}-${position}`]]; + // When an action is fused onto the group's inline-end, square the control's corners + // that face the action (see the grouped-control `-end-action` rules in the mixin). + if (endAction) { + classNames.push(styles[`grouped-end-action-${direction}-${position}`]); + } + return classNames; } diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 58c15ed5be..2ad1aaaffa 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -35,26 +35,11 @@ export default function InternalControlGroup({ const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; - // Inspect the passed element's `variant` prop so the slot can supply the shared - // gray control border for the borderless icon variant. The read is defensive: - // a non-element or a missing variant yields `false` (treated as bordered, which - // matches Button's default `normal` variant). The action is not required to be a - // Button, and this never wraps the action in grouped-control context. - const actionVariant = React.isValidElement(action) ? (action.props as { variant?: string }).variant : undefined; - const isIconAction = actionVariant === 'icon'; - const controls = (
{flattenedChildren.map((child, index) => { const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; @@ -65,7 +50,14 @@ export default function InternalControlGroup({ key={key ? String(key) : index} className={clsx(styles.control, styles[`control-${position}-${direction}`])} > - {child} + {/* + `position` is unchanged whether or not an action is present, so the + action is never counted as a control. `endAction` only tells the + control which facing corners to square so it fuses with the action. + */} + + {child} +
); })} @@ -79,7 +71,22 @@ export default function InternalControlGroup({ return (
{controls} -
{action}
+ {/* + The action fuses onto the controls as the trailing element. It reads the + grouped-control context like any other control, so it owns its own border and + corner flattening (see button/styles.scss `.grouped`). The direction is always + `horizontal` for the action: in a vertical group the action sits BESIDE the + stacked column spanning its full height, so relative to the column it is the + trailing element of a horizontal pairing (flat inline-start corners, rounded + inline-end corners top and bottom). `endAction` is omitted (false): the action + rounds its inline-end rather than squaring it. It is NOT counted as a control + and NOT a member of the role="group". + */} +
+ + {action} + +
); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index ac2428b206..5f2e8109aa 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -36,17 +36,11 @@ } .action-slot { - // The action fuses onto the controls like the last control: flat corners on the - // inline-start (where it seams onto the controls) and the control corner radius on - // the inline-end (the outer edge of the fused strip). The slot's radius is pulled - // from the forms constants so it matches the controls' grouped-control radius, not - // the button's own radius token. - border-start-start-radius: 0; - border-end-start-radius: 0; - border-start-end-radius: styles.$control-border-radius; - border-end-end-radius: styles.$control-border-radius; - // Size to the button and let the border/radius wrap the visible button. Not - // flex: 1 — the slot must size to the action, not grow to fill the row. + // The action's own root (the Button) owns the fused border and corner radius: it + // reads the grouped-control context and applies the shared corner mixin on itself + // (see button/styles.scss `.grouped`). The slot is just a layout wrapper that lets + // the button fill it so it spans the controls' height (and the full column height in + // vertical direction). Not flex: 1 — the slot sizes to the action, not the row. display: flex; // Establish a stacking context and raise the slot on focus so the button's focus // ring paints over the seam border, mirroring the `.control` treatment below. @@ -56,17 +50,6 @@ } } -.action-slot-bordered { - // Icon-variant (borderless) actions get the SAME gray control border as the other - // controls, using the same token pair the controls draw their field border with - // (see %input-default-styles), so the fused strip is uniform. Combined with the - // inline-start seam margin below, the slot's inline-start border coincides with the - // last control's inline-end border as a single seam line. Normal/primary variants - // keep their own border and omit this class. - border-block: awsui.$border-width-field solid awsui.$color-border-input-default; - border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; -} - .layout-horizontal { // Line the action's bottom edge up with the controls' field boxes, matching the // horizontal root's bottom-edge alignment. @@ -96,28 +79,13 @@ } } -// Square the stacked column's inline-end corners where it meets the full-height -// action, so the column's trailing edge reads flat. The grouped corner radius is -// painted on each control's OWN element (CSS-module-hashed per package, at a -// control-type-dependent depth), unreachable by class name from here, so it is -// overridden through the slot's single rendered child via `> :first-child` (the same -// child + pseudo-class pattern used elsewhere, e.g. breadcrumb-group / side-navigation, -// which the repo allows where the universal `*` and descendant space combinators are -// forbidden). Scoped to the vertical slot classes and emitted only when an action is -// present (root-has-end-action is added only in the action branch), so a group -// without an action is completely unaffected. -.root-has-end-action { - > .control-first-vertical > :first-child { - border-start-end-radius: 0; - } - > .control-last-vertical > :first-child { - border-end-end-radius: 0; - } - > .control-only-vertical > :first-child { - border-start-end-radius: 0; - border-end-end-radius: 0; - } -} +// The stacked column's inline-end corners (and the horizontal strip's trailing +// control corners) are squared where they meet the action by the shared +// grouped-control mixin via the `endAction` context flag, NOT from here — the mixin +// runs on each control's own painted element, so it reaches the visible corner for +// every control type (Input, Select, Multiselect, Autosuggest, SegmentedControl, +// Button-trigger) without this component targeting control-internal DOM. See +// src/internal/styles/forms/mixins.scss (`grouped-control` -> `-end-action`). .control { // Let slots shrink below their content so a narrow group compresses, not overflows. diff --git a/src/internal/context/control-group-context.tsx b/src/internal/context/control-group-context.tsx index f06b78421f..ac1855c576 100644 --- a/src/internal/context/control-group-context.tsx +++ b/src/internal/context/control-group-context.tsx @@ -17,11 +17,22 @@ export interface GroupedControlContextProps { * controls. */ direction: GroupedControlDirection; + /** + * Whether an action is fused onto the group's inline-end (spanning the full + * block height in vertical direction). Controls read this to square the + * inline-end corners that face the action so the column's trailing edge reads + * flat and the action owns the only rounded inline-end corners. + * + * This does NOT change `position`, so the action is still not counted as a + * control; it only tells a control which facing corners to square. + */ + endAction?: boolean; } export const GroupedControlContext = createContext({ position: null, direction: 'horizontal', + endAction: false, }); export function useGroupedControlContext() { diff --git a/src/internal/styles/forms/mixins.scss b/src/internal/styles/forms/mixins.scss index 5bb4af20f0..ff7e441653 100644 --- a/src/internal/styles/forms/mixins.scss +++ b/src/internal/styles/forms/mixins.scss @@ -402,4 +402,32 @@ $inline-label-border-radius: 2px; border-start-end-radius: 0; } } + + // When an action is fused onto the group's inline-end, the controls' corners that + // face the action become interior to the fused strip and must be squared so the + // action owns the only rounded inline-end corners. These rules run on the SAME + // element the base grouped-control radii paint on (each control applies this mixin + // to its own painted element), so they reach the visible corner for every control + // type without any component reaching into another's DOM. + &-end-action { + // Horizontal: only the trailing control (last, or the single `only`) sits against + // the action; first/middle already have their inline-end corners squared. + &-horizontal { + &-last, + &-only { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + // Vertical: the action spans the full block height on the inline-end, so every + // control's inline-end corners face it. (middle controls are already fully squared.) + &-vertical { + &-first, + &-last, + &-only { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + } + } } From 9c9d0e84fbed07c51d7e0b252cf7049bba4ba47b Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 20:16:45 +0200 Subject: [PATCH 5/8] fix: Align and pad fused ControlGroup action button Polish the three remaining defects on the fused (grouped) action Button; all changes are scoped to `.button.grouped` so standalone buttons are untouched. Only src/button/styles.scss changed (CSS expressed all three, so internal.tsx was not needed). 1. Icon padding: `.button.grouped.variant-icon` uses `$space-field-horizontal` (the controls' horizontal field padding) instead of the stock icon-only `$space-xxs`, so the fused icon cell is not cramped. Three-class specificity beats `.button.variant-icon`. 2. Top border flush / height: `.button.grouped` gets `min-block-size: $size-vertical-input`. The controls floor their height with the same token under box-sizing: border-box (field height == 32px in the default theme). The Button reset is also border-box but had no floor, so with the smaller button vertical padding its box was shorter; under the row's `align-items: flex-end` the shortfall showed as a gap at the TOP. Flooring to $size-vertical-input makes action box height == field height == 32px, flush in both directions. The icon border recolor swaps $border-width-button (1px) -> $border-width-field (1px); both are inside the border-box and equal, so it adds no net height. 3. Text no-wrap inline: `.button.grouped` sets `white-space: nowrap`, overriding the base `.button` `white-space: normal`; non-grouped buttons still wrap. Verification (package root): - npm run quick-build -> OK (one-shot; transient clean ENOTEMPTY once, succeeded on retry). - TZ=UTC node_modules/.bin/jest -c jest.unit.config.js src/button src/internal/components/control-group -> 36 suites, 801 passed, 1 skipped, 0 failed. - npx tsc --noEmit -p tsconfig.json -> clean. - stylelint src/button/styles.scss -> clean; eslint changed files -> clean. - Compiled styles.scoped.css confirms: min-block-size var(--size-vertical-input,32px) + white-space: nowrap on .grouped; padding-inline var(--space-field-horizontal,8px) only under grouped+icon; base .button still white-space: normal. --- src/button/styles.scss | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index 4ad0f0aee3..5ad683356c 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -129,18 +129,44 @@ $tokens: style-api.resolve($props, carrier); // where the border and radius are painted — so the facing corners flatten correctly // regardless of variant. &.grouped { - @include styles.grouped-control; - // The seam overlap (negative inline-start margin) lives on the control-group // `.action-slot`, mirroring how the other controls get their seam from the // control-group slot rather than from the control itself. + // Match the fused action's border-box height to the control field height. The + // controls floor their height with `min-block-size: $size-vertical-input` under + // `box-sizing: border-box` (see input/styles.scss `.input`); the Button has the + // same `border-box` reset but no `min-block-size`, so with the smaller button + // vertical padding its box was shorter than the field box. Under the row's + // bottom-edge alignment (`align-items: flex-end`) a shorter box left a gap at the + // TOP, so the fused action's top border sat a couple pixels below the controls'. + // Flooring to the same `$size-vertical-input` makes both boxes resolve to the same + // field height, so the top borders are flush in both directions. Border widths are + // already inside each border-box, so the icon border recolor below does not change + // this outer height. + min-block-size: awsui.$size-vertical-input; + + // A fused text (normal/primary) action must stay on a single line so the strip + // height keeps matching the controls; override the base `.button` text-wrapping + // (`white-space: normal`). Standalone buttons (no `.grouped`) keep wrapping. + white-space: nowrap; + + @include styles.grouped-control; + // The icon variant is visually borderless (transparent border). As a fused control // it needs the SAME gray control border the other controls draw, so the fused strip // reads as one continuous outline. Normal/primary keep their own border. &.variant-icon { border-block: awsui.$border-width-field solid awsui.$color-border-input-default; border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; + + // The stock icon-only inline padding ($space-xxs, applied by `.variant-icon` + // below) makes the fused icon cell look cramped against the roomier control + // fields. Use the controls' own horizontal field padding so the icon sits in a + // cell whose inline insets match the adjacent control. Scoped to grouped+icon so + // standalone icon buttons keep $space-xxs. This changes inline padding only, so + // the border-box height (floored above) is unaffected. + padding-inline: awsui.$space-field-horizontal; } } From 8324c89d7bc49b85a1ccb969f25e6b9efeffbdca Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 20:53:04 +0200 Subject: [PATCH 6/8] fix: Align fused action button to field height Addresses the review finding that the prior `min-block-size: $size-vertical-input` floor on `.button.grouped` is arithmetically inert in the default theme: the base Button already resolves to a 32px border-box (line-height 22px + 2x4px block padding + 2x1px border), so flooring to 32px changed no default geometry. Give the fused action the control field's full height recipe instead: add `padding-block: $space-field-vertical` alongside the existing `min-block-size: $size-vertical-input`. The field box is `content + 2*$space-field-vertical + 2*$border-width-field` floored to `$size-vertical-input` under border-box; the button already shares border-box and the same 22px content line-height (via styles-reset font-body-m), so matching the block-padding token and floor makes the button border-box equal the field box by construction, keeping the top borders flush across token modes rather than only where the button and field spacing tokens coincide. normal/primary keep their own border width per spec. The grouped icon border recolor stays inside the border-box, so it adds no net height. Also correct the comments: the base `white-space: normal` comes from styles-reset (not styles.text-wrapping), and drop the inert "shorter box / 2px gap" narrative and restated scope notes. Keeps grouped icon padding ($space-field-horizontal, grouped+icon only) and nowrap on `.button.grouped`; non-grouped buttons unchanged. Verification (default-theme token values): - field: max(32, 22 + 2*4 + 2*1) = 32px - button: 22 + 2*4 + 2*1 = 32px (recipe match) - npm run quick-build -> OK - jest src/button src/internal/components/control-group -> 801 passed, 1 skipped - tsc --noEmit -> clean - stylelint src/button/styles.scss -> clean - eslint src/button/internal.tsx -> clean (unmodified) --- src/button/styles.scss | 43 ++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index 5ad683356c..3be1dd7321 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -133,39 +133,42 @@ $tokens: style-api.resolve($props, carrier); // `.action-slot`, mirroring how the other controls get their seam from the // control-group slot rather than from the control itself. - // Match the fused action's border-box height to the control field height. The - // controls floor their height with `min-block-size: $size-vertical-input` under - // `box-sizing: border-box` (see input/styles.scss `.input`); the Button has the - // same `border-box` reset but no `min-block-size`, so with the smaller button - // vertical padding its box was shorter than the field box. Under the row's - // bottom-edge alignment (`align-items: flex-end`) a shorter box left a gap at the - // TOP, so the fused action's top border sat a couple pixels below the controls'. - // Flooring to the same `$size-vertical-input` makes both boxes resolve to the same - // field height, so the top borders are flush in both directions. Border widths are - // already inside each border-box, so the icon border recolor below does not change - // this outer height. + // Give the fused action the control field's exact height recipe so its border-box + // equals the field's in every theme, not only where the button and field tokens + // happen to coincide. The field is `box-sizing: border-box` with + // `padding-block: $space-field-vertical`, `border-block: $border-width-field` and + // `min-block-size: $size-vertical-input` (input/styles.scss `.input`). The button + // shares the `border-box` reset and the same content line-height (both resolve + // `font-body-m` via styles-reset), but defaults to `$space-button-vertical` padding + // with no floor. Switching the block padding to `$space-field-vertical` and adding + // the same floor makes `action box = content + 2·$space-field-vertical + + // 2·border == field box`, so under the row's bottom-edge alignment + // (`align-items: flex-end`) the top borders stay flush even if a theme sets the + // button and field spacing tokens apart. normal/primary keep their own border width + // per spec; where `$border-width-button` differs from `$border-width-field` that + // single-token difference is intentional and left untouched. + padding-block: awsui.$space-field-vertical; min-block-size: awsui.$size-vertical-input; // A fused text (normal/primary) action must stay on a single line so the strip - // height keeps matching the controls; override the base `.button` text-wrapping - // (`white-space: normal`). Standalone buttons (no `.grouped`) keep wrapping. + // keeps the control row's height; override the base `.button` `white-space: normal` + // (set by styles-reset). Standalone buttons (no `.grouped`) keep wrapping. white-space: nowrap; @include styles.grouped-control; // The icon variant is visually borderless (transparent border). As a fused control // it needs the SAME gray control border the other controls draw, so the fused strip - // reads as one continuous outline. Normal/primary keep their own border. + // reads as one continuous outline. Both tokens are inside the border-box, so this + // recolor keeps the height recipe above intact. Normal/primary keep their own border. &.variant-icon { border-block: awsui.$border-width-field solid awsui.$color-border-input-default; border-inline: awsui.$border-width-field solid awsui.$color-border-input-default; - // The stock icon-only inline padding ($space-xxs, applied by `.variant-icon` - // below) makes the fused icon cell look cramped against the roomier control - // fields. Use the controls' own horizontal field padding so the icon sits in a - // cell whose inline insets match the adjacent control. Scoped to grouped+icon so - // standalone icon buttons keep $space-xxs. This changes inline padding only, so - // the border-box height (floored above) is unaffected. + // Stock icon-only inline padding ($space-xxs) looks cramped beside the roomier + // control fields; use the controls' own horizontal field padding so the icon cell + // matches its neighbor. Scoped to grouped+icon, so standalone icon buttons keep + // $space-xxs. Inline-only, so the height recipe above is unaffected. padding-inline: awsui.$space-field-horizontal; } } From 5ff150af027819074b7d2044cc0d1d52c2b39178 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 21:07:35 +0200 Subject: [PATCH 7/8] fix: Prove fused action height equality for icon Address the review's one blocking finding: the grouped action button's height declarations are default-theme no-ops and the comment overclaimed a universal "equal in every theme / by construction" invariant that the retained $border-width-button for normal/primary breaks. Take the finding's "demonstrate the supported token constraints that make the formulas equal" option and rewrite the comment to match the box model (comment-only; the declarations are already correct and unchanged): - Write out the field recipe max($size-vertical-input, lineHeight + 2*$space-field-vertical + 2*$border-width-field) under border-box. - Show the grouped action shares border-box and the same font-body-m line-height, so adopting $space-field-vertical padding and the $size-vertical-input floor makes the two recipes share every term except the border width. - Scope the EXACT equality to the icon variant, whose border is recolored to $border-width-field, so there every term matches the field token-for-token (the variant the user reported the offset on). The load-bearing fix for that offset is the border recolor giving the icon cell a real 1px edge; padding+floor is the height half. - State normal/primary keep $border-width-button per spec, so their height matches only while the two border tokens coincide (true in the default theme) - the one intentional, documented divergence. Removes the overclaimed universal-invariant wording (the non-blocking comment finding). No declaration changed; non-grouped buttons untouched. Verification: - field: max(32, 22 + 2*4 + 2*1) = 32px - grouped icon action: max(32, 22 + 2*4 + 2*1) = 32px (token-for-token) - npm run quick-build -> OK - jest src/button src/internal/components/control-group -> 801 passed, 1 skipped - tsc --noEmit -> clean - stylelint src/button/styles.scss -> clean - eslint src/button/internal.tsx -> clean (unmodified) --- src/button/styles.scss | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index 3be1dd7321..cbed2f4f04 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -133,20 +133,21 @@ $tokens: style-api.resolve($props, carrier); // `.action-slot`, mirroring how the other controls get their seam from the // control-group slot rather than from the control itself. - // Give the fused action the control field's exact height recipe so its border-box - // equals the field's in every theme, not only where the button and field tokens - // happen to coincide. The field is `box-sizing: border-box` with - // `padding-block: $space-field-vertical`, `border-block: $border-width-field` and - // `min-block-size: $size-vertical-input` (input/styles.scss `.input`). The button - // shares the `border-box` reset and the same content line-height (both resolve - // `font-body-m` via styles-reset), but defaults to `$space-button-vertical` padding - // with no floor. Switching the block padding to `$space-field-vertical` and adding - // the same floor makes `action box = content + 2·$space-field-vertical + - // 2·border == field box`, so under the row's bottom-edge alignment - // (`align-items: flex-end`) the top borders stay flush even if a theme sets the - // button and field spacing tokens apart. normal/primary keep their own border width - // per spec; where `$border-width-button` differs from `$border-width-field` that - // single-token difference is intentional and left untouched. + // Reproduce the control field's block-height recipe so the fused action lines up + // with its neighbours under the row's bottom-edge alignment (`align-items: + // flex-end` on the control-group root), where a shorter box would leave the top + // border a couple pixels low — the offset the icon action originally showed. + // The field box (input/styles.scss `.input`) is, under `box-sizing: border-box`: + // field = max($size-vertical-input, lineHeight + 2·$space-field-vertical + 2·$border-width-field) + // The button shares the same `border-box` reset and the same `font-body-m` + // line-height (styles-reset; `font-button` only adds weight), so adopting the + // field's own padding and floor tokens below makes the two recipes share every + // term EXCEPT the border width. For the icon variant the border is recolored to + // `$border-width-field` (see `&.variant-icon`), so there every term matches the + // field token-for-token and the border-box heights are provably equal, not merely + // 32px by coincidence. normal/primary keep `$border-width-button` per spec, so + // their height matches only while `$border-width-button == $border-width-field` + // (true in the default theme); that single-token divergence is intentional. padding-block: awsui.$space-field-vertical; min-block-size: awsui.$size-vertical-input; From a256f3463583b2023c00ff22daeb3b9a76d6826e Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 8 Oct 2026 09:30:44 +0200 Subject: [PATCH 8/8] feat: Replace ControlGroup action slot with actionButton --- pages/control-group/action.page.tsx | 51 ++------------ .../__tests__/control-group.test.tsx | 69 +++++-------------- .../components/control-group/index.tsx | 48 ++++++++++--- 3 files changed, 59 insertions(+), 109 deletions(-) diff --git a/pages/control-group/action.page.tsx b/pages/control-group/action.page.tsx index bf9430af20..eb1eabcc46 100644 --- a/pages/control-group/action.page.tsx +++ b/pages/control-group/action.page.tsx @@ -2,12 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; -import Button from '~components/button'; import FormField from '~components/form-field'; import Input from '~components/input'; import ControlGroup, { InternalControlGroupProps } from '~components/internal/components/control-group'; -import Multiselect, { MultiselectProps } from '~components/multiselect'; import RadioGroup from '~components/radio-group'; import Select, { SelectProps } from '~components/select'; @@ -27,41 +24,8 @@ const operators: SelectProps.Option[] = [ { value: '!=', label: '!=' }, ]; -const multiOptions: MultiselectProps.Option[] = [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, -]; - -const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { value: 'MemoryUtilization' }]; - -const enteredTextLabel = (value: string) => `Use: ${value}`; - -// Three border cases: icon (borderless, slot supplies the gray control border), -// normal and primary (keep their own border). -const removeAction = ; -const primaryAction = ; - const input = ; const select = ); const actionSlot = container.querySelector(`.${styles['action-slot']}`); expect(actionSlot).not.toBeNull(); - expect(actionSlot).toContainElement(getByTestId('action')); + expect(actionSlot).toContainElement(getByRole('button', { name: 'Remove' })); }); - test('renders no action-slot element when action is absent', () => { + test('renders no action-slot element when actionButton is absent', () => { const { container } = render( @@ -95,7 +93,7 @@ describe('Control group', () => { test('does not count the action as a control for a single-control group', () => { const { getByTestId } = render( - Remove}> + ); @@ -105,7 +103,7 @@ describe('Control group', () => { test('does not count the action as a control when positions are assigned', () => { const { getByTestId } = render( - Remove}> + @@ -117,41 +115,21 @@ describe('Control group', () => { expect(getByTestId('c')).toHaveTextContent('last'); }); - test('renders the action button as a grouped-last control so it fuses onto the controls', () => { - const { container } = render( - Apply}> - - - ); - - // The button reads the grouped-control context and applies the shared grouped - // classes to its own root, where its border + radius are painted. The action is - // always `last` in the horizontal direction (it is the trailing fused element, - // beside the stacked column in vertical direction). - const button = container.querySelector(`.${buttonStyles.button}`); - expect(button).not.toBeNull(); - expect(button!.classList.contains(buttonStyles.grouped)).toBe(true); - expect(button!.classList.contains(buttonStyles['grouped-horizontal-last'])).toBe(true); - }); - - test('an icon-variant action button fuses as a grouped-last control', () => { - const { container } = render( - }> + test('calls onClick when the action button is clicked', () => { + const onClick = jest.fn(); + const { getByRole } = render( + ); - const button = container.querySelector(`.${buttonStyles.button}`); - expect(button).not.toBeNull(); - // The borderless icon variant gets the shared gray control border via its own - // grouped + variant-icon styling (asserted here by the grouped class membership). - expect(button!.classList.contains(buttonStyles.grouped)).toBe(true); - expect(button!.classList.contains(buttonStyles['variant-icon'])).toBe(true); + fireEvent.click(getByRole('button', { name: 'Remove' })); + expect(onClick).toHaveBeenCalledTimes(1); }); test('signals endAction to the controls so they square the corners facing the action', () => { const { getByTestId } = render( - Remove}> + @@ -172,21 +150,6 @@ describe('Control group', () => { expect(getByTestId('a')).toHaveTextContent('no'); expect(getByTestId('b')).toHaveTextContent('no'); }); - - test('a normal-variant action button keeps its own border and fuses as grouped-last', () => { - const { container } = render( - Apply}> - - - ); - - const button = container.querySelector(`.${buttonStyles.button}`); - expect(button).not.toBeNull(); - // Normal/primary buttons keep their own border; they are still grouped so the - // shared corner mixin flattens their inline-start corners against the seam. - expect(button!.classList.contains(buttonStyles.grouped)).toBe(true); - expect(button!.classList.contains(buttonStyles['variant-normal'])).toBe(true); - }); }); describe('direction', () => { diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 2ad1aaaffa..6d81992f98 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { InternalButton } from '../../../button/internal'; +import { IconProps } from '../../../icon/interfaces'; import { BaseComponentProps } from '../../../types/base-component'; import { getBaseProps } from '../../base-component'; import { @@ -10,6 +12,7 @@ import { GroupedControlDirection, GroupedControlPosition, } from '../../context/control-group-context'; +import { fireNonCancelableEvent, NonCancelableEventHandler } from '../../events'; import { flattenChildren } from '../../utils/flatten-children'; import styles from './styles.css.js'; @@ -18,16 +21,34 @@ export interface InternalControlGroupProps extends BaseComponentProps { children?: React.ReactNode; direction?: GroupedControlDirection; /** - * Slot to render a button next to the grouped controls. The action is a sibling - * of the fused controls and stays on the side rather than stacking with them. + * Specifies an action button rendered next to the grouped controls. */ - action?: React.ReactNode; + actionButton?: InternalControlGroupProps.ActionButton; +} + +export namespace InternalControlGroupProps { + export interface ActionButton { + /** Called when the user clicks the button. */ + onClick?: NonCancelableEventHandler; + /** Accessible name for the action. */ + ariaLabel?: string; + /** Displays an icon in the action (for example `remove` or `close`). */ + iconName?: IconProps.Name; + /** Alternate text for a custom icon, recommended for accessibility. */ + iconAlt?: string; + /** Disables the action and prevents clicks. */ + disabled?: boolean; + /** Reason the action is disabled (keeps it focusable). */ + disabledReason?: string; + /** Adds `aria-describedby` to point the action at extra descriptive text. */ + ariaDescribedby?: string; + } } export default function InternalControlGroup({ children, direction = 'horizontal', - action, + actionButton, ...props }: InternalControlGroupProps) { const baseProps = getBaseProps(props); @@ -37,9 +58,9 @@ export default function InternalControlGroup({ const controls = (
{flattenedChildren.map((child, index) => { const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; @@ -55,7 +76,7 @@ export default function InternalControlGroup({ action is never counted as a control. `endAction` only tells the control which facing corners to square so it fuses with the action. */} - + {child}
@@ -64,7 +85,7 @@ export default function InternalControlGroup({
); - if (!action) { + if (!actionButton) { return controls; } @@ -84,7 +105,16 @@ export default function InternalControlGroup({ */}
- {action} + fireNonCancelableEvent(actionButton.onClick)} + />