From ec225d939c6d4965fa7131d26c0d9ee3dc4cbe13 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 13 Oct 2026 09:36:55 +0200 Subject: [PATCH 01/40] feat: Add validation states to ControlGroup ControlGroup now reflects the surrounding form field's validation state (error and warning) by reading invalid/warning from the form-field context, with error taking precedence over warning. - The group owns the validation state and masks it from its children, so inner controls stop painting their own borders and inner seams between controls stay neutral. - The validation color is painted per control on each control's outward-facing edges only (first/middle/last/only, horizontal and vertical), threaded through the shared grouped-control mixin via an inherited custom property with a neutral fallback. No row-spanning overlay. - When an action button is present, the boundary between the controls and the button (the button's inline-start border) takes the validation color and reuses the same extra-thick invalid border width as the inner controls; the button's other edges stay neutral. - The seam's validation color and thickness persist through the button's hover and active states, scoped strictly to the validation + action case. Adds a FormField-wrapped validation dev page, a declarative visual-test entry, and unit tests covering context wiring, per-control edge styling, neutral seams, action-button seam, and error-over-warning precedence. --- build-tools/utils/custom-css-properties.js | 2 + pages/control-group/validation.page.tsx | 96 ++++++ src/button/styles.scss | 46 +++ .../control-group/__tests__/common.tsx | 20 ++ .../__tests__/control-group.test.tsx | 326 +++++++++++++++++- .../components/control-group/index.tsx | 67 +++- .../components/control-group/styles.scss | 29 ++ .../control-group/test-classes/styles.scss | 8 + .../context/control-group-context.tsx | 20 +- src/internal/generated/styles/tokens.d.ts | 4 + .../styles/forms/grouped-control.scss | 57 ++- src/segmented-control/styles.scss | 2 +- test/definitions/visual/control-group.ts | 6 + 13 files changed, 674 insertions(+), 9 deletions(-) create mode 100644 pages/control-group/validation.page.tsx diff --git a/build-tools/utils/custom-css-properties.js b/build-tools/utils/custom-css-properties.js index 45c758b3d47..3dfbe47d756 100644 --- a/build-tools/utils/custom-css-properties.js +++ b/build-tools/utils/custom-css-properties.js @@ -204,5 +204,7 @@ const customCssPropertiesList = [ 'inlineLabelBackgroundColor', // Container style API 'containerStyleBorderRadius', + // Control group validation + 'groupedControlBorderColor', ]; module.exports = customCssPropertiesList; diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx new file mode 100644 index 00000000000..ac0eb24244a --- /dev/null +++ b/pages/control-group/validation.page.tsx @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { SpaceBetween } from '~components'; +import FormField from '~components/form-field'; +import Input from '~components/input'; +import ControlGroup from '~components/internal/components/control-group'; +import Select from '~components/select'; + +import { SimplePage } from '../app/templates'; +import { input, noop, operators, select, useControlGroupDirection } from './common'; +import { DirectionSettings } from './common'; + +// A three-control group makes the middle control's neutral seam edges visible: +// only its first and last siblings paint an outward-facing group edge. +const threeControls = [ + , + + + + + ); + + const group = getByRole('group'); + expect(group).toHaveClass(invalidClass); + expect(group).toHaveClass(controlGroupTestStyles['controls-invalid']); + expect(group).not.toHaveClass(warningClass); + }); + + test('publishes the error color through the grouped-control border custom property', () => { + // The group sets the custom property inline on `.controls`; every grouped + // control then reads it on exactly its outward-facing border edges. + const { getByRole } = render( + + + + + + + ); + + const group = getByRole('group'); + expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + }); + + test('applies the warning class to the group when the surrounding form field is in a warning state', () => { + const { getByRole } = render( + + + + + + + ); + + const group = getByRole('group'); + expect(group).toHaveClass(warningClass); + expect(group).toHaveClass(controlGroupTestStyles['controls-warning']); + expect(group).not.toHaveClass(invalidClass); + }); + + test('publishes the warning color through the grouped-control border custom property', () => { + const { getByRole } = render( + + + + + + + ); + + const group = getByRole('group'); + expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusWarning); + }); + + test('applies no validation class when the surrounding form field is neutral', () => { + const { getByRole } = render( + + + + + + ); + + const group = getByRole('group'); + expect(group).not.toHaveClass(invalidClass); + expect(group).not.toHaveClass(warningClass); + }); + + test('does not set the border custom property when the surrounding form field is neutral', () => { + // With the property unset, each grouped control's `var()` falls back to its + // own neutral border color, so the group looks unchanged. + const { getByRole } = render( + + + + + + ); + + const group = getByRole('group'); + expect(group.style.getPropertyValue(borderColorProp)).toBe(''); + }); + + test('error takes precedence over warning when the form field has both', () => { + const { getByRole } = render( + + + + + + ); + + const group = getByRole('group'); + expect(group).toHaveClass(invalidClass); + expect(group).not.toHaveClass(warningClass); + // Precedence also holds for the published color. + expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + }); + + test('does not apply the group validation class to the action button element', () => { + const { container, getByRole } = render( + + + + + + ); + + // The group (`.controls`) carries the state and the custom property. The + // `-invalid` / `-warning` marker classes stay on `.controls` only; the + // action button never carries them. + const group = getByRole('group'); + expect(group).toHaveClass(invalidClass); + expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + + const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); + expect(actionButton).not.toHaveClass(invalidClass); + expect(actionButton).not.toHaveClass(warningClass); + expect(actionButton).not.toHaveClass(controlGroupTestStyles['controls-invalid']); + expect(actionButton).not.toHaveClass(controlGroupTestStyles['controls-warning']); + // The validation color reaches the button through the inherited custom + // property on its parent action slot, not as an inline style on the button + // element itself. + expect(actionButton.style.getPropertyValue(borderColorProp)).toBe(''); + }); + + test('publishes the error color to the action slot so the controls↔button seam is colored', () => { + // The seam between the controls and the action button is the button's + // inline-start border. The group publishes the validation color to the + // action slot (the button's parent); the button's SCSS reads it on exactly + // that controls-facing edge. jsdom can't compute SCSS, so assert the + // published custom property on the slot (the testable surface). + const { container } = render( + + + + + + ); + + const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; + expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + }); + + test('publishes the warning color to the action slot so the controls↔button seam is colored', () => { + const { container } = render( + + + + + + ); + + const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; + expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusWarning); + }); + + test('error takes precedence over warning for the controls↔button seam', () => { + const { container } = render( + + + + + + ); + + const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; + expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + }); + + test('does not color the action slot when the surrounding form field is neutral (no stray seam)', () => { + // With the property unset on the slot, the button's inline-start border + // `var()` falls back to its neutral variant color, so no stray colored seam + // appears when there is no validation state. + const { container } = render( + + + + + + ); + + const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; + expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(''); + }); + + // The seam gets BOTH the validation color (published via the custom property, + // asserted above) AND the extra-thick inline-start border width that the + // standalone invalid/warning controls render. The thickness is applied in the + // button's SCSS off the `action-button-validation` marker class, reusing the + // same `$invalid-control-left-border` token the standalone controls use. + // jsdom cannot compute the SCSS pixel width (nor the `:hover` persistence), so + // the testable surface is the marker class the width and hover rules gate on; + // the actual pixel width and hover persistence are covered by the visual test. + const actionValidationClass = buttonStyles['action-button-validation']; + + test('marks the action button for the thick validation seam in an error state', () => { + const { container } = render( + + + + + + ); + + const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); + expect(actionButton).toHaveClass(actionValidationClass); + }); + + test('marks the action button for the thick validation seam in a warning state', () => { + const { container } = render( + + + + + + ); + + const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); + expect(actionButton).toHaveClass(actionValidationClass); + }); + + test('does not mark the action button for the thick seam when the form field is neutral', () => { + const { container } = render( + + + + + + ); + + const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); + expect(actionButton).not.toHaveClass(actionValidationClass); + }); + + test('does not emit the thick-seam marker when a validation state has no action button', () => { + // The thickness, like the color, is strictly gated to the validation + + // action case: with no action button there is no element to mark. + const { container } = render( + + + + + + ); + + expect(container.querySelector(`.${actionValidationClass}`)).toBeNull(); + }); + + test('keeps the thick-seam marker off the controls so inner controls are unaffected', () => { + const { getByRole } = render( + + + + + + ); + + expect(getByRole('group')).not.toHaveClass(actionValidationClass); + }); + + test('masks the validation state for inner controls so seams stay neutral (error)', () => { + const { getByTestId } = render( + + + + + + ); + + // The child reads neutral validation flags: it will not paint its own + // border or inner seams. + expect(getByTestId('form-field-probe')).toHaveTextContent('invalid:false warning:false'); + }); + + test('masks the validation state for inner controls so seams stay neutral (warning)', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('form-field-probe')).toHaveTextContent('invalid:false warning:false'); + }); + + test('exposes the resolved validation state through the grouped-control context', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('grouped-probe')).toHaveTextContent('invalid:true warning:false'); + }); + }); }); diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index ca3da291077..3b5bbc4c574 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -8,9 +8,13 @@ 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 { FormFieldContext, useFormFieldContext } from '../../context/form-field-context'; +import customCssProps from '../../generated/custom-css-properties'; +import { colorTextStatusError, colorTextStatusWarning } from '../../generated/styles/tokens'; import { flattenChildren } from '../../utils/flatten-children'; import { ControlGroupProps } from './interfaces'; +import buttonStyles from '../../../button/styles.css.js'; import styles from './styles.css.js'; import testUtilStyles from './test-classes/styles.css.js'; @@ -26,6 +30,23 @@ export default function ControlGroup({ const baseProps = getBaseProps(props); const labelId = useUniqueId('control-group-label'); + // The group owns the validation state read from the surrounding form field. + // Error wins over warning, matching the precedence every individual control uses. + const formFieldContext = useFormFieldContext({}); + const isInvalid = !!formFieldContext.invalid; + const isWarning = !!formFieldContext.warning && !isInvalid; + + // Publish the validation color to every grouped control via an inherited custom + // property set on the `.controls` box. Each control reads it on exactly its + // outward-facing border edges (see the shared `grouped-control` mixin), so the + // inner seams between controls stay neutral. The action button lives in a + // sibling `.action-slot`, so it never inherits this property and is excluded. + // Error wins over warning; when neutral the property is omitted entirely. + const groupedControlBorderColor = isInvalid ? colorTextStatusError : isWarning ? colorTextStatusWarning : undefined; + const controlsStyle = groupedControlBorderColor + ? ({ [customCssProps.groupedControlBorderColor]: groupedControlBorderColor } as React.CSSProperties) + : undefined; + const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; @@ -35,9 +56,13 @@ export default function ControlGroup({
@@ -50,8 +75,20 @@ export default function ControlGroup({ key={key ? String(key) : index} className={clsx(styles.control, styles[`control-${position}-${direction}`])} > - - {child} + + {/* + Each grouped control paints the validation color on its own + outward-facing border edges (driven by the inherited custom + property above). Mask the form-field validation flags so inner + controls do not ALSO react to the form field directly (which + would color their inner seams); everything else (controlId, aria + wiring) passes through unchanged. + */} + + {child} +
); @@ -65,9 +102,31 @@ export default function ControlGroup({ className={clsx(styles['group-layout'], styles[`group-layout-${direction}`], !inlineLabelText && rootClassnames)} > {controls} -
+ {/* + Publish the validation color to the action slot so the action button + inherits `--awsui-grouped-control-border-color`. The action button's + `variant-control-group-action` style reads that property on exactly its + inline-start (controls-facing) edge, coloring the single boundary line + between the controls and the button. The property is only set here when + the group is in a validation state (`controlsStyle` is otherwise + undefined), so a neutral group falls back to the button's neutral variant + border and no stray colored seam appears. The button's other edges stay + neutral because the variant only reads the property on inline-start. + */} +
({ position: null, direction: 'horizontal', hasAction: false, + invalid: false, + warning: false, }); export function useGroupedControlContext() { @@ -41,11 +53,15 @@ export function useGroupedControlContext() { * content) should wrap that content with this provider. Otherwise a nested * control rendered inside such a slot would inherit the surrounding group's * `position` and incorrectly pick up the grouped-control styling - * where adjacent borders lose their border radii. + * where adjacent borders lose their border radii. The group's validation + * state (`invalid`/`warning`) is also cleared so nested custom-slot controls + * never inherit the group's validation styling. */ export function ResetGroupedControlContext({ children }: { children: React.ReactNode }) { const { direction } = useGroupedControlContext(); return ( - {children} + + {children} + ); } diff --git a/src/internal/generated/styles/tokens.d.ts b/src/internal/generated/styles/tokens.d.ts index b42ce787d0a..c29e9bbf787 100644 --- a/src/internal/generated/styles/tokens.d.ts +++ b/src/internal/generated/styles/tokens.d.ts @@ -62,6 +62,10 @@ export const colorChartsThresholdPositive: string; export const colorChartsThresholdInfo: string; export const colorChartsThresholdNeutral: string; +// Status text colors +export const colorTextStatusError: string; +export const colorTextStatusWarning: string; + // Spacing export const spaceXs: string; export const spaceXxs: string; diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index 2e29f0808ea..ee30ca34b02 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -1,12 +1,41 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +@use 'sass:list'; @use '../tokens' as awsui; @use '../utils' as styles; @use './constants' as constants; +@use '../../generated/custom-css-properties/index.scss' as custom-props; $grouped-control-border-radius: constants.$control-border-radius; +// The group owns the validation state and publishes it to every grouped control +// through the inherited `--awsui-grouped-control-border-color-*` custom property +// (set once on the `.controls` box). Each control reads it on exactly its +// OUTWARD-facing border edges — the edges that do NOT touch an adjacent control — +// so the inner seams stay the control's own neutral border color. When the group +// is neutral the property is unset and the `var()` falls back to the neutral +// color, so grouped controls look unchanged outside a validation state. +// +// `$neutral-border-color` is the consuming control's own border color (the +// fallback). Input and the Select/Multiselect trigger use the input border +// color; segmented-control passes its segment-wrapper border color. +@mixin grouped-control-outer-border-color($edges, $neutral-border-color: awsui.$color-border-input-default) { + $color: var(#{custom-props.$groupedControlBorderColor}, #{$neutral-border-color}); + @if list.index($edges, inline-start) { + border-inline-start-color: $color; + } + @if list.index($edges, inline-end) { + border-inline-end-color: $color; + } + @if list.index($edges, block-start) { + border-block-start-color: $color; + } + @if list.index($edges, block-end) { + border-block-end-color: $color; + } +} + @mixin grouped-control-base { @include styles.logical-radius($grouped-control-border-radius); } @@ -31,30 +60,56 @@ $grouped-control-border-radius: constants.$control-border-radius; @include styles.logical-radius(0); } -@mixin grouped-control { +@mixin grouped-control($neutral-border-color: awsui.$color-border-input-default) { @include grouped-control-base; &-horizontal { &-first { @include grouped-control-horizontal-first; + // Outward edges: inline-start (group start) + both block edges. + // inline-end faces the next control, so it stays neutral. + @include grouped-control-outer-border-color((inline-start, block-start, block-end), $neutral-border-color); } &-middle { @include grouped-control-middle; + // Only the block edges face outward; both inline edges face neighbors. + @include grouped-control-outer-border-color((block-start, block-end), $neutral-border-color); } &-last { @include grouped-control-horizontal-last; + // Outward edges: inline-end (group end) + both block edges. + @include grouped-control-outer-border-color((inline-end, block-start, block-end), $neutral-border-color); + } + &-only { + @include grouped-control-outer-border-color( + (inline-start, inline-end, block-start, block-end), + $neutral-border-color + ); } } &-vertical { &-first { @include grouped-control-vertical-first; + // Outward edges: block-start (group top) + both inline edges. + // block-end faces the next control, so it stays neutral. + @include grouped-control-outer-border-color((block-start, inline-start, inline-end), $neutral-border-color); } &-middle { @include grouped-control-middle; + // Only the inline edges face outward; both block edges face neighbors. + @include grouped-control-outer-border-color((inline-start, inline-end), $neutral-border-color); } &-last { @include grouped-control-vertical-last; + // Outward edges: block-end (group bottom) + both inline edges. + @include grouped-control-outer-border-color((block-end, inline-start, inline-end), $neutral-border-color); + } + &-only { + @include grouped-control-outer-border-color( + (inline-start, inline-end, block-start, block-end), + $neutral-border-color + ); } } diff --git a/src/segmented-control/styles.scss b/src/segmented-control/styles.scss index 97d2a75c6d6..ca5f2198fe8 100644 --- a/src/segmented-control/styles.scss +++ b/src/segmented-control/styles.scss @@ -62,7 +62,7 @@ } .grouped { - @include styles.grouped-control; + @include styles.grouped-control(awsui.$color-border-segment-wrapper); &.grouped-horizontal-middle, &.grouped-horizontal-last { diff --git a/test/definitions/visual/control-group.ts b/test/definitions/visual/control-group.ts index 296f3ce550b..e3d4680f57d 100644 --- a/test/definitions/visual/control-group.ts +++ b/test/definitions/visual/control-group.ts @@ -12,6 +12,12 @@ const suite: TestSuite = { screenshotType: 'permutations', pixelDiffTolerance: 2, }, + { + description: 'Validation states', + path: 'control-group/validation', + screenshotType: 'screenshotArea', + pixelDiffTolerance: 2, + }, ], }; From 9bb8c6a2411b6b39aae7b72c1b1839f76706a170 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 13 Oct 2026 10:13:50 +0200 Subject: [PATCH 02/40] fix: Render ControlGroup validation action seam on the control not the button Move the thick, validation-colored controls-to-action-button seam from the action button to the control(s) facing it, reusing the existing grouped-control validation-border mechanism. - getGroupedControlClassNames emits a grouped-validation class on the control facing the button (last/only horizontally, all controls vertically) when the group is in a validation state and has an action button. - The shared grouped-control mixin applies the thick inline-end border on that class, reusing constants.$invalid-control-left-border plus the standard padding compensation. The inline-end color is already published via the grouped-control border custom property. - Remove the inline-start color override and action-button-validation thick-width block from the button's variant-control-group-action; the button is neutral on all edges. Drop the action-button-validation marker wiring and the action-slot color publishing from ControlGroup. Net visual result is unchanged: one thick validation-colored boundary line between the controls and the action button, persisting through hover/active, now drawn by the control. box-sizing: border-box keeps the row from shifting. Verification: quick-build exit 0; control-group jest 28/28 passed; stylelint and eslint exit 0 on all changed files. Compiled CSS confirms the same-element compound selector .grouped.grouped-validation emits border-inline-end-width: var(--border-invalid-width, 4px) (reused token). --- pages/control-group/validation.page.tsx | 2 +- src/button/styles.scss | 46 ----- .../__tests__/control-group.test.tsx | 167 +++++------------- .../control-group/grouped-control-styles.ts | 16 +- .../components/control-group/index.tsx | 44 +---- .../components/control-group/styles.scss | 32 +--- .../styles/forms/grouped-control.scss | 13 ++ 7 files changed, 80 insertions(+), 240 deletions(-) diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index ac0eb24244a..6bb19b2aba9 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -26,7 +26,7 @@ export default function ControlGroupValidation() { return ( } i18n={{}} screenshotArea={{}} diff --git a/src/button/styles.scss b/src/button/styles.scss index ea83348fe92..e77ee13e364 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -219,52 +219,6 @@ $tokens: style-api.resolve($props, carrier); padding-block: awsui.$space-field-vertical; padding-inline: awsui.$space-xs; min-block-size: awsui.$size-vertical-input; - - // When this button is a ControlGroup action, its inline-start border is the - // single boundary line between the controls and the action button (the - // action slot overlaps the last control's inline-end border). The group - // publishes its validation color through the inherited - // `--awsui-grouped-control-border-color` custom property (set only when the - // group is in a validation state), so color ONLY that controls-facing edge - // with it, falling back to the neutral input border color otherwise. The - // block-start, block-end, and far inline-end edges keep the variant's own - // neutral border color, so the rest of the button stays neutral. Repeat the - // override on the interactive states so the seam keeps the validation color - // through hover/active/disabled (whose variant border colors would otherwise - // reset this edge to neutral at higher specificity). - &, - &:hover, - &:active, - &.disabled { - border-inline-start-color: var(#{custom-props.$groupedControlBorderColor}, #{awsui.$color-border-input-default}); - } - - // In a validation state the ControlGroup adds its - // `action-button-validation` marker class to this button (routed through the - // button's `className`). Match the EXTRA-THICK inline-start border the - // standalone invalid/warning controls render, reusing the SAME width token - // (`$invalid-control-left-border` = `$border-invalid-width`) so the seam stays - // in sync with that mechanism — no hardcoded width. Because `styles-reset` - // sets `box-sizing: border-box`, the thicker border grows INWARD, so the - // button's border-box width is unchanged and the wrapping row does not shift; - // the only effect is the centered icon nudging inward by the extra width. - // Mirror the standalone controls' compensation by reducing - // `padding-inline-start` by the same delta (the variant's inline padding is - // `$space-xs`), re-centering the icon. Apply thickness ONLY in the - // validation + action case, and repeat it across hover/active/disabled so the - // thickness — like the color above — persists when the action button is - // hovered (Change 2). - &.action-button-validation { - &, - &:hover, - &:active, - &.disabled { - border-inline-start-width: styles.$invalid-control-left-border; - padding-inline-start: calc( - #{awsui.$space-xs} - (#{styles.$invalid-control-left-border} - #{awsui.$border-width-field}) - ); - } - } } > .icon-left { 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 cb7f08acb03..cd1776c54c8 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import FormField from '../../../../../lib/components/form-field'; +import Input from '../../../../../lib/components/input'; import ControlGroup from '../../../../../lib/components/internal/components/control-group'; import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; import customCssProps from '../../../../../lib/components/internal/generated/custom-css-properties'; @@ -14,7 +15,7 @@ import { import ControlGroupWrapper from '../../../../../lib/components/test-utils/dom/internal/control-group'; import { DirectionProbe, FormFieldValidationProbe, GroupedValidationProbe, PositionProbe } from './common'; -import buttonStyles from '../../../../../lib/components/button/styles.selectors.js'; +import inputStyles from '../../../../../lib/components/input/styles.selectors.js'; import controlGroupStyles from '../../../../../lib/components/internal/components/control-group/styles.selectors.js'; import controlGroupTestStyles from '../../../../../lib/components/internal/components/control-group/test-classes/styles.selectors.js'; @@ -310,8 +311,7 @@ describe('Control group', () => { ); // The group (`.controls`) carries the state and the custom property. The - // `-invalid` / `-warning` marker classes stay on `.controls` only; the - // action button never carries them. + // button carries neither validation markers nor the border custom property. const group = getByRole('group'); expect(group).toHaveClass(invalidClass); expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); @@ -321,145 +321,58 @@ describe('Control group', () => { expect(actionButton).not.toHaveClass(warningClass); expect(actionButton).not.toHaveClass(controlGroupTestStyles['controls-invalid']); expect(actionButton).not.toHaveClass(controlGroupTestStyles['controls-warning']); - // The validation color reaches the button through the inherited custom - // property on its parent action slot, not as an inline style on the button - // element itself. expect(actionButton.style.getPropertyValue(borderColorProp)).toBe(''); }); - test('publishes the error color to the action slot so the controls↔button seam is colored', () => { - // The seam between the controls and the action button is the button's - // inline-start border. The group publishes the validation color to the - // action slot (the button's parent); the button's SCSS reads it on exactly - // that controls-facing edge. jsdom can't compute SCSS, so assert the - // published custom property on the slot (the testable surface). - const { container } = render( - - - - - - ); - - const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; - expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); - }); - - test('publishes the warning color to the action slot so the controls↔button seam is colored', () => { - const { container } = render( - - - + // The last/only control facing the action button draws the seam: it carries + // the `grouped-validation` class, which the shared mixin uses to apply the + // thick inline-end border. jsdom cannot compute the SCSS pixel width or the + // `:hover` persistence, so the testable surface is this gating class; the + // actual width and hover persistence are covered by the visual test. + const seamClass = inputStyles['grouped-validation']; + + function renderGroupedInput(validation: 'error' | 'warning' | 'neutral', withAction: boolean) { + const field = + validation === 'error' + ? { errorText: 'Required' } + : validation === 'warning' + ? { warningText: 'Careful' } + : { label: 'Field' }; + return render( + + + {}} /> ); + } - const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; - expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusWarning); + test('draws the seam on the control facing the action button in an error state', () => { + const { getByRole } = renderGroupedInput('error', true); + expect(getByRole('textbox')).toHaveClass(seamClass); }); - test('error takes precedence over warning for the controls↔button seam', () => { - const { container } = render( - - - - - - ); - - const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; - expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + test('draws the seam on the control facing the action button in a warning state', () => { + const { getByRole } = renderGroupedInput('warning', true); + expect(getByRole('textbox')).toHaveClass(seamClass); }); - test('does not color the action slot when the surrounding form field is neutral (no stray seam)', () => { - // With the property unset on the slot, the button's inline-start border - // `var()` falls back to its neutral variant color, so no stray colored seam - // appears when there is no validation state. - const { container } = render( - - - - - - ); - - const actionSlot = findControlGroup(container)!.findActionButton()!.getElement().parentElement!; - expect(actionSlot.style.getPropertyValue(borderColorProp)).toBe(''); + test('does not draw the seam on the control when the form field is neutral', () => { + const { getByRole } = renderGroupedInput('neutral', true); + expect(getByRole('textbox')).not.toHaveClass(seamClass); }); - // The seam gets BOTH the validation color (published via the custom property, - // asserted above) AND the extra-thick inline-start border width that the - // standalone invalid/warning controls render. The thickness is applied in the - // button's SCSS off the `action-button-validation` marker class, reusing the - // same `$invalid-control-left-border` token the standalone controls use. - // jsdom cannot compute the SCSS pixel width (nor the `:hover` persistence), so - // the testable surface is the marker class the width and hover rules gate on; - // the actual pixel width and hover persistence are covered by the visual test. - const actionValidationClass = buttonStyles['action-button-validation']; - - test('marks the action button for the thick validation seam in an error state', () => { - const { container } = render( - - - - - - ); - - const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); - expect(actionButton).toHaveClass(actionValidationClass); + test('does not draw the seam on the control when a validation state has no action button', () => { + const { getByRole } = renderGroupedInput('error', false); + expect(getByRole('textbox')).not.toHaveClass(seamClass); }); - test('marks the action button for the thick validation seam in a warning state', () => { - const { container } = render( - - - - - - ); - - const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); - expect(actionButton).toHaveClass(actionValidationClass); - }); - - test('does not mark the action button for the thick seam when the form field is neutral', () => { - const { container } = render( - - - - - - ); - - const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); - expect(actionButton).not.toHaveClass(actionValidationClass); - }); - - test('does not emit the thick-seam marker when a validation state has no action button', () => { - // The thickness, like the color, is strictly gated to the validation + - // action case: with no action button there is no element to mark. - const { container } = render( - - - - - - ); - - expect(container.querySelector(`.${actionValidationClass}`)).toBeNull(); - }); - - test('keeps the thick-seam marker off the controls so inner controls are unaffected', () => { - const { getByRole } = render( - - - - - - ); - - expect(getByRole('group')).not.toHaveClass(actionValidationClass); + test('keeps the seam class off the group element so only the control carries it', () => { + const { getByRole } = renderGroupedInput('error', true); + expect(getByRole('group')).not.toHaveClass(seamClass); }); test('masks the validation state for inner controls so seams stay neutral (error)', () => { diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group/grouped-control-styles.ts index 0f6f81f7e12..b85b909cb65 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group/grouped-control-styles.ts @@ -11,11 +11,21 @@ import { GroupedControlContextProps } from '../../context/control-group-context' */ export function getGroupedControlClassNames( styles: Record, - { position, direction, hasAction }: GroupedControlContextProps + { position, direction, hasAction, invalid, warning }: GroupedControlContextProps ): string[] { if (!position) { return []; } - const baseClassNames = [styles.grouped, styles[`grouped-${direction}-${position}`]]; - return hasAction ? [...baseClassNames, styles['grouped-with-action']] : baseClassNames; + const classNames = [styles.grouped, styles[`grouped-${direction}-${position}`]]; + if (hasAction) { + classNames.push(styles['grouped-with-action']); + // Mark the controls whose inline-end faces the action button so they draw + // the validation seam: the last/only control horizontally, every control + // vertically (the action spans the whole column). + const facesAction = direction === 'vertical' || position === 'last' || position === 'only'; + if ((invalid || warning) && facesAction) { + classNames.push(styles['grouped-validation']); + } + } + return classNames; } diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 3b5bbc4c574..5d86a860b07 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -14,7 +14,6 @@ import { colorTextStatusError, colorTextStatusWarning } from '../../generated/st import { flattenChildren } from '../../utils/flatten-children'; import { ControlGroupProps } from './interfaces'; -import buttonStyles from '../../../button/styles.css.js'; import styles from './styles.css.js'; import testUtilStyles from './test-classes/styles.css.js'; @@ -37,11 +36,9 @@ export default function ControlGroup({ const isWarning = !!formFieldContext.warning && !isInvalid; // Publish the validation color to every grouped control via an inherited custom - // property set on the `.controls` box. Each control reads it on exactly its - // outward-facing border edges (see the shared `grouped-control` mixin), so the - // inner seams between controls stay neutral. The action button lives in a - // sibling `.action-slot`, so it never inherits this property and is excluded. - // Error wins over warning; when neutral the property is omitted entirely. + // property on the `.controls` box. Each control reads it on its outward-facing + // border edges (shared `grouped-control` mixin), so inner seams stay neutral. + // The control(s) facing the action button also draw the thick seam against it. const groupedControlBorderColor = isInvalid ? colorTextStatusError : isWarning ? colorTextStatusWarning : undefined; const controlsStyle = groupedControlBorderColor ? ({ [customCssProps.groupedControlBorderColor]: groupedControlBorderColor } as React.CSSProperties) @@ -78,14 +75,7 @@ export default function ControlGroup({ - {/* - Each grouped control paints the validation color on its own - outward-facing border edges (driven by the inherited custom - property above). Mask the form-field validation flags so inner - controls do not ALSO react to the form field directly (which - would color their inner seams); everything else (controlId, aria - wiring) passes through unchanged. - */} + {/* Mask the form-field flags so inner controls do not color their own seams. */} {child} @@ -102,31 +92,9 @@ export default function ControlGroup({ className={clsx(styles['group-layout'], styles[`group-layout-${direction}`], !inlineLabelText && rootClassnames)} > {controls} - {/* - Publish the validation color to the action slot so the action button - inherits `--awsui-grouped-control-border-color`. The action button's - `variant-control-group-action` style reads that property on exactly its - inline-start (controls-facing) edge, coloring the single boundary line - between the controls and the button. The property is only set here when - the group is in a validation state (`controlsStyle` is otherwise - undefined), so a neutral group falls back to the button's neutral variant - border and no stray colored seam appears. The button's other edges stay - neutral because the variant only reads the property on inline-start. - */} -
+
Date: Tue, 13 Oct 2026 16:54:10 +0200 Subject: [PATCH 03/40] fix: Render ControlGroup validation thick border on first control inline-start edge Move the extra-thick validation border from the last (button-facing) control's inline-end edge to the first control's inline-start edge, the group's outer leading edge. Gate the thick width on validation state alone (not the presence of the action button). Reuse the existing mechanism: the shared grouped-control mixin now sets border-inline-start-width to constants.$invalid-control-left-border and padding-inline-start to the named constants.$invalid-control-left-padding token (the same width/padding the standalone invalid controls use on their inline-start edge). getGroupedControlClassNames emits grouped-validation on the leading control (first/only horizontally, every control vertically) in a validation state. The old inline-end thick width + padding compensation are removed. Button stays neutral on all edges; color publishing, masked child context, and error-over-warning precedence are unchanged. Verification: - npm run quick-build: SUCCESS - jest -c jest.unit.config.js src/internal/components/control-group: 29 passed, 29 total (seam now asserted on first control; last carries none) - stylelint grouped-control.scss: exit 0 - eslint grouped-control-styles.ts, control-group.test.tsx, index.tsx, validation.page.tsx: exit 0 - compiled input/styles.scoped.css: border-inline-start-width uses --border-invalid-width; zero inline-end thick-width occurrences --- pages/control-group/validation.page.tsx | 2 +- .../__tests__/control-group.test.tsx | 35 ++++++++++++++----- .../control-group/grouped-control-styles.ts | 14 ++++---- .../components/control-group/index.tsx | 2 +- .../styles/forms/grouped-control.scss | 15 +++----- 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 6bb19b2aba9..287bd4d12af 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -26,7 +26,7 @@ export default function ControlGroupValidation() { return ( } i18n={{}} screenshotArea={{}} 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 cd1776c54c8..58b4d75bb22 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -324,11 +324,9 @@ describe('Control group', () => { expect(actionButton.style.getPropertyValue(borderColorProp)).toBe(''); }); - // The last/only control facing the action button draws the seam: it carries - // the `grouped-validation` class, which the shared mixin uses to apply the - // thick inline-end border. jsdom cannot compute the SCSS pixel width or the - // `:hover` persistence, so the testable surface is this gating class; the - // actual width and hover persistence are covered by the visual test. + // The first/only control on the group's leading edge draws the seam via the + // `grouped-validation` class. jsdom cannot compute pixel width or `:hover`; + // those are covered by the visual test. const seamClass = inputStyles['grouped-validation']; function renderGroupedInput(validation: 'error' | 'warning' | 'neutral', withAction: boolean) { @@ -350,12 +348,24 @@ describe('Control group', () => { ); } - test('draws the seam on the control facing the action button in an error state', () => { + function renderGroupedInputs(validation: 'error' | 'warning' | 'neutral') { + const field = validation === 'error' ? { errorText: 'Required' } : { warningText: 'Careful' }; + return render( + + + {}} /> + {}} /> + + + ); + } + + test('draws the seam on the leading control in an error state', () => { const { getByRole } = renderGroupedInput('error', true); expect(getByRole('textbox')).toHaveClass(seamClass); }); - test('draws the seam on the control facing the action button in a warning state', () => { + test('draws the seam on the leading control in a warning state', () => { const { getByRole } = renderGroupedInput('warning', true); expect(getByRole('textbox')).toHaveClass(seamClass); }); @@ -365,9 +375,16 @@ describe('Control group', () => { expect(getByRole('textbox')).not.toHaveClass(seamClass); }); - test('does not draw the seam on the control when a validation state has no action button', () => { + test('draws the seam on the leading control even without an action button', () => { const { getByRole } = renderGroupedInput('error', false); - expect(getByRole('textbox')).not.toHaveClass(seamClass); + expect(getByRole('textbox')).toHaveClass(seamClass); + }); + + test('draws the seam only on the first control, not the last, in a horizontal group', () => { + const { getAllByRole } = renderGroupedInputs('error'); + const [first, last] = getAllByRole('textbox'); + expect(first).toHaveClass(seamClass); + expect(last).not.toHaveClass(seamClass); }); test('keeps the seam class off the group element so only the control carries it', () => { diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group/grouped-control-styles.ts index b85b909cb65..c4b9f80a0cb 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group/grouped-control-styles.ts @@ -19,13 +19,13 @@ export function getGroupedControlClassNames( const classNames = [styles.grouped, styles[`grouped-${direction}-${position}`]]; if (hasAction) { classNames.push(styles['grouped-with-action']); - // Mark the controls whose inline-end faces the action button so they draw - // the validation seam: the last/only control horizontally, every control - // vertically (the action spans the whole column). - const facesAction = direction === 'vertical' || position === 'last' || position === 'only'; - if ((invalid || warning) && facesAction) { - classNames.push(styles['grouped-validation']); - } + } + // Mark the controls on the group's leading (inline-start) edge so they draw + // the thick validation seam: the first/only control horizontally, every + // control vertically (the leading edge spans the whole column). + const isLeading = direction === 'vertical' || position === 'first' || position === 'only'; + if ((invalid || warning) && isLeading) { + classNames.push(styles['grouped-validation']); } return classNames; } diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 5d86a860b07..0427ac5229d 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -38,7 +38,7 @@ export default function ControlGroup({ // Publish the validation color to every grouped control via an inherited custom // property on the `.controls` box. Each control reads it on its outward-facing // border edges (shared `grouped-control` mixin), so inner seams stay neutral. - // The control(s) facing the action button also draw the thick seam against it. + // The leading control also draws the thick seam on the group's inline-start edge. const groupedControlBorderColor = isInvalid ? colorTextStatusError : isWarning ? colorTextStatusWarning : undefined; const controlsStyle = groupedControlBorderColor ? ({ [customCssProps.groupedControlBorderColor]: groupedControlBorderColor } as React.CSSProperties) diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index 88de43ec699..7eba3f1ed7d 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -118,16 +118,11 @@ $grouped-control-border-radius: constants.$control-border-radius; border-end-end-radius: 0; } - // Thick validation seam on the button-facing (inline-end) edge, reusing the - // same width the standalone invalid/warning controls use. Only the controls - // marked `grouped-validation` (last/only horizontally, all vertically) get it. - // box-sizing: border-box grows the border inward, so the row does not shift; - // the padding reduction re-centers content, mirroring the standalone control. + // Thick validation seam on the group's leading (inline-start) edge, reusing + // the standalone invalid control's width/padding. box-sizing: border-box + // grows the border inward, so the row does not shift. &#{&}-validation { - border-inline-end-width: constants.$invalid-control-left-border; - padding-inline-end: calc( - #{constants.$control-padding-horizontal} - - (#{constants.$invalid-control-left-border} - #{awsui.$border-width-field}) - ); + border-inline-start-width: constants.$invalid-control-left-border; + padding-inline-start: constants.$invalid-control-left-padding; } } From 6f70acadd51a0de69af88763b332eed1d57db052 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 13 Oct 2026 17:09:14 +0200 Subject: [PATCH 04/40] refactor: Convert ControlGroup validation dev page to a permutations page Replace the SimplePage-based validation page and its runtime DirectionSettings toggle with a PermutationsPage that renders a static createPermutations matrix: error/warning x direction (horizontal, vertical) x with/without action button. Each permutation renders ControlGroup inside a FormField supplying the matching errorText/warningText so validation propagates through form-field context. Dev-page-only change; ControlGroup behavior and styling are untouched. Verification: - eslint pages/control-group/validation.page.tsx: exit 0 (clean) - npm run quick-build: exit 0 (TS + SCSS compile, no errors) - jest control-group.test.tsx: 29 passed, 29 total (no regression) - stylelint skipped (no SCSS changed) --- pages/control-group/validation.page.tsx | 127 ++++++++---------------- 1 file changed, 42 insertions(+), 85 deletions(-) diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 287bd4d12af..a01f6651065 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -2,95 +2,52 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { SpaceBetween } from '~components'; import FormField from '~components/form-field'; -import Input from '~components/input'; -import ControlGroup from '~components/internal/components/control-group'; -import Select from '~components/select'; - -import { SimplePage } from '../app/templates'; -import { input, noop, operators, select, useControlGroupDirection } from './common'; -import { DirectionSettings } from './common'; - -// A three-control group makes the middle control's neutral seam edges visible: -// only its first and last siblings paint an outward-facing group edge. -const threeControls = [ - , - - + {}} /> + {}} /> ); - const group = getByRole('group'); - expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); + for (const control of getAllByRole('textbox')) { + expect(control).toHaveClass(controlErrorClass); + expect(control).not.toHaveClass(controlWarningClass); + } + }); + + test('marks a non-leading control with the error state class but not the thick seam class', () => { + const { getAllByRole } = render( + + + {}} /> + {}} /> + + + ); + + const [, last] = getAllByRole('textbox'); + expect(last).toHaveClass(controlErrorClass); + expect(last).not.toHaveClass(inputStyles['grouped-validation']); }); test('applies the warning class to the group when the surrounding form field is in a warning state', () => { @@ -242,18 +257,20 @@ describe('Control group', () => { expect(group).not.toHaveClass(invalidClass); }); - test('publishes the warning color through the grouped-control border custom property', () => { - const { getByRole } = render( + test('marks each grouped control with the warning state class in a warning state', () => { + const { getAllByRole } = render( - - + {}} /> + {}} /> ); - const group = getByRole('group'); - expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusWarning); + for (const control of getAllByRole('textbox')) { + expect(control).toHaveClass(controlWarningClass); + expect(control).not.toHaveClass(controlErrorClass); + } }); test('applies no validation class when the surrounding form field is neutral', () => { @@ -270,19 +287,20 @@ describe('Control group', () => { expect(group).not.toHaveClass(warningClass); }); - test('does not set the border custom property when the surrounding form field is neutral', () => { - // With the property unset, each grouped control's `var()` falls back to its - // own neutral border color, so the group looks unchanged. + test('applies no validation state class to a control when the form field is neutral', () => { + // Without a state class, each grouped control keeps its own neutral border + // color, so the group looks unchanged. const { getByRole } = render( - + {}} /> ); - const group = getByRole('group'); - expect(group.style.getPropertyValue(borderColorProp)).toBe(''); + const control = getByRole('textbox'); + expect(control).not.toHaveClass(controlErrorClass); + expect(control).not.toHaveClass(controlWarningClass); }); test('error takes precedence over warning when the form field has both', () => { @@ -297,31 +315,60 @@ describe('Control group', () => { const group = getByRole('group'); expect(group).toHaveClass(invalidClass); expect(group).not.toHaveClass(warningClass); - // Precedence also holds for the published color. - expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); }); - test('does not apply the group validation class to the action button element', () => { - const { container, getByRole } = render( - - - + test('error takes precedence over warning on the control state class', () => { + const { getByRole } = render( + + + {}} /> ); - // The group (`.controls`) carries the state and the custom property. The - // button carries neither validation markers nor the border custom property. - const group = getByRole('group'); - expect(group).toHaveClass(invalidClass); - expect(group.style.getPropertyValue(borderColorProp)).toBe(colorTextStatusError); - - const actionButton = findControlGroup(container)!.findActionButton()!.getElement(); - expect(actionButton).not.toHaveClass(invalidClass); - expect(actionButton).not.toHaveClass(warningClass); - expect(actionButton).not.toHaveClass(controlGroupTestStyles['controls-invalid']); - expect(actionButton).not.toHaveClass(controlGroupTestStyles['controls-warning']); - expect(actionButton.style.getPropertyValue(borderColorProp)).toBe(''); + const control = getByRole('textbox'); + expect(control).toHaveClass(inputStyles['grouped-in-error']); + expect(control).not.toHaveClass(inputStyles['grouped-in-warning']); + }); + + test('applies the matching action-button validation class only in a validation state', () => { + const errorActionClass = buttonStyles['variant-control-group-action-error']; + const warningActionClass = buttonStyles['variant-control-group-action-warning']; + + function renderActionButton(field: Record) { + const { container } = render( + + + + + + ); + return findControlGroup(container)!.findActionButton()!.getElement(); + } + + // Error: button gets the error variant class only. + const errorButton = renderActionButton({ errorText: 'Required' }); + expect(errorButton).toHaveClass(errorActionClass); + expect(errorButton).not.toHaveClass(warningActionClass); + + // Warning: button gets the warning variant class only. + const warningButton = renderActionButton({ warningText: 'Careful' }); + expect(warningButton).toHaveClass(warningActionClass); + expect(warningButton).not.toHaveClass(errorActionClass); + + // Error precedence: both texts -> error variant class, not warning. + const bothButton = renderActionButton({ errorText: 'Required', warningText: 'Careful' }); + expect(bothButton).toHaveClass(errorActionClass); + expect(bothButton).not.toHaveClass(warningActionClass); + + // Neutral: button carries no state variant class, nor the group's classes. + const neutralButton = renderActionButton({ label: 'Field' }); + expect(neutralButton).not.toHaveClass(errorActionClass); + expect(neutralButton).not.toHaveClass(warningActionClass); + expect(neutralButton).not.toHaveClass(invalidClass); + expect(neutralButton).not.toHaveClass(warningClass); + expect(neutralButton).not.toHaveClass(controlGroupTestStyles['controls-invalid']); + expect(neutralButton).not.toHaveClass(controlGroupTestStyles['controls-warning']); }); // The first/only control on the group's leading edge draws the seam via the diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group/grouped-control-styles.ts index cb0164af9cd..974b9ae9fa7 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group/grouped-control-styles.ts @@ -21,9 +21,10 @@ export function getGroupedControlClassNames( classNames.push(styles['grouped-with-action']); } if (invalid || warning) { - // Every control in the group gets the focus marker so each re-asserts its - // own outward validation color (and the invalid ring) on focus. - classNames.push(styles['grouped-in-validation']); + // State class drives the outward validation color (and invalid ring) in SCSS; + // error wins over warning. Every control gets it so each re-asserts its own + // outward edges on focus. + classNames.push(invalid ? styles['grouped-in-error'] : styles['grouped-in-warning']); // Only the leading (inline-start) controls draw the thick validation seam: // the first/only control horizontally, every control vertically. const isLeading = direction === 'vertical' || position === 'first' || position === 'only'; diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index aba3b2673f0..2b9d88d41cb 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -9,8 +9,6 @@ import { InternalButton } from '../../../button/internal'; import { getBaseProps } from '../../base-component'; import { GroupedControlContext, GroupedControlPosition } from '../../context/control-group-context'; import { FormFieldContext, useFormFieldContext } from '../../context/form-field-context'; -import customCssProps from '../../generated/custom-css-properties'; -import { colorTextStatusError, colorTextStatusWarning } from '../../generated/styles/tokens'; import { flattenChildren } from '../../utils/flatten-children'; import { ControlGroupProps } from './interfaces'; @@ -34,11 +32,6 @@ export default function ControlGroup({ const isInvalid = !!formFieldContext.invalid; const isWarning = !!formFieldContext.warning && !isInvalid; - const groupedControlBorderColor = isInvalid ? colorTextStatusError : isWarning ? colorTextStatusWarning : undefined; - const controlsStyle = groupedControlBorderColor - ? ({ [customCssProps.groupedControlBorderColor]: groupedControlBorderColor } as React.CSSProperties) - : undefined; - const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; @@ -48,7 +41,6 @@ export default function ControlGroup({
{controls} @@ -92,7 +83,8 @@ export default function ControlGroup({ Date: Wed, 14 Oct 2026 09:31:59 +0200 Subject: [PATCH 14/40] chore: Increase widget export size limit for ControlGroup validation The ControlGroup validation feature adds ~27 KB of inlined CSS (grouped control validation border rules + button action variants) to the widget export bundle, pushing it from ~1445 kB to ~1473 kB. Raise the limit from 1450 kB to 1485 kB to accommodate the legitimate growth with headroom. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b81ca3cde11..0645ef3301c 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "1450 kB", + "limit": "1485 kB", "ignore": "react-dom" } ], From e98ab5b4652a249e4262128587a879497b78b6de Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 09:40:00 +0200 Subject: [PATCH 15/40] refactor: Use a single validation field on ControlGroup context Collapse the two booleans (invalid, warning) on GroupedControlContext into one field validation: 'error' | 'warning' | null. Verification (read-only; build/test/lint NOT run per the user's watch-job constraint): 1. The context now exposes a single validation?: GroupedControlValidation | null field (GroupedControlValidation = 'error' | 'warning'); the invalid / warning members are gone. createContext default and ResetGroupedControlContext both provide validation: null. 2. Every grouped-context consumer reads validation and emits the SAME classes: getGroupedControlClassNames pushes grouped-in-error for 'error', grouped-in-warning for 'warning', and the grouped-validation leading seam when validation is non-null and the control is leading (unchanged isLeading rule). GroupedValidationProbe reads validation. 3. Error-over-warning precedence preserved: producer sets validation = isInvalid ? 'error' : isWarning ? 'warning' : null, and isWarning stays warning && !isInvalid. 4. No SCSS/behavioral change: no .scss edited; emitted class names identical. The action-button variant classes and controls-invalid/-warning still wire off the local isInvalid/isWarning (not the context) and are unchanged. Children stay masked (FormFieldContext invalid:false/warning:false). 5. Former grouped-context invalid/warning consumers, each converted: - src/internal/context/control-group-context.tsx (type, default, reset) -> validation. - src/internal/components/control-group/index.tsx (provider value) -> validation. - src/internal/components/control-group/grouped-control-styles.ts (destructure + branch) -> validation. - src/internal/components/control-group/__tests__/common.tsx (GroupedValidationProbe) -> validation. Opaque forwarders (input, select/multiselect/autosuggest, segmented-control, button-trigger, select trigger) never name the fields; unchanged. The one context-shape test assertion now expects 'validation:error'; all class-name assertions unchanged. form-field-context invalid/warning left intact. --- .../control-group/__tests__/common.tsx | 4 +-- .../__tests__/control-group.test.tsx | 2 +- .../control-group/grouped-control-styles.ts | 11 ++++---- .../components/control-group/index.tsx | 7 +++++- .../context/control-group-context.tsx | 25 ++++++++----------- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/internal/components/control-group/__tests__/common.tsx b/src/internal/components/control-group/__tests__/common.tsx index 3cacdec996e..0e2be69b9fe 100644 --- a/src/internal/components/control-group/__tests__/common.tsx +++ b/src/internal/components/control-group/__tests__/common.tsx @@ -30,6 +30,6 @@ export function FormFieldValidationProbe({ testId = 'form-field-probe' }: { test * context (the real state the group owns). */ export function GroupedValidationProbe({ testId = 'grouped-probe' }: { testId?: string }) { - const { invalid, warning } = useGroupedControlContext(); - return {`invalid:${!!invalid} warning:${!!warning}`}; + const { validation } = useGroupedControlContext(); + return {`validation:${validation ?? 'null'}`}; } 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 8296bdfeeed..a0a41088a20 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -474,7 +474,7 @@ describe('Control group', () => { ); - expect(getByTestId('grouped-probe')).toHaveTextContent('invalid:true warning:false'); + expect(getByTestId('grouped-probe')).toHaveTextContent('validation:error'); }); }); }); diff --git a/src/internal/components/control-group/grouped-control-styles.ts b/src/internal/components/control-group/grouped-control-styles.ts index 974b9ae9fa7..08a3a999d06 100644 --- a/src/internal/components/control-group/grouped-control-styles.ts +++ b/src/internal/components/control-group/grouped-control-styles.ts @@ -11,7 +11,7 @@ import { GroupedControlContextProps } from '../../context/control-group-context' */ export function getGroupedControlClassNames( styles: Record, - { position, direction, hasAction, invalid, warning }: GroupedControlContextProps + { position, direction, hasAction, validation }: GroupedControlContextProps ): string[] { if (!position) { return []; @@ -20,11 +20,10 @@ export function getGroupedControlClassNames( if (hasAction) { classNames.push(styles['grouped-with-action']); } - if (invalid || warning) { - // State class drives the outward validation color (and invalid ring) in SCSS; - // error wins over warning. Every control gets it so each re-asserts its own - // outward edges on focus. - classNames.push(invalid ? styles['grouped-in-error'] : styles['grouped-in-warning']); + if (validation) { + // State class drives the outward validation color (and invalid ring) in SCSS. + // Every control gets it so each re-asserts its own outward edges on focus. + classNames.push(validation === 'error' ? styles['grouped-in-error'] : styles['grouped-in-warning']); // Only the leading (inline-start) controls draw the thick validation seam: // the first/only control horizontally, every control vertically. const isLeading = direction === 'vertical' || position === 'first' || position === 'only'; diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 2b9d88d41cb..0d6b71eaac8 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -60,7 +60,12 @@ export default function ControlGroup({ className={clsx(styles.control, styles[`control-${position}-${direction}`])} > {/* Mask the form-field flags so inner controls do not color their own seams. */} diff --git a/src/internal/context/control-group-context.tsx b/src/internal/context/control-group-context.tsx index c760c7eb852..089ee2eb095 100644 --- a/src/internal/context/control-group-context.tsx +++ b/src/internal/context/control-group-context.tsx @@ -6,6 +6,8 @@ export type GroupedControlPosition = 'first' | 'middle' | 'last' | 'only'; export type GroupedControlDirection = 'horizontal' | 'vertical'; +export type GroupedControlValidation = 'error' | 'warning'; + export interface GroupedControlContextProps { /** * The control's position within a control group, @@ -23,23 +25,18 @@ export interface GroupedControlContextProps { */ hasAction?: boolean; /** - * Whether the surrounding control group is in an error state. The group owns - * the validation border; individual controls do not independently paint it. - */ - invalid?: boolean; - /** - * Whether the surrounding control group is in a warning state. The group owns - * the validation border; individual controls do not independently paint it. + * The surrounding control group's validation state, or `null` for none. The + * group owns the validation border; individual controls do not independently + * paint it. */ - warning?: boolean; + validation?: GroupedControlValidation | null; } export const GroupedControlContext = createContext({ position: null, direction: 'horizontal', hasAction: false, - invalid: false, - warning: false, + validation: null, }); export function useGroupedControlContext() { @@ -53,14 +50,14 @@ export function useGroupedControlContext() { * content) should wrap that content with this provider. Otherwise a nested * control rendered inside such a slot would inherit the surrounding group's * `position` and incorrectly pick up the grouped-control styling - * where adjacent borders lose their border radii. The group's validation - * state (`invalid`/`warning`) is also cleared so nested custom-slot controls - * never inherit the group's validation styling. + * where adjacent borders lose their border radii. The group's `validation` + * state is also cleared so nested custom-slot controls never inherit the + * group's validation styling. */ export function ResetGroupedControlContext({ children }: { children: React.ReactNode }) { const { direction } = useGroupedControlContext(); return ( - + {children} ); From 767925b1ca4a0bfcd6c7c47c1f569ce59634acd3 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 11:05:39 +0200 Subject: [PATCH 16/40] refactor: Dedupe ControlGroup validation ring and state styles Pure byte-reduction dedup of the ControlGroup validation CSS in grouped-control.scss. Behavior-identical; confirmed by reading the resulting SCSS source: 1. The invalid focus ring is now emitted once as a pointer-focus rule (&.grouped-in-error:focus, &.grouped-in-warning:focus) plus one focus-visible.when-visible block, both keyed on the state markers and placed in the mixin body AFTER every per-position color invocation (after &-with-action, before &#{&}-validation). The hoisted selector .grouped.grouped-in-error:focus has the same 0-3-1 specificity as the old .grouped-horizontal-first.grouped-in-error:focus on the same node, so source order decides and the ring still layers on top on :focus. 2. The per-position edge COLOR is preserved for every position/axis/state: base + hover + focus are grouped in grouped-control-outer-border-color, and the keyboard-focus (when-visible) edge-color re-assertion stays per-position in grouped-control-validation-focus. 3. Error-over-warning precedence (mutually-exclusive marker classes), the thick leading border (&#{&}-validation, untouched), the action-button seam (unchanged), and hover + keyboard-focus color persistence are all intact. 4. No runtime custom property is reintroduced; the dedup adds none. 5. Only grouped-control.scss changed and the emitted class names are unchanged, so no TSX/test/dev-page change is needed. --- .../styles/forms/grouped-control.scss | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index 7d4f7ed164b..a38e29304d4 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -43,46 +43,34 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // passes its segment-wrapper border color. @mixin grouped-control-outer-border-color($edges, $neutral-border-color: awsui.$color-border-input-default) { @include grouped-control-edge-color($edges, $neutral-border-color); - &#{$grouped-control-error-marker} { - @include grouped-control-edge-color($edges, awsui.$color-text-status-error); - } - &#{$grouped-control-warning-marker} { - @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); - } -} - -// In a validation state, keep the outward validation color on focus (so the -// control's neutral `form-focus-element` border swap does not win) and layer the -// invalid focus ring instead of swapping the border. Mirrors standalone invalid -// controls. `&` is the position class; the control also carries the state marker. -@mixin grouped-control-validation-focus($edges, $neutral-border-color) { - // Re-assert the outward color on hover and focus so the control's own neutral - // hover/focus border-color does not win. Ring stays focus-only. + // Assert the outward color once per state across base/hover/focus so the + // control's own neutral hover/focus border-color does not win. + &#{$grouped-control-error-marker}, &#{$grouped-control-error-marker}:hover, &#{$grouped-control-error-marker}:focus { @include grouped-control-edge-color($edges, awsui.$color-text-status-error); } + &#{$grouped-control-warning-marker}, &#{$grouped-control-warning-marker}:hover, &#{$grouped-control-warning-marker}:focus { @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); } - &#{$grouped-control-error-marker}:focus, - &#{$grouped-control-warning-marker}:focus { - box-shadow: foundation.$box-shadow-focused-light-invalid; - } - // The trigger's neutral keyboard focus runs through focus-visible.when-visible, - // whose body[data-awsui-focus-visible] descendant outranks a plain :focus. Match - // that scope so the validation color and invalid ring win on keyboard focus too. +} + +// Keyboard focus runs through focus-visible.when-visible, whose +// body[data-awsui-focus-visible] descendant outranks a plain :focus. Re-assert +// the per-position outward validation color in that scope so it wins on keyboard +// focus too. The invalid ring is hoisted (position-independent) into the mixin +// body. `&` is the position class; the control also carries the state marker. +@mixin grouped-control-validation-focus($edges, $neutral-border-color) { &#{$grouped-control-error-marker} { @include focus-visible.when-visible { @include grouped-control-edge-color($edges, awsui.$color-text-status-error); - box-shadow: foundation.$box-shadow-focused-light-invalid; } } &#{$grouped-control-warning-marker} { @include focus-visible.when-visible { @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); - box-shadow: foundation.$box-shadow-focused-light-invalid; } } } @@ -137,11 +125,6 @@ $grouped-control-warning-marker: '.grouped-in-warning'; @include grouped-control-outer-border-color($edges, $neutral-border-color); @include grouped-control-validation-focus($edges, $neutral-border-color); } - &-only { - $edges: (inline-start, inline-end, block-start, block-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); - } } &-vertical { @@ -167,11 +150,15 @@ $grouped-control-warning-marker: '.grouped-in-warning'; @include grouped-control-outer-border-color($edges, $neutral-border-color); @include grouped-control-validation-focus($edges, $neutral-border-color); } - &-only { - $edges: (inline-start, inline-end, block-start, block-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); - } + } + + // A sole control has every edge outward; axis is irrelevant, so both + // -only positions share one rule (identical edge set, base radius kept). + &-horizontal-only, + &-vertical-only { + $edges: (inline-start, inline-end, block-start, block-end); + @include grouped-control-outer-border-color($edges, $neutral-border-color); + @include grouped-control-validation-focus($edges, $neutral-border-color); } &-with-action { @@ -179,6 +166,19 @@ $grouped-control-warning-marker: '.grouped-in-warning'; border-end-end-radius: 0; } + // Invalid focus ring, position-independent. Keyed on the state markers and + // emitted after the per-position color rules so it layers on top on :focus. + &#{$grouped-control-error-marker}:focus, + &#{$grouped-control-warning-marker}:focus { + box-shadow: foundation.$box-shadow-focused-light-invalid; + } + &#{$grouped-control-error-marker}, + &#{$grouped-control-warning-marker} { + @include focus-visible.when-visible { + box-shadow: foundation.$box-shadow-focused-light-invalid; + } + } + // Thick validation seam on the group's leading (inline-start) edge, reusing // the standalone invalid control's width/padding. box-sizing: border-box // grows the border inward, so the row does not shift. From 809acad6ff54b1489687b6317718e166ee4f0b48 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 11:10:10 +0200 Subject: [PATCH 17/40] chore: Lower widget export size limit after SCSS dedup The grouped-control validation ring/state dedup removes ~7-8 KB of inlined CSS, so reduce the limit from 1485 kB to 1470 kB. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0645ef3301c..48dece8de52 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ { "path": "lib/components/internal/widget-exports.js", "brotli": false, - "limit": "1485 kB", + "limit": "1470 kB", "ignore": "react-dom" } ], From 9f17058ead94192f0259846c1120fcbb809382de Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 12:07:45 +0200 Subject: [PATCH 18/40] fix: Keep validation border color on grouped controls and action button on keyboard focus On keyboard focus the neutral form-focus-element paints all four border sides with the focus-blue color. The grouped-control re-assertion only covered outward edges, so inner seams went blue; the action button only re-asserted its leading seam, so three of its four edges went blue. Fix A (grouped-control.scss): grouped-control-validation-focus now colors all four sides (local $all list) in the when-visible scope instead of just the outward $edges. Selector stays (1,5,1) and is emitted after the trigger neutral (1,4,1), so it wins every edge; the mouse-state grouped-control-outer-border-color path still uses $edges only, so resting/ hover/mouse-focus seams stay neutral grey. The hoisted invalid ring and thick leading seam width are untouched and layer on top. Fix B (button/styles.scss): the error/warning action-button variants now re-assert border-block-color + border-inline-color (all four edges) instead of only border-inline-start-color. Selector gains one class over the all- blue form-focus-element rule -> (1,5,1) > (1,4,1), emitted later, so it wins all four edges; the invalid ring box-shadow is kept. No TSX/test/dev-page change: emitted class names are unchanged. --- src/button/styles.scss | 6 ++++-- src/internal/styles/forms/grouped-control.scss | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index c56e1fbba0f..51d430a5e1d 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -164,11 +164,13 @@ $tokens: style-api.resolve($props, carrier); // In a group validation state, keep the leading seam's validation color // and show the invalid ring instead of letting the focus border swap win. &.variant-control-group-action-error { - border-inline-start-color: awsui.$color-text-status-error; + border-block-color: awsui.$color-text-status-error; + border-inline-color: awsui.$color-text-status-error; box-shadow: foundation.$box-shadow-focused-light-invalid; } &.variant-control-group-action-warning { - border-inline-start-color: awsui.$color-text-status-warning; + border-block-color: awsui.$color-text-status-warning; + border-inline-color: awsui.$color-text-status-warning; box-shadow: foundation.$box-shadow-focused-light-invalid; } } diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index a38e29304d4..a23c6b3f705 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -63,14 +63,17 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // focus too. The invalid ring is hoisted (position-independent) into the mixin // body. `&` is the position class; the control also carries the state marker. @mixin grouped-control-validation-focus($edges, $neutral-border-color) { + // On keyboard focus the neutral form-focus-element paints all four sides, so + // re-assert the validation color on all four (seams included) to beat it. + $all: (inline-start, inline-end, block-start, block-end); &#{$grouped-control-error-marker} { @include focus-visible.when-visible { - @include grouped-control-edge-color($edges, awsui.$color-text-status-error); + @include grouped-control-edge-color($all, awsui.$color-text-status-error); } } &#{$grouped-control-warning-marker} { @include focus-visible.when-visible { - @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); + @include grouped-control-edge-color($all, awsui.$color-text-status-warning); } } } From 6a1a0662321d9d2cb1e50a5cd383e118f7a83317 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 12:19:28 +0200 Subject: [PATCH 19/40] Revert "fix: Keep validation border color on grouped controls and action button on keyboard focus" This reverts commit 9f17058ead94192f0259846c1120fcbb809382de. --- src/button/styles.scss | 6 ++---- src/internal/styles/forms/grouped-control.scss | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index 51d430a5e1d..c56e1fbba0f 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -164,13 +164,11 @@ $tokens: style-api.resolve($props, carrier); // In a group validation state, keep the leading seam's validation color // and show the invalid ring instead of letting the focus border swap win. &.variant-control-group-action-error { - border-block-color: awsui.$color-text-status-error; - border-inline-color: awsui.$color-text-status-error; + border-inline-start-color: awsui.$color-text-status-error; box-shadow: foundation.$box-shadow-focused-light-invalid; } &.variant-control-group-action-warning { - border-block-color: awsui.$color-text-status-warning; - border-inline-color: awsui.$color-text-status-warning; + border-inline-start-color: awsui.$color-text-status-warning; box-shadow: foundation.$box-shadow-focused-light-invalid; } } diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index a23c6b3f705..a38e29304d4 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -63,17 +63,14 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // focus too. The invalid ring is hoisted (position-independent) into the mixin // body. `&` is the position class; the control also carries the state marker. @mixin grouped-control-validation-focus($edges, $neutral-border-color) { - // On keyboard focus the neutral form-focus-element paints all four sides, so - // re-assert the validation color on all four (seams included) to beat it. - $all: (inline-start, inline-end, block-start, block-end); &#{$grouped-control-error-marker} { @include focus-visible.when-visible { - @include grouped-control-edge-color($all, awsui.$color-text-status-error); + @include grouped-control-edge-color($edges, awsui.$color-text-status-error); } } &#{$grouped-control-warning-marker} { @include focus-visible.when-visible { - @include grouped-control-edge-color($all, awsui.$color-text-status-warning); + @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); } } } From 297adb6134ec2528ade5e7612d8d27387633a5bf Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 12:42:16 +0200 Subject: [PATCH 20/40] fix: Keep standalone select and multiselect validation border on keyboard focus A standalone (ungrouped) invalid/warning Select or Multiselect lost its validation border color on keyboard focus (focus-visible): the neutral keyboard-focus rule, wrapped in body[data-awsui-focus-visible] and ending in :focus (specificity (1,4,1)), out-specified the plain validation re-assertion (&.invalid:focus, (1,4,0)), so the border turned blue while the blue invalid ring still painted. Re-assert the validation border color inside a focus-visible.when-visible scope in button-trigger/styles.scss. The new rule compiles to body[data-awsui-focus-visible] .button-trigger:not(.in-filtering-token).invalid:focus = (1,5,1), beating the neutral (1,4,1) and emitted later, so the validation color wins on keyboard focus with the blue ring still layered around it. Behavior preserved: mouse focus and resting unchanged (new rule only matches under body[data-awsui-focus-visible]); non-validation keyboard focus still shows the neutral blue border; the thick invalid inline-start width survives (only border-color re-asserted). The shared form-focus-element mixin and the ControlGroup grouped-* path are untouched; input/textarea unaffected. Adds pages/select/validation-focus.page.tsx and a declarative visual test (select.ts) that Tabs to activate the focus-visible ring. --- pages/select/validation-focus.page.tsx | 76 +++++++++++++++++++ .../components/button-trigger/styles.scss | 11 +++ test/definitions/visual/select.ts | 11 +++ 3 files changed, 98 insertions(+) create mode 100644 pages/select/validation-focus.page.tsx diff --git a/pages/select/validation-focus.page.tsx b/pages/select/validation-focus.page.tsx new file mode 100644 index 00000000000..fa09c50ff8b --- /dev/null +++ b/pages/select/validation-focus.page.tsx @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Multiselect from '~components/multiselect'; +import Select from '~components/select'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; + +const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, +]; + +function Section({ caption, children }: { caption: string; children: React.ReactNode }) { + return ( + + {caption} + {children} + + ); +} + +export default function SelectValidationFocusPage() { + return ( + +
+ {}} + /> +
+
+ {}} + /> +
+
+ {}} + /> +
+
+ ); +} diff --git a/src/internal/components/button-trigger/styles.scss b/src/internal/components/button-trigger/styles.scss index cf8d73436aa..292a191e0ab 100644 --- a/src/internal/components/button-trigger/styles.scss +++ b/src/internal/components/button-trigger/styles.scss @@ -146,6 +146,17 @@ $icon-offset: awsui.$space-xxxs; &.warning:focus { @include styles.form-warning-control(); } + + // Re-assert the validation border color at focus-visible specificity so it wins over + // the neutral keyboard-focus border. The blue invalid ring still layers around it. + @include focus-visible.when-visible { + &.invalid { + border-color: awsui.$color-text-status-error; + } + &.warning { + border-color: awsui.$color-text-status-warning; + } + } } &.custom-option { diff --git a/test/definitions/visual/select.ts b/test/definitions/visual/select.ts index e1b34605d65..e1e43eaa046 100644 --- a/test/definitions/visual/select.ts +++ b/test/definitions/visual/select.ts @@ -76,6 +76,17 @@ const suite: TestSuite = { path: 'select/trigger.permutations', screenshotType: 'permutations', }, + { + description: 'standalone validation - keyboard focus', + path: 'select/validation-focus', + screenshotType: 'screenshotArea', + pixelDiffTolerance: 2, + // Tab activates the keyboard focus ring (body[data-awsui-focus-visible]) + // on the first standalone invalid trigger, exercising the focus-visible path. + setup: async ({ page }) => { + await page.keys(['Tab']); + }, + }, { description: 'Long virtual list - navigate to last item', path: 'select/virtual-scroll', From a9f6c2bf8e67a66b454fa9dc6d0f7bb37af756fd Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 14:29:15 +0200 Subject: [PATCH 21/40] chore: Split standalone validation-focus dev pages for select and multiselect --- pages/multiselect/validation-focus.page.tsx | 56 +++++++++++++++++++++ pages/select/validation-focus.page.tsx | 28 ++--------- test/definitions/visual/multiselect.ts | 13 +++++ test/definitions/visual/select.ts | 6 ++- 4 files changed, 77 insertions(+), 26 deletions(-) create mode 100644 pages/multiselect/validation-focus.page.tsx diff --git a/pages/multiselect/validation-focus.page.tsx b/pages/multiselect/validation-focus.page.tsx new file mode 100644 index 00000000000..50711f0effa --- /dev/null +++ b/pages/multiselect/validation-focus.page.tsx @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Multiselect from '~components/multiselect'; +import SpaceBetween from '~components/space-between'; + +import { SimplePage } from '../app/templates'; + +const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, +]; + +function Section({ caption, children }: { caption: string; children: React.ReactNode }) { + return ( + + {caption} + {children} + + ); +} + +export default function MultiselectValidationFocusPage() { + return ( + + {/* Focus button is first in tab order: a test clicks it, then presses Tab to move + keyboard focus (focus-visible) onto the invalid Multiselect trigger below. */} + +
+ {}} + /> +
+
+ {}} + /> +
+
+ ); +} diff --git a/pages/select/validation-focus.page.tsx b/pages/select/validation-focus.page.tsx index fa09c50ff8b..71db9ff31b2 100644 --- a/pages/select/validation-focus.page.tsx +++ b/pages/select/validation-focus.page.tsx @@ -3,7 +3,6 @@ import React from 'react'; import Box from '~components/box'; -import Multiselect from '~components/multiselect'; import Select from '~components/select'; import SpaceBetween from '~components/space-between'; @@ -26,7 +25,10 @@ function Section({ caption, children }: { caption: string; children: React.React export default function SelectValidationFocusPage() { return ( - + + {/* Focus button is first in tab order: a test clicks it, then presses Tab to move + keyboard focus (focus-visible) onto the invalid Select trigger below. */} +
{ - await page.click('#focus-button'); + await page.click('#focus-target'); await page.keys(['Tab']); }, }, diff --git a/test/definitions/visual/select.ts b/test/definitions/visual/select.ts index 35635d62a4e..fa4e42f6759 100644 --- a/test/definitions/visual/select.ts +++ b/test/definitions/visual/select.ts @@ -81,11 +81,11 @@ const suite: TestSuite = { path: 'select/validation-focus', screenshotType: 'screenshotArea', pixelDiffTolerance: 2, - // Click the focus button, then Tab onto the first standalone invalid trigger. Tabbing + // Click the focus target, then Tab onto the first standalone invalid trigger. Tabbing // activates the keyboard focus ring (body[data-awsui-focus-visible]), exercising the // focus-visible path the fix targets. setup: async ({ page }) => { - await page.click('#focus-button'); + await page.click('#focus-target'); await page.keys(['Tab']); }, }, From 5872df675b95e1bff2f9acd688838ef32d65e7c5 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 14:36:36 +0200 Subject: [PATCH 24/40] chore: Move FocusTarget to settings slot so it is excluded from screenshots --- pages/multiselect/validation-focus.page.tsx | 7 +++++-- pages/select/validation-focus.page.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pages/multiselect/validation-focus.page.tsx b/pages/multiselect/validation-focus.page.tsx index 683f680c2da..1307d418141 100644 --- a/pages/multiselect/validation-focus.page.tsx +++ b/pages/multiselect/validation-focus.page.tsx @@ -26,8 +26,11 @@ function Section({ caption, children }: { caption: string; children: React.React export default function MultiselectValidationFocusPage() { return ( - - + } + >
- + } + >
{}} /> -
-
Date: Wed, 14 Oct 2026 14:40:33 +0200 Subject: [PATCH 27/40] Refine dev page --- pages/control-group/validation.page.tsx | 56 ++++--------------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 265a3eb5799..183128d727a 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -10,76 +10,38 @@ import createPermutations from '../utils/permutations'; import PermutationsView from '../utils/permutations-view'; import { input, multiselect, segmentedControl, select } from './common'; -// Validation propagates through FormField context, so errorText/warningText are -// carried on the permutation and applied by render, not passed to ControlGroup. type ValidationPermutation = ControlGroupProps & { errorText?: string; warningText?: string }; -const children = ( +const children = [ <> {input} {select} - -); -// Segmented control as the last/button-facing control. -const segmentedChildren = ( + , <> {input} {segmentedControl} - -); -// Multiselect as the last/button-facing control. -const multiselectChildren = ( - <> - {input} - {multiselect} - -); -const selectMultiselectChildren = ( + , <> {select} {multiselect} - -); + , +]; + const directions: ReadonlyArray = ['horizontal', 'vertical']; + const actionButtons: ReadonlyArray = [ undefined, { iconName: 'remove', ariaLabel: 'Remove' }, ]; -const actionButton: ControlGroupProps['actionButton'] = { iconName: 'remove', ariaLabel: 'Remove' }; -// Error and warning are mutually exclusive, so each lives in its own set. const permutations = createPermutations([ - { children: [children], errorText: ['This value is required'], direction: directions, actionButton: actionButtons }, + { children, errorText: ['This value is required'], direction: directions, actionButton: actionButtons }, { - children: [children], + children, warningText: ['Double-check this value'], direction: directions, actionButton: actionButtons, }, - { - children: [segmentedChildren], - errorText: ['This value is required'], - direction: directions, - actionButton: [actionButton], - }, - { - children: [segmentedChildren], - warningText: ['Double-check this value'], - direction: directions, - actionButton: [actionButton], - }, - { - children: [multiselectChildren, selectMultiselectChildren], - errorText: ['This value is required'], - direction: directions, - actionButton: [actionButton], - }, - { - children: [multiselectChildren, selectMultiselectChildren], - warningText: ['Double-check this value'], - direction: directions, - actionButton: [actionButton], - }, ]); export default function ControlGroupValidation() { From 65b9ff6a882afd9eb8036212ab18525c08a95401 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 15:51:17 +0200 Subject: [PATCH 28/40] Fix and simplify borders --- src/button/styles.scss | 52 ++++++++++++------------------------------ 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index 25372b4a564..8cd35f304e1 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -100,6 +100,9 @@ $tokens: style-api.resolve($props, carrier); @include styles.styles-reset; @include styles.text-wrapping; @include styles.font-button; + + $focus-border-color: var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused); + letter-spacing: awsui.$font-button-letter-spacing; border-start-start-radius: style-api.value($tokens, border-radius, awsui.$border-radius-button); border-start-end-radius: style-api.value($tokens, border-radius, awsui.$border-radius-button); @@ -128,10 +131,9 @@ $tokens: style-api.resolve($props, carrier); @include focus-visible.when-visible { $radius: var(#{custom-props.$styleFocusRingBorderRadius}, awsui.$border-radius-control-default-focus-ring); $width: var(#{custom-props.$styleFocusRingBorderWidth}, foundation.$box-shadow-focused-width); - $color: var(#{custom-props.$styleFocusRingBorderColor}, awsui.$color-border-item-focused); &:not(.variant-control-group-action) { - @include style-api.focus-highlight($radius: $radius, $width: $width, $color: $color); + @include style-api.focus-highlight($radius: $radius, $width: $width, $color: $focus-border-color); } &.variant-icon, @@ -141,7 +143,7 @@ $tokens: style-api.resolve($props, carrier); $gutter-block: awsui.$space-button-icon-focus-outline-gutter-vertical, $radius: $radius, $width: $width, - $color: $color + $color: $focus-border-color ); } @@ -152,25 +154,8 @@ $tokens: style-api.resolve($props, carrier); $gutter-inline: awsui.$space-button-inline-icon-focus-outline-gutter, $radius: $radius, $width: $width, - $color: $color - ); - } - - &.variant-control-group-action { - @include styles.form-focus-element( - $border-color: $color, - $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) + $color: $focus-border-color ); - // In a group validation state, keep the leading seam's validation color - // and show the invalid ring instead of letting the focus border swap win. - &.variant-control-group-action-error { - border-inline-start-color: awsui.$color-text-status-error; - box-shadow: foundation.$box-shadow-focused-light-invalid; - } - &.variant-control-group-action-warning { - border-inline-start-color: awsui.$color-text-status-warning; - box-shadow: foundation.$box-shadow-focused-light-invalid; - } } } @@ -231,24 +216,17 @@ $tokens: style-api.resolve($props, carrier); min-block-size: awsui.$size-vertical-input; &-error { - &, - &:hover, - &:active, - &.disabled, - &:focus, - &:focus-visible { - border-inline-start-color: awsui.$color-text-status-error; - } + border-inline-start-color: awsui.$color-text-status-error; } &-warning { - &, - &:hover, - &:active, - &.disabled, - &:focus, - &:focus-visible { - border-inline-start-color: awsui.$color-text-status-warning; - } + border-inline-start-color: awsui.$color-text-status-warning; + } + + @include focus-visible.when-visible { + @include styles.form-focus-element( + $border-color: $focus-border-color, + $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) + ); } } From 7e5e0d4bc6b31dced4a296af73aee9f58762e570 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 15:54:20 +0200 Subject: [PATCH 29/40] refactor: Remove unused neutral-color argument from grouped-control mixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $neutral-border-color parameter was dead flexibility: segmented-control was the only call site passing a non-default value (awsui.$color-border-segment-wrapper), a pure alias of awsui.$color-border-input-default that resolves pixel-identically across all shipped themes/modes. ControlGroup owns the border color and it does not need to be themeable per-control for now. Removed the parameter from grouped-control, grouped-control-outer-border-color, and grouped-control-validation-focus, hard-coding awsui.$color-border-input-default at the leaf neutral-edge usage. Updated segmented-control to call @include styles.grouped-control; with no argument. Verification (by reasoning, no build run — user has a watch job): 1. grouped-control, grouped-control-outer-border-color, and grouped-control-validation-focus no longer take a neutral-color param. 2. The neutral outward-edge color now references awsui.$color-border-input-default directly at the single leaf usage. 3. Call sites: segmented-control updated to no-arg; input and button-trigger already called no-arg and compile under the new signature; button uses grouped-control-base + grouped-control-horizontal-last and never called the arg-taking mixin. No call site passes an argument. 4. segmented-control now resolves the default token, pixel-identical to the prior segment-wrapper alias per the investigation. 5. Validation (error/warning status tokens), thick leading border, outward-edge logic, inner-seam neutrality, hover/focus/keyboard-focus, and the hoisted invalid ring are unchanged. 6. Only SCSS files changed; no TSX/test change (emitted class names unchanged). Non-blocking: pixel confirmation needs the control-group + segmented-control visual-regression run. --- .../styles/forms/grouped-control.scss | 42 +++++++++---------- src/segmented-control/styles.scss | 2 +- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index a38e29304d4..e41e22ab3ae 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -35,14 +35,10 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // The group owns the validation state and drives it to every grouped control // through the context-emitted `.grouped-in-error` / `.grouped-in-warning` class. -// Neutral controls get their own border color; the error/warning class selects +// Neutral controls get the input border color; the error/warning class selects // the matching SCSS status token on the outward edges only. -// -// `$neutral-border-color` is the consuming control's own border color. Input and -// the Select/Multiselect trigger use the input border color; segmented-control -// passes its segment-wrapper border color. -@mixin grouped-control-outer-border-color($edges, $neutral-border-color: awsui.$color-border-input-default) { - @include grouped-control-edge-color($edges, $neutral-border-color); +@mixin grouped-control-outer-border-color($edges) { + @include grouped-control-edge-color($edges, awsui.$color-border-input-default); // Assert the outward color once per state across base/hover/focus so the // control's own neutral hover/focus border-color does not win. &#{$grouped-control-error-marker}, @@ -62,7 +58,7 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // the per-position outward validation color in that scope so it wins on keyboard // focus too. The invalid ring is hoisted (position-independent) into the mixin // body. `&` is the position class; the control also carries the state marker. -@mixin grouped-control-validation-focus($edges, $neutral-border-color) { +@mixin grouped-control-validation-focus($edges) { &#{$grouped-control-error-marker} { @include focus-visible.when-visible { @include grouped-control-edge-color($edges, awsui.$color-text-status-error); @@ -99,7 +95,7 @@ $grouped-control-warning-marker: '.grouped-in-warning'; @include styles.logical-radius(0); } -@mixin grouped-control($neutral-border-color: awsui.$color-border-input-default) { +@mixin grouped-control { @include grouped-control-base; &-horizontal { @@ -108,22 +104,22 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // Outward edges: inline-start (group start) + both block edges. // inline-end faces the next control, so it stays neutral. $edges: (inline-start, block-start, block-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } &-middle { @include grouped-control-middle; // Only the block edges face outward; both inline edges face neighbors. $edges: (block-start, block-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } &-last { @include grouped-control-horizontal-last; // Outward edges: inline-end (group end) + both block edges. $edges: (inline-end, block-start, block-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } } @@ -133,22 +129,22 @@ $grouped-control-warning-marker: '.grouped-in-warning'; // Outward edges: block-start (group top) + both inline edges. // block-end faces the next control, so it stays neutral. $edges: (block-start, inline-start, inline-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } &-middle { @include grouped-control-middle; // Only the inline edges face outward; both block edges face neighbors. $edges: (inline-start, inline-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } &-last { @include grouped-control-vertical-last; // Outward edges: block-end (group bottom) + both inline edges. $edges: (block-end, inline-start, inline-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } } @@ -157,8 +153,8 @@ $grouped-control-warning-marker: '.grouped-in-warning'; &-horizontal-only, &-vertical-only { $edges: (inline-start, inline-end, block-start, block-end); - @include grouped-control-outer-border-color($edges, $neutral-border-color); - @include grouped-control-validation-focus($edges, $neutral-border-color); + @include grouped-control-outer-border-color($edges); + @include grouped-control-validation-focus($edges); } &-with-action { diff --git a/src/segmented-control/styles.scss b/src/segmented-control/styles.scss index ca5f2198fe8..97d2a75c6d6 100644 --- a/src/segmented-control/styles.scss +++ b/src/segmented-control/styles.scss @@ -62,7 +62,7 @@ } .grouped { - @include styles.grouped-control(awsui.$color-border-segment-wrapper); + @include styles.grouped-control; &.grouped-horizontal-middle, &.grouped-horizontal-last { From c1a3fff0a05dd5aac5fd0b0b22234e6cae9e5df3 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 17:03:40 +0200 Subject: [PATCH 30/40] chore: Revert visual test definition changes Restore test/definitions/visual/{control-group,multiselect,select}.ts to their origin/main state; drop the validation visual-test entries added on this branch. --- test/definitions/visual/control-group.ts | 6 ------ test/definitions/visual/multiselect.ts | 13 ------------- test/definitions/visual/select.ts | 13 ------------- 3 files changed, 32 deletions(-) diff --git a/test/definitions/visual/control-group.ts b/test/definitions/visual/control-group.ts index e3d4680f57d..296f3ce550b 100644 --- a/test/definitions/visual/control-group.ts +++ b/test/definitions/visual/control-group.ts @@ -12,12 +12,6 @@ const suite: TestSuite = { screenshotType: 'permutations', pixelDiffTolerance: 2, }, - { - description: 'Validation states', - path: 'control-group/validation', - screenshotType: 'screenshotArea', - pixelDiffTolerance: 2, - }, ], }; diff --git a/test/definitions/visual/multiselect.ts b/test/definitions/visual/multiselect.ts index d23f4c7c9f8..eeef7fa8cce 100644 --- a/test/definitions/visual/multiselect.ts +++ b/test/definitions/visual/multiselect.ts @@ -11,19 +11,6 @@ const suite: TestSuite = { path: 'multiselect/permutations', screenshotType: 'permutations', }, - { - description: 'standalone validation - keyboard focus', - path: 'multiselect/validation-focus', - screenshotType: 'screenshotArea', - pixelDiffTolerance: 2, - // Click the focus target, then Tab onto the first standalone invalid trigger. Tabbing - // activates the keyboard focus ring (body[data-awsui-focus-visible]), exercising the - // focus-visible path the fix targets. - setup: async ({ page }) => { - await page.click('#focus-target'); - await page.keys(['Tab']); - }, - }, { description: 'inlineLabelText-permutations', path: 'multiselect/inline-label-text-permutations', diff --git a/test/definitions/visual/select.ts b/test/definitions/visual/select.ts index fa4e42f6759..e1b34605d65 100644 --- a/test/definitions/visual/select.ts +++ b/test/definitions/visual/select.ts @@ -76,19 +76,6 @@ const suite: TestSuite = { path: 'select/trigger.permutations', screenshotType: 'permutations', }, - { - description: 'standalone validation - keyboard focus', - path: 'select/validation-focus', - screenshotType: 'screenshotArea', - pixelDiffTolerance: 2, - // Click the focus target, then Tab onto the first standalone invalid trigger. Tabbing - // activates the keyboard focus ring (body[data-awsui-focus-visible]), exercising the - // focus-visible path the fix targets. - setup: async ({ page }) => { - await page.click('#focus-target'); - await page.keys(['Tab']); - }, - }, { description: 'Long virtual list - navigate to last item', path: 'select/virtual-scroll', From db46ce0829b0008fddad7d5bac36723dcb4036e9 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 17:13:20 +0200 Subject: [PATCH 31/40] test: Remove ControlGroup validation classname unit tests --- .../control-group/__tests__/common.tsx | 20 -- .../__tests__/control-group.test.tsx | 303 +----------------- 2 files changed, 1 insertion(+), 322 deletions(-) diff --git a/src/internal/components/control-group/__tests__/common.tsx b/src/internal/components/control-group/__tests__/common.tsx index 0e2be69b9fe..2dad08dcc68 100644 --- a/src/internal/components/control-group/__tests__/common.tsx +++ b/src/internal/components/control-group/__tests__/common.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { useGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context'; -import { useFormFieldContext } from '../../../../../lib/components/internal/context/form-field-context'; export function PositionProbe({ testId = 'probe' }: { testId?: string }) { const { position } = useGroupedControlContext(); @@ -14,22 +13,3 @@ export function DirectionProbe({ testId = 'direction-probe' }: { testId?: string const { direction } = useGroupedControlContext(); return {direction}; } - -/** - * Reports the form-field validation flags a child control sees. The group masks - * these to `false` so inner controls (and their seams) stay neutral while the - * group paints the single outer validation border. - */ -export function FormFieldValidationProbe({ testId = 'form-field-probe' }: { testId?: string }) { - const { invalid, warning } = useFormFieldContext({}); - return {`invalid:${!!invalid} warning:${!!warning}`}; -} - -/** - * Reports the validation flags the group exposes through the grouped-control - * context (the real state the group owns). - */ -export function GroupedValidationProbe({ testId = 'grouped-probe' }: { testId?: string }) { - const { validation } = useGroupedControlContext(); - return {`validation:${validation ?? 'null'}`}; -} 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 a0a41088a20..bb30b9a4a5d 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -3,17 +3,10 @@ import React from 'react'; import { render } from '@testing-library/react'; -import FormField from '../../../../../lib/components/form-field'; -import Input from '../../../../../lib/components/input'; 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 { DirectionProbe, FormFieldValidationProbe, GroupedValidationProbe, PositionProbe } from './common'; - -import buttonStyles from '../../../../../lib/components/button/styles.selectors.js'; -import inputStyles from '../../../../../lib/components/input/styles.selectors.js'; -import controlGroupStyles from '../../../../../lib/components/internal/components/control-group/styles.selectors.js'; -import controlGroupTestStyles from '../../../../../lib/components/internal/components/control-group/test-classes/styles.selectors.js'; +import { DirectionProbe, PositionProbe } from './common'; function findControlGroup(container: HTMLElement) { const element = container.querySelector(`.${ControlGroupWrapper.rootSelector}`); @@ -183,298 +176,4 @@ describe('Control group', () => { expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); expect(findControlGroup(container)?.findInlineLabel()).toBeNull(); }); - - describe('validation', () => { - const invalidClass = controlGroupStyles['controls-invalid']; - const warningClass = controlGroupStyles['controls-warning']; - - test('applies the error class to the group when the surrounding form field is in an error state', () => { - const { getByRole } = render( - - - - - - - ); - - const group = getByRole('group'); - expect(group).toHaveClass(invalidClass); - expect(group).toHaveClass(controlGroupTestStyles['controls-invalid']); - expect(group).not.toHaveClass(warningClass); - }); - - // jsdom cannot compute the resolved border color/width or the focus ring; - // those are covered by the visual test. Here we assert the state class that - // drives the SCSS token selection is applied to each grouped control. - const controlErrorClass = inputStyles['grouped-in-error']; - const controlWarningClass = inputStyles['grouped-in-warning']; - - test('marks each grouped control with the error state class in an error state', () => { - const { getAllByRole } = render( - - - {}} /> - {}} /> - - - ); - - for (const control of getAllByRole('textbox')) { - expect(control).toHaveClass(controlErrorClass); - expect(control).not.toHaveClass(controlWarningClass); - } - }); - - test('marks a non-leading control with the error state class but not the thick seam class', () => { - const { getAllByRole } = render( - - - {}} /> - {}} /> - - - ); - - const [, last] = getAllByRole('textbox'); - expect(last).toHaveClass(controlErrorClass); - expect(last).not.toHaveClass(inputStyles['grouped-validation']); - }); - - test('applies the warning class to the group when the surrounding form field is in a warning state', () => { - const { getByRole } = render( - - - - - - - ); - - const group = getByRole('group'); - expect(group).toHaveClass(warningClass); - expect(group).toHaveClass(controlGroupTestStyles['controls-warning']); - expect(group).not.toHaveClass(invalidClass); - }); - - test('marks each grouped control with the warning state class in a warning state', () => { - const { getAllByRole } = render( - - - {}} /> - {}} /> - - - ); - - for (const control of getAllByRole('textbox')) { - expect(control).toHaveClass(controlWarningClass); - expect(control).not.toHaveClass(controlErrorClass); - } - }); - - test('applies no validation class when the surrounding form field is neutral', () => { - const { getByRole } = render( - - - - - - ); - - const group = getByRole('group'); - expect(group).not.toHaveClass(invalidClass); - expect(group).not.toHaveClass(warningClass); - }); - - test('applies no validation state class to a control when the form field is neutral', () => { - // Without a state class, each grouped control keeps its own neutral border - // color, so the group looks unchanged. - const { getByRole } = render( - - - {}} /> - - - ); - - const control = getByRole('textbox'); - expect(control).not.toHaveClass(controlErrorClass); - expect(control).not.toHaveClass(controlWarningClass); - }); - - test('error takes precedence over warning when the form field has both', () => { - const { getByRole } = render( - - - - - - ); - - const group = getByRole('group'); - expect(group).toHaveClass(invalidClass); - expect(group).not.toHaveClass(warningClass); - }); - - test('error takes precedence over warning on the control state class', () => { - const { getByRole } = render( - - - {}} /> - - - ); - - const control = getByRole('textbox'); - expect(control).toHaveClass(inputStyles['grouped-in-error']); - expect(control).not.toHaveClass(inputStyles['grouped-in-warning']); - }); - - test('applies the matching action-button validation class only in a validation state', () => { - const errorActionClass = buttonStyles['variant-control-group-action-error']; - const warningActionClass = buttonStyles['variant-control-group-action-warning']; - - function renderActionButton(field: Record) { - const { container } = render( - - - - - - ); - return findControlGroup(container)!.findActionButton()!.getElement(); - } - - // Error: button gets the error variant class only. - const errorButton = renderActionButton({ errorText: 'Required' }); - expect(errorButton).toHaveClass(errorActionClass); - expect(errorButton).not.toHaveClass(warningActionClass); - - // Warning: button gets the warning variant class only. - const warningButton = renderActionButton({ warningText: 'Careful' }); - expect(warningButton).toHaveClass(warningActionClass); - expect(warningButton).not.toHaveClass(errorActionClass); - - // Error precedence: both texts -> error variant class, not warning. - const bothButton = renderActionButton({ errorText: 'Required', warningText: 'Careful' }); - expect(bothButton).toHaveClass(errorActionClass); - expect(bothButton).not.toHaveClass(warningActionClass); - - // Neutral: button carries no state variant class, nor the group's classes. - const neutralButton = renderActionButton({ label: 'Field' }); - expect(neutralButton).not.toHaveClass(errorActionClass); - expect(neutralButton).not.toHaveClass(warningActionClass); - expect(neutralButton).not.toHaveClass(invalidClass); - expect(neutralButton).not.toHaveClass(warningClass); - expect(neutralButton).not.toHaveClass(controlGroupTestStyles['controls-invalid']); - expect(neutralButton).not.toHaveClass(controlGroupTestStyles['controls-warning']); - }); - - // The first/only control on the group's leading edge draws the seam via the - // `grouped-validation` class. jsdom cannot compute pixel width or `:hover`; - // those are covered by the visual test. - const seamClass = inputStyles['grouped-validation']; - - function renderGroupedInput(validation: 'error' | 'warning' | 'neutral', withAction: boolean) { - const field = - validation === 'error' - ? { errorText: 'Required' } - : validation === 'warning' - ? { warningText: 'Careful' } - : { label: 'Field' }; - return render( - - - {}} /> - - - ); - } - - function renderGroupedInputs(validation: 'error' | 'warning' | 'neutral') { - const field = validation === 'error' ? { errorText: 'Required' } : { warningText: 'Careful' }; - return render( - - - {}} /> - {}} /> - - - ); - } - - test('draws the seam on the leading control in an error state', () => { - const { getByRole } = renderGroupedInput('error', true); - expect(getByRole('textbox')).toHaveClass(seamClass); - }); - - test('draws the seam on the leading control in a warning state', () => { - const { getByRole } = renderGroupedInput('warning', true); - expect(getByRole('textbox')).toHaveClass(seamClass); - }); - - test('does not draw the seam on the control when the form field is neutral', () => { - const { getByRole } = renderGroupedInput('neutral', true); - expect(getByRole('textbox')).not.toHaveClass(seamClass); - }); - - test('draws the seam on the leading control even without an action button', () => { - const { getByRole } = renderGroupedInput('error', false); - expect(getByRole('textbox')).toHaveClass(seamClass); - }); - - test('draws the seam only on the first control, not the last, in a horizontal group', () => { - const { getAllByRole } = renderGroupedInputs('error'); - const [first, last] = getAllByRole('textbox'); - expect(first).toHaveClass(seamClass); - expect(last).not.toHaveClass(seamClass); - }); - - test('keeps the seam class off the group element so only the control carries it', () => { - const { getByRole } = renderGroupedInput('error', true); - expect(getByRole('group')).not.toHaveClass(seamClass); - }); - - test('masks the validation state for inner controls so seams stay neutral (error)', () => { - const { getByTestId } = render( - - - - - - ); - - // The child reads neutral validation flags: it will not paint its own - // border or inner seams. - expect(getByTestId('form-field-probe')).toHaveTextContent('invalid:false warning:false'); - }); - - test('masks the validation state for inner controls so seams stay neutral (warning)', () => { - const { getByTestId } = render( - - - - - - ); - - expect(getByTestId('form-field-probe')).toHaveTextContent('invalid:false warning:false'); - }); - - test('exposes the resolved validation state through the grouped-control context', () => { - const { getByTestId } = render( - - - - - - ); - - expect(getByTestId('grouped-probe')).toHaveTextContent('validation:error'); - }); - }); }); From cda1254a6009b929586b23f773843e61c8dc37ad Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 14 Oct 2026 17:39:02 +0200 Subject: [PATCH 32/40] chore: Remove unused ControlGroup validation test-util classes --- src/internal/components/control-group/index.tsx | 4 ++-- .../components/control-group/test-classes/styles.scss | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 0d6b71eaac8..0f5f7199743 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -44,8 +44,8 @@ export default function ControlGroup({ className={clsx( styles.controls, styles[`controls-${direction}`], - isInvalid && [styles['controls-invalid'], testUtilStyles['controls-invalid']], - isWarning && [styles['controls-warning'], testUtilStyles['controls-warning']], + isInvalid && styles['controls-invalid'], + isWarning && styles['controls-warning'], actionButton && styles['controls-with-action'], !actionButton && !inlineLabelText && rootClassnames )} diff --git a/src/internal/components/control-group/test-classes/styles.scss b/src/internal/components/control-group/test-classes/styles.scss index e1f5a5fb0e2..6961045a728 100644 --- a/src/internal/components/control-group/test-classes/styles.scss +++ b/src/internal/components/control-group/test-classes/styles.scss @@ -14,11 +14,3 @@ .inline-label { /* used in test-utils */ } - -.controls-invalid { - /* used in test-utils */ -} - -.controls-warning { - /* used in test-utils */ -} From 034e1b97d2c7d9403dcb3a47fa52267ae110000f Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 15 Oct 2026 08:55:38 +0200 Subject: [PATCH 33/40] fix: Draw ControlGroup validation leading bar as a pseudo-element to avoid border miter --- .../styles/forms/grouped-control.scss | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index e41e22ab3ae..ffa3a0f72a2 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -175,11 +175,32 @@ $grouped-control-warning-marker: '.grouped-in-warning'; } } - // Thick validation seam on the group's leading (inline-start) edge, reusing - // the standalone invalid control's width/padding. box-sizing: border-box - // grows the border inward, so the row does not shift. + // Thick validation seam on the group's leading (inline-start) edge. Painted as + // a flat ::before rectangle rather than a thick border so it cannot miter + // against the thin neutral block seams (which would notch the colored bar). + // The control keeps its real 1px inline-start border for the overlap seam math; + // the bar overlays the inline-start strip inside the (border-box) padding, and + // padding-inline-start pushes the content clear of it with no row shift. &#{&}-validation { - border-inline-start-width: constants.$invalid-control-left-border; - padding-inline-start: constants.$invalid-control-left-padding; + position: relative; + padding-inline-start: constants.$control-padding-horizontal; + + &::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + inline-size: constants.$invalid-control-left-border; + // Match only the leading corners the position class rounded (border-radius + // is not inherited by default, so this pulls the control's computed values). + border-start-start-radius: inherit; + border-end-start-radius: inherit; + } + &#{$grouped-control-error-marker}::before { + background-color: awsui.$color-text-status-error; + } + &#{$grouped-control-warning-marker}::before { + background-color: awsui.$color-text-status-warning; + } } } From 3f4a8b778c175e37ee4a4cd796314bed0f5c3a63 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 15 Oct 2026 11:25:21 +0200 Subject: [PATCH 34/40] fix: Paint ControlGroup validation leading bar on the group slot to fix miter and input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the thick leading validation bar off the control and onto the group's per-control leading `.control` slot
, drawn as a positive-z-index ::before. Reading the resulting code: 1. The bar is now a `z-index: 1` `::before` on the leading `.control` slot (control-group/styles.scss `.control-validation`); the control-side `::before` and its `position: relative` are removed from grouped-control.scss (only the `padding-inline-start` content inset remains there). 2. It is a flat rectangle (no border, no miter), offset `inset-inline-start: -1 * $border-width-field` and widened by the same, so its outer edge sits flush with the control's outer edge and overpaints the 1px gray leading border; `inset-block: 0` avoids bleeding past rounded first/last corners (the overlap seam already collapses the block seams). 3. It renders for ALL control types (input/autosuggest/select/multiselect/ segmented) because the host is a real
slot, not a replaced that generates no pseudo-elements. 4. Per-position leading corner radii are set explicitly on the `::before` keyed on the existing `control-${position}-${direction}` slot classes (border-radius is not inherited and the slot carries none): horizontal first/only and vertical only round both leading corners; vertical first rounds only top-leading; vertical last rounds only bottom-leading; horizontal/vertical middle stay square — mirroring the grouped-control mixin's outer corners. 5. Status color comes from new leading-slot markers `control-in-error` / `control-in-warning`, gated in index.tsx by `isLeading && isInvalid/isWarning`; error precedence holds because `isWarning` is already `!!warning && !isInvalid`. 6. Content inset is unchanged — `padding-inline-start` stays on the control. 7. Inner seams, outward-edge coloring, invalid focus ring, `$margin-overlap`, `isolation: isolate`, and `:focus-within { z-index: 1 }` are untouched. 8. No control-package, test, or dev-page changes. --- .../components/control-group/index.tsx | 12 ++++- .../components/control-group/styles.scss | 49 +++++++++++++++++++ .../styles/forms/grouped-control.scss | 30 ++---------- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 0f5f7199743..95eff438fe7 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -54,10 +54,20 @@ export default function ControlGroup({ const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; const position: GroupedControlPosition = controlCount === 1 ? 'only' : index === 0 ? 'first' : index === controlCount - 1 ? 'last' : 'middle'; + // Only the leading (inline-start) slots host the thick validation bar: + // the first/only control horizontally, every control vertically. Mirrors + // the isLeading rule in grouped-control-styles.ts. + const isLeading = direction === 'vertical' || position === 'first' || position === 'only'; return (
(not the control) so it + // is a flat rectangle that cannot miter against the control's thin block seams + // and renders for replaced-element controls (Input/Autosuggest). Offset one + // border width outboard and widened to match so it sits flush with the control's + // outer edge and overpaints the control's 1px gray leading border. z-index: 1 + // keeps it above the control's border/box-shadow phase; the slot's own + // isolation keeps it from reaching an adjacent slot. + &-validation { + position: relative; + + &::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: calc(-1 * #{awsui.$border-width-field}); + inline-size: calc(#{constants.$invalid-control-left-border} + #{awsui.$border-width-field}); + z-index: 1; + } + + // Per-position leading corner radii. border-radius is not inherited and the + // slot carries none, so match the leading corners each position rounds in + // grouped-control.scss so the bar's rounded corners coincide with the group's + // outer corners (and stay square where the group is square). + // Leading horizontal positions (first/only) round both leading corners. + &.control-first-horizontal::before, + &.control-only-horizontal::before, + &.control-only-vertical::before { + border-start-start-radius: constants.$control-border-radius; + border-end-start-radius: constants.$control-border-radius; + } + // Vertical first rounds only the top-leading corner. + &.control-first-vertical::before { + border-start-start-radius: constants.$control-border-radius; + } + // Vertical last rounds only the bottom-leading corner. + &.control-last-vertical::before { + border-end-start-radius: constants.$control-border-radius; + } + // Vertical middle rounds neither (no radius rule needed). + + &.control-in-error::before { + background-color: awsui.$color-text-status-error; + } + &.control-in-warning::before { + background-color: awsui.$color-text-status-warning; + } + } } .action-slot, diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index ffa3a0f72a2..ca468298b07 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -175,32 +175,12 @@ $grouped-control-warning-marker: '.grouped-in-warning'; } } - // Thick validation seam on the group's leading (inline-start) edge. Painted as - // a flat ::before rectangle rather than a thick border so it cannot miter - // against the thin neutral block seams (which would notch the colored bar). - // The control keeps its real 1px inline-start border for the overlap seam math; - // the bar overlays the inline-start strip inside the (border-box) padding, and - // padding-inline-start pushes the content clear of it with no row shift. + // Content inset for the thick leading validation bar. The bar itself is drawn + // by the group's leading `.control` slot (control-group/styles.scss) so it is a + // flat
rectangle that cannot miter and renders for replaced-element + // controls (Input/Autosuggest) too; this padding keeps the control's content + // clear of that bar with no row shift. &#{&}-validation { - position: relative; padding-inline-start: constants.$control-padding-horizontal; - - &::before { - content: ''; - position: absolute; - inset-block: 0; - inset-inline-start: 0; - inline-size: constants.$invalid-control-left-border; - // Match only the leading corners the position class rounded (border-radius - // is not inherited by default, so this pulls the control's computed values). - border-start-start-radius: inherit; - border-end-start-radius: inherit; - } - &#{$grouped-control-error-marker}::before { - background-color: awsui.$color-text-status-error; - } - &#{$grouped-control-warning-marker}::before { - background-color: awsui.$color-text-status-warning; - } } } From fcbb69a9dfd96821a808def75b450bf05d035ed0 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 15 Oct 2026 13:58:47 +0200 Subject: [PATCH 35/40] Add inline labels to dev page --- pages/control-group/validation.page.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pages/control-group/validation.page.tsx b/pages/control-group/validation.page.tsx index 183128d727a..8f679e789ed 100644 --- a/pages/control-group/validation.page.tsx +++ b/pages/control-group/validation.page.tsx @@ -34,6 +34,8 @@ const actionButtons: ReadonlyArray = [ { iconName: 'remove', ariaLabel: 'Remove' }, ]; +const labels = [undefined, 'Label']; + const permutations = createPermutations([ { children, errorText: ['This value is required'], direction: directions, actionButton: actionButtons }, { @@ -41,6 +43,7 @@ const permutations = createPermutations([ warningText: ['Double-check this value'], direction: directions, actionButton: actionButtons, + inlineLabelText: labels, }, ]); From eb21ba28de4eb41fd427010c0a2e2bd1b50de36d Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 15 Oct 2026 14:03:43 +0200 Subject: [PATCH 36/40] Refine dev page --- pages/multiselect/validation-focus.page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/multiselect/validation-focus.page.tsx b/pages/multiselect/validation-focus.page.tsx index c13dcca8dd2..15cad0242b3 100644 --- a/pages/multiselect/validation-focus.page.tsx +++ b/pages/multiselect/validation-focus.page.tsx @@ -17,7 +17,7 @@ const options = [ export default function MultiselectValidationFocusPage() { return ( } > From 4d681d0d07076a4962fc62d44011088cdac91765 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 15 Oct 2026 14:32:23 +0200 Subject: [PATCH 37/40] refactor: Let ControlGroup controls own the action-button seam border Make the last control's inline-end edge the single controls-to-action-button seam, removing the button's redundant inline-start border and the button-side validation special-case. The control already draws and validation-colors its outward inline-end edge, so the button border was duplicate paint on top of it. Changes: - button/styles.scss: variant-control-group-action gets border-inline-start-width: 0 (base) so no neutral or colored inline-start border is painted; re-asserted inside the when-visible block (form-focus-element sets inline style+color but not width, so width 0 survives focus). Deleted the &-error/&-warning sub-blocks. - control-group/styles.scss: .action-slot margin-inline-start -1px -> 0 (butt-join). - control-group/index.tsx: removed the two buttonStyles variant-control-group- action-error/-warning class pushes and the now-unused buttonStyles import. Kept isInvalid/isWarning (still feed GroupedControlContext.validation and the leading-bar classes). Verified by reading the resulting code (no build/sass run; user watch job owns pixels): (1) button variant paints no inline-start border in any state: base width 0, and width 0 re-asserted under when-visible so it survives form-focus-element. (2) .action-slot margin is 0; total row width = C + B - 1 matches today's C + (-1) + B, so no 1px shift and no gap; single inline-start margin covers both horizontal and vertical (vertical only adds align-items: stretch, no own margin). (3) seam validation color now sourced from the control's existing outward inline-end coloring (horizontal-last and all vertical positions, error precedence, neutral otherwise) via grouped-control.scss; no button-side color needed. (4) button-side variant-control-group-action-error/-warning SCSS rules and the two index.tsx class pushes deleted; no remaining references in src/. (5) corner radii square at the seam (unchanged), focus halo on other edges and .action-slot:focus-within z-index unchanged, leading .control-validation::before thick bar untouched, button block-start/block-end/inline-end edges unchanged. (6) only 3 files changed plus the unused buttonStyles import cleanup; no change to constants.scss, tests, or dev pages. --- src/button/styles.scss | 14 +++++++------- src/internal/components/control-group/index.tsx | 7 +------ src/internal/components/control-group/styles.scss | 8 +++++--- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index 8cd35f304e1..ee959e75722 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -211,22 +211,22 @@ $tokens: style-api.resolve($props, carrier); border-inline-style: solid; @include styles.grouped-control-base; @include styles.grouped-control-horizontal-last; + // The control owns the controls-to-button seam: the last control draws and + // validation-colors its inline-end border, so the button paints no inline-start + // border (width 0, neutral or colored) that would sit on top of it. + border-inline-start-width: 0; padding-block: awsui.$space-field-vertical; padding-inline: awsui.$space-xs; min-block-size: awsui.$size-vertical-input; - &-error { - border-inline-start-color: awsui.$color-text-status-error; - } - &-warning { - border-inline-start-color: awsui.$color-text-status-warning; - } - @include focus-visible.when-visible { @include styles.form-focus-element( $border-color: $focus-border-color, $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) ); + // form-focus-element sets inline border style+color but not width, so re-assert + // the zeroed inline-start width so the seam edge stays absent on focus too. + border-inline-start-width: 0; } } diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 95eff438fe7..d96d8e356ba 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -12,7 +12,6 @@ import { FormFieldContext, useFormFieldContext } from '../../context/form-field- import { flattenChildren } from '../../utils/flatten-children'; import { ControlGroupProps } from './interfaces'; -import buttonStyles from '../../../button/styles.css.js'; import styles from './styles.css.js'; import testUtilStyles from './test-classes/styles.css.js'; @@ -96,11 +95,7 @@ export default function ControlGroup({ {controls}
Date: Thu, 15 Oct 2026 23:41:26 +0200 Subject: [PATCH 38/40] Fixes --- src/input/styles.scss | 16 ++++++++++++---- .../components/button-trigger/styles.scss | 2 +- .../components/control-group/styles.scss | 1 - src/internal/styles/forms/mixins.scss | 9 +++++---- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/input/styles.scss b/src/input/styles.scss index 9c602fc8b19..70ed8168a01 100644 --- a/src/input/styles.scss +++ b/src/input/styles.scss @@ -63,10 +63,18 @@ } %input-focus-styles { - @include styles.form-focus-element( - $border-color: var(#{custom-props.$styleBorderColorFocus}, awsui.$color-border-input-focused), - $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) - ); + &:not(.grouped-validation) { + @include styles.form-focus-element( + $border-color: var(#{custom-props.$styleBorderColorFocus}, awsui.$color-border-input-focused), + $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) + ); + } + &.grouped-validation { + @include styles.form-focus-element( + $border-color: false, + $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) + ); + } } .root { diff --git a/src/internal/components/button-trigger/styles.scss b/src/internal/components/button-trigger/styles.scss index 292a191e0ab..9cdb22b08d0 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(.in-filtering-token):not(.grouped-validation) { @include focus-visible.when-visible { @include styles.form-focus-element(); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index bb77ed67303..ba96111125d 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -95,7 +95,6 @@ $margin-overlap: calc(-1 * #{awsui.$border-width-field}); content: ''; position: absolute; inset-block: 0; - inset-inline-start: calc(-1 * #{awsui.$border-width-field}); inline-size: calc(#{constants.$invalid-control-left-border} + #{awsui.$border-width-field}); z-index: 1; } diff --git a/src/internal/styles/forms/mixins.scss b/src/internal/styles/forms/mixins.scss index 189b33f9753..e459bebce96 100644 --- a/src/internal/styles/forms/mixins.scss +++ b/src/internal/styles/forms/mixins.scss @@ -124,12 +124,13 @@ // See focus-highlight above. outline: 2px dotted transparent; - // Set focus border color/style only; do not reset the border width, so an - // already-applied thick invalid inline-start side survives focus. border-block-style: solid; border-inline-style: solid; - border-block-color: $border-color; - border-inline-color: $border-color; + + @if $border-color { + border-block-color: $border-color; + border-inline-color: $border-color; + } box-shadow: $box-shadow; } From 5fcf584b8efefe015061cdb2e11eedc7c455d6f2 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Fri, 16 Oct 2026 13:29:09 +0200 Subject: [PATCH 39/40] fix: Correct invalid-state inline padding in prompt input In the invalid/warning state the thick leading validation border lives on the .root element while the compensating reduced padding lives on inner content cells. The root's padding-inline-start: 0 was emitted at a lower specificity than the form-*-control mixin's own :not(.grouped) padding, so the mixin's 5px won and stacked on the inner cell's 5px, leaving 5px of excess leading gap in both themes. Pass the zero as mixin @content (matching the working Input pattern) so it lands inside the :not(.grouped) branch at equal specificity and later source order, zeroing the root padding. Text x-position is now identical neutral vs invalid/warning: 13px (visual refresh) / 9px (classic), verified in-browser. --- src/prompt-input/styles.scss | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/prompt-input/styles.scss b/src/prompt-input/styles.scss index 3887fec3461..8d69e9144a6 100644 --- a/src/prompt-input/styles.scss +++ b/src/prompt-input/styles.scss @@ -71,11 +71,15 @@ $invalid-border-offset: constants.$invalid-control-left-padding; } &.textarea-invalid { + // The thick leading border lives on this root while the compensating reduced + // padding lives on inner content cells (.textarea etc.), so the root must own + // zero inline-start padding. Pass the zero as mixin @content so it lands inside + // the mixin's `:not(.grouped)` branch and wins the specificity contest against + // the mixin's own padding (matching the working Input pattern). @include styles.form-invalid-control( $color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-status-error), $border-color: var(#{custom-props.$promptInputStyleBorderColorDefault}, awsui.$color-text-status-error) - ); - & { + ) { padding-inline-start: 0; } @@ -84,8 +88,7 @@ $invalid-border-offset: constants.$invalid-control-left-padding; @include styles.form-invalid-control( $color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-status-error), $border-color: var(#{custom-props.$promptInputStyleBorderColorDefault}, awsui.$color-text-status-error) - ); - & { + ) { padding-inline-start: 0; box-shadow: foundation.$box-shadow-focused-light-invalid; } @@ -99,11 +102,13 @@ $invalid-border-offset: constants.$invalid-control-left-padding; } &.textarea-warning { + // Symmetric with the invalid state: pass the root's zero inline-start padding + // as mixin @content so it wins the specificity contest against the mixin's own + // padding, keeping text at the neutral x-position next to the thick border. @include styles.form-warning-control( $color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-status-warning), $border-color: var(#{custom-props.$promptInputStyleBorderColorDefault}, awsui.$color-text-status-warning) - ); - & { + ) { padding-inline-start: 0; } @@ -112,8 +117,7 @@ $invalid-border-offset: constants.$invalid-control-left-padding; @include styles.form-warning-control( $color: var(#{custom-props.$promptInputStyleColorDefault}, awsui.$color-text-status-warning), $border-color: var(#{custom-props.$promptInputStyleBorderColorDefault}, awsui.$color-text-status-warning) - ); - & { + ) { padding-inline-start: 0; box-shadow: foundation.$box-shadow-focused-light-invalid; } From 22fa300adfe31a6557a905d3cd729bf4a6034c32 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Sat, 17 Oct 2026 10:04:54 +0200 Subject: [PATCH 40/40] Fixes and improvements --- src/button/styles.scss | 13 +-- .../components/button-trigger/styles.scss | 17 +-- .../components/control-group/index.tsx | 2 - .../components/control-group/styles.scss | 15 +-- .../styles/forms/grouped-control.scss | 108 +++++------------- src/segmented-control/styles.scss | 12 ++ 6 files changed, 54 insertions(+), 113 deletions(-) diff --git a/src/button/styles.scss b/src/button/styles.scss index ee959e75722..3b701539502 100644 --- a/src/button/styles.scss +++ b/src/button/styles.scss @@ -10,6 +10,7 @@ @use '../internal/generated/custom-css-properties/index.scss' as custom-props; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; @use './constants' as constants; +@use '../internal/styles/forms/constants' as form-constants; @use '../internal/styles/style-api' as style-api; $props: style-api.combine(style-api.$surface, (box-shadow, padding-block, padding-inline), style-api.$focus-ring); @@ -211,22 +212,20 @@ $tokens: style-api.resolve($props, carrier); border-inline-style: solid; @include styles.grouped-control-base; @include styles.grouped-control-horizontal-last; - // The control owns the controls-to-button seam: the last control draws and - // validation-colors its inline-end border, so the button paints no inline-start - // border (width 0, neutral or colored) that would sit on top of it. - border-inline-start-width: 0; padding-block: awsui.$space-field-vertical; padding-inline: awsui.$space-xs; min-block-size: awsui.$size-vertical-input; + &, + &:hover { + border-inline-start-color: transparent; + } + @include focus-visible.when-visible { @include styles.form-focus-element( $border-color: $focus-border-color, $box-shadow: var(#{custom-props.$styleBoxShadowFocus}, foundation.$box-shadow-focused-light) ); - // form-focus-element sets inline border style+color but not width, so re-assert - // the zeroed inline-start width so the seam edge stays absent on focus too. - border-inline-start-width: 0; } } diff --git a/src/internal/components/button-trigger/styles.scss b/src/internal/components/button-trigger/styles.scss index 31d72dd5d9b..87bc1a862fe 100644 --- a/src/internal/components/button-trigger/styles.scss +++ b/src/internal/components/button-trigger/styles.scss @@ -128,8 +128,10 @@ $icon-offset: awsui.$space-xxxs; } &:not(.in-filtering-token):not(.compact) { - @include focus-visible.when-visible { - @include styles.form-focus-element(); + &:not(.grouped) { + @include focus-visible.when-visible { + @include styles.form-focus-element(); + } } &:invalid { @@ -147,8 +149,6 @@ $icon-offset: awsui.$space-xxxs; @include styles.form-warning-control(); } - // Re-assert the validation border color at focus-visible specificity so it wins over - // the neutral keyboard-focus border. The blue invalid ring still layers around it. @include focus-visible.when-visible { &.invalid { border-color: awsui.$color-text-status-error; @@ -156,15 +156,6 @@ $icon-offset: awsui.$space-xxxs; &.warning { border-color: awsui.$color-text-status-warning; } - - // Grouped controls get no `invalid`/`warning` class (the group masks the - // form-field context), so form-focus-element above paints every border blue - // on keyboard focus. The group owns border coloring — it recolors outward - // edges per state and keeps inner seams neutral — so drop the neutral focus - // border here and let the group's higher-specificity rules win. - &.grouped { - border-color: awsui.$color-border-input-default; - } } } diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index d96d8e356ba..bc3089d494f 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -43,8 +43,6 @@ export default function ControlGroup({ className={clsx( styles.controls, styles[`controls-${direction}`], - isInvalid && styles['controls-invalid'], - isWarning && styles['controls-warning'], actionButton && styles['controls-with-action'], !actionButton && !inlineLabelText && rootClassnames )} diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index ba96111125d..72b3a1144f7 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -16,11 +16,7 @@ $margin-overlap: calc(-1 * #{awsui.$border-width-field}); .action-slot { display: flex; - // Butt against the last control's inline-end border: the control owns the seam, - // and the button paints no inline-start border, so the slot sits flush with no - // overlap (margin 0 keeps total width unchanged with the control's edge as the - // single 1px seam). - margin-inline-start: 0; + margin-inline-start: $margin-overlap; } .group-layout { @@ -52,15 +48,6 @@ $margin-overlap: calc(-1 * #{awsui.$border-width-field}); &-vertical { flex-direction: column; } - - // The group publishes the validation color via an inherited custom property - // set inline on this box; each control reads it on its outward-facing edges. - // These marker classes carry no styling — they only let test-utils detect the - // validation state. - &-invalid, - &-warning { - /* Marker only. */ - } } .control { diff --git a/src/internal/styles/forms/grouped-control.scss b/src/internal/styles/forms/grouped-control.scss index be90eb0c624..58a3dd2f92f 100644 --- a/src/internal/styles/forms/grouped-control.scss +++ b/src/internal/styles/forms/grouped-control.scss @@ -9,15 +9,12 @@ @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; $grouped-control-border-radius: constants.$control-border-radius; -// State classes the group adds to every control in a validation state. Error -// wins over warning (set in grouped-control-styles.ts), so they are mutually -// exclusive. Selecting the SCSS status token off these classes replaces the old -// published custom property. + $grouped-control-error-marker: '.grouped-in-error'; $grouped-control-warning-marker: '.grouped-in-warning'; -// Sets the given color on the OUTWARD-facing border edges of `$edges` — the -// edges that do NOT touch an adjacent control — so inner seams stay neutral. +// Sets the given color on the outward-facing border edges of `$edges`. +// Inner borders stay neutral. @mixin grouped-control-edge-color($edges, $color) { @if list.index($edges, inline-start) { border-inline-start-color: $color; @@ -33,44 +30,32 @@ $grouped-control-warning-marker: '.grouped-in-warning'; } } -// The group owns the validation state and drives it to every grouped control -// through the context-emitted `.grouped-in-error` / `.grouped-in-warning` class. -// Neutral controls get the input border color; the error/warning class selects -// the matching SCSS status token on the outward edges only. -@mixin grouped-control-outer-border-color($edges) { - @include grouped-control-edge-color($edges, awsui.$color-border-input-default); - // Assert the outward color once per state across base/hover/focus so the - // control's own neutral hover/focus border-color does not win. - &#{$grouped-control-error-marker}, - &#{$grouped-control-error-marker}:hover, - &#{$grouped-control-error-marker}:focus { - @include grouped-control-edge-color($edges, awsui.$color-text-status-error); - } - &#{$grouped-control-warning-marker}, - &#{$grouped-control-warning-marker}:hover, - &#{$grouped-control-warning-marker}:focus { - @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); - } -} - -// Keyboard focus runs through focus-visible.when-visible, whose -// body[data-awsui-focus-visible] descendant outranks a plain :focus. Re-assert -// the per-position outward validation color in that scope so it wins on keyboard -// focus too. The invalid ring is hoisted (position-independent) into the mixin -// body. `&` is the position class; the control also carries the state marker. -@mixin grouped-control-validation-focus($edges) { - &#{$grouped-control-error-marker} { - @include focus-visible.when-visible { - @include grouped-control-edge-color($edges, awsui.$color-text-status-error); +@mixin grouped-control-status-edges($edges) { + @each $marker, + $color + in ( + $grouped-control-error-marker: awsui.$color-text-status-error, + $grouped-control-warning-marker: awsui.$color-text-status-warning + ) + { + &#{$marker}, + &#{$marker}:hover, + &#{$marker}:focus { + @include grouped-control-edge-color($edges, $color); } - } - &#{$grouped-control-warning-marker} { - @include focus-visible.when-visible { - @include grouped-control-edge-color($edges, awsui.$color-text-status-warning); + &#{$marker} { + @include focus-visible.when-visible { + @include grouped-control-edge-color($edges, $color); + } } } } +@mixin grouped-control-outer-border-color($edges) { + @include grouped-control-edge-color($edges, awsui.$color-border-input-default); + @include grouped-control-status-edges($edges); +} + @mixin grouped-control-base { @include styles.logical-radius($grouped-control-border-radius); } @@ -101,60 +86,36 @@ $grouped-control-warning-marker: '.grouped-in-warning'; &-horizontal { &-first { @include grouped-control-horizontal-first; - // Outward edges: inline-start (group start) + both block edges. - // inline-end faces the next control, so it stays neutral. - $edges: (inline-start, block-start, block-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((inline-start, block-start, block-end)); } &-middle { @include grouped-control-middle; - // Only the block edges face outward; both inline edges face neighbors. - $edges: (block-start, block-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((block-start, block-end)); } &-last { @include grouped-control-horizontal-last; - // Outward edges: inline-end (group end) + both block edges. - $edges: (inline-end, block-start, block-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((inline-end, block-start, block-end)); } } &-vertical { &-first { @include grouped-control-vertical-first; - // Outward edges: block-start (group top) + both inline edges. - // block-end faces the next control, so it stays neutral. - $edges: (block-start, inline-start, inline-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((block-start, inline-start, inline-end)); } &-middle { @include grouped-control-middle; - // Only the inline edges face outward; both block edges face neighbors. - $edges: (inline-start, inline-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((inline-start, inline-end)); } &-last { @include grouped-control-vertical-last; - // Outward edges: block-end (group bottom) + both inline edges. - $edges: (block-end, inline-start, inline-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((block-end, inline-start, inline-end)); } } - // A sole control has every edge outward; axis is irrelevant, so both - // -only positions share one rule (identical edge set, base radius kept). &-horizontal-only, &-vertical-only { - $edges: (inline-start, inline-end, block-start, block-end); - @include grouped-control-outer-border-color($edges); - @include grouped-control-validation-focus($edges); + @include grouped-control-outer-border-color((inline-start, inline-end, block-start, block-end)); } &-with-action { @@ -176,13 +137,6 @@ $grouped-control-warning-marker: '.grouped-in-warning'; } } - // Content inset for the thick leading validation bar. The bar is drawn by the - // group's leading `.control` slot (control-group/styles.scss) as a flat
- // rectangle, so it cannot miter and renders for replaced-element controls - // (Input/Autosuggest) too. The inset is the bar width plus the invalid content - // gap, matching the standalone validation text position. Single-element - // controls (button-trigger, segmented-control) take the full inset here; Input - // overrides the wrapper (its inner native input already contributes cph). &#{&}-validation { padding-inline-start: calc(#{constants.$invalid-control-left-border} + #{constants.$invalid-control-left-padding}); } diff --git a/src/segmented-control/styles.scss b/src/segmented-control/styles.scss index aa5deb60e44..0f07763fa99 100644 --- a/src/segmented-control/styles.scss +++ b/src/segmented-control/styles.scss @@ -8,6 +8,7 @@ @use 'sass:list' as list; @use '../internal/styles/tokens' as awsui; @use '../internal/styles/utils/theming' as theming; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; @function repeatValue($value, $times) { $result: (); @@ -69,6 +70,17 @@ &.grouped-horizontal-last { border-inline-start: none; } + + // The focused inner segment option draws its own ring, so suppress the group's. + &.grouped-in-error, + &.grouped-in-warning { + &:focus-within { + box-shadow: none; + } + @include focus-visible.when-visible { + box-shadow: none; + } + } } .select {