From c2e5f69c366d7864721a43a36a02a1334f1a2802 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Sun, 28 Jun 2026 00:23:20 +0200 Subject: [PATCH 1/5] fix: Replace flex with CSS grid in container fit-height to stop chart oscillation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3018 (AWSUI-54814) introduced a flex column layout on .content-fit-height with a flex:1 inner wrapper to fix the table sticky scrollbar floating above the table content. Flex's sub-pixel space distribution makes the inner wrapper's content-box width oscillate by fractions of a pixel under certain conditions, which feeds back into LabelsMeasure's ResizeObserver and causes 'Maximum update depth exceeded' in legacy charts (BarChart, MixedLineBarChart, PieChart) inside fit-height containers — most visibly inside board-components BoardItem. CSS Grid with grid-template-rows: 1fr uses an integer-pixel track sizing algorithm, so the inner wrapper gets a stable integer width and the ResizeObserver feedback loop terminates after one cycle. The structural change (outer scrolling wrapper, inner padding wrapper) is preserved, so the original AWSUI-54814 sticky scrollbar fix continues to work. Bisect notes (board-components content-permutations dev page): components 3.0.836 -> CLEAN components 3.0.837 -> BROKEN (PR #3018 lands) components HEAD with Option D (CSS Grid) -> CLEAN components HEAD with min-block-size: 100% (no flex) -> STILL OSCILLATES components HEAD with flex: 1 1 auto -> STILL OSCILLATES Rel: AWSUI-62091 Rel: AWSUI-54814 (regression guard) --- src/container/styles.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/container/styles.scss b/src/container/styles.scss index c37e6a61aa..1041a712dc 100644 --- a/src/container/styles.scss +++ b/src/container/styles.scss @@ -256,12 +256,12 @@ flex: 1; &-fit-height { overflow: auto; - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: 1fr; } } .content-inner { - flex: 1; + min-block-size: 0; &.with-paddings { padding-block: awsui.$space-scaled-l; From 9922fc0c8d2e4524a99c4eb370528d84095f411a Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Wed, 1 Jul 2026 02:03:57 +0200 Subject: [PATCH 2/5] fix: Restore definite block-size and constrained inline size for fit-height inner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the CSS Grid switch. Two related sizing issues surfaced in the board-components content-permutations screenshot tests once the inner wrapper became a grid item: 1. Vertical: legacy charts inside fitHeight use a nested and measure its rendered height via useHeightMeasure. With min-block-size: 0 alone on the grid item, the inner wrapper's block-size is indefinite for percentage resolution, so the SVG falls back to its intrinsic viewport height. That produces a plot much larger than the widget and pushes the bars/legend off-screen. Fix: add block-size: 100% so descendants have a definite parent height to resolve against — matches the resolved-size behavior of the original flex: 1 layout without reintroducing flex's sub-pixel width distribution. 2. Horizontal: without an explicit column track, the grid falls back to an implicit auto column that grows to the widest child's max-content, letting wide inner content overflow the container instead of being scrolled by overflow: auto. Fix: grid-template-columns: minmax(0, 1fr) on the outer + min-inline- size: 0 on the inner, which replicates flex's align-items: stretch: the inner is exactly one parent width, wide content overflows the inner, and .content-fit-height's overflow: auto handles scrolling. Verified against board-components content-permutations page (Firefox screenshot test AWS-UI-IntegrationTests board-components-Firefox-local- light-console-comfortable). Rel: AWSUI-62091 --- .../__tests__/accessibility.test.tsx | 51 +++++++++++++++++++ src/container/internal.tsx | 11 +++- src/container/styles.scss | 3 ++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/container/__tests__/accessibility.test.tsx diff --git a/src/container/__tests__/accessibility.test.tsx b/src/container/__tests__/accessibility.test.tsx new file mode 100644 index 0000000000..fc9da96d53 --- /dev/null +++ b/src/container/__tests__/accessibility.test.tsx @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Container from '../../../lib/components/container'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/container/styles.css.js'; + +function renderContainer(jsx: React.ReactElement) { + const { container } = render(jsx); + return { wrapper: createWrapper(container).findContainer()!, container }; +} + +describe('Accessibility: scrollable-region-focusable (fitHeight)', () => { + test('fitHeight=false renders no tabIndex, role, or aria-labelledby on the content wrapper', () => { + const { wrapper } = renderContainer(content); + const contentDiv = wrapper.findByClassName(styles.content)!.getElement(); + expect(contentDiv).not.toHaveAttribute('tabindex'); + expect(contentDiv).not.toHaveAttribute('role'); + expect(contentDiv).not.toHaveAttribute('aria-labelledby'); + }); + + test('fitHeight=true with no header: tabIndex=0, no role, no aria-labelledby', () => { + const { wrapper } = renderContainer(content); + const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); + expect(contentDiv).toHaveAttribute('tabindex', '0'); + expect(contentDiv).not.toHaveAttribute('role'); + expect(contentDiv).not.toHaveAttribute('aria-labelledby'); + }); + + test('fitHeight=true with header: tabIndex=0, role="region", aria-labelledby resolves to header wrapper', () => { + const { wrapper, container } = renderContainer( + + content + + ); + const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); + expect(contentDiv).toHaveAttribute('tabindex', '0'); + expect(contentDiv).toHaveAttribute('role', 'region'); + + const labelledById = contentDiv.getAttribute('aria-labelledby'); + expect(labelledById).toBeTruthy(); + + // Verify the referenced element exists in the DOM and is the header wrapper + const headerElement = container.querySelector(`#${labelledById}`); + expect(headerElement).not.toBeNull(); + expect(headerElement).toHaveClass(styles.header); + }); +}); diff --git a/src/container/internal.tsx b/src/container/internal.tsx index fd69f793ae..f48c9153a4 100644 --- a/src/container/internal.tsx +++ b/src/container/internal.tsx @@ -105,6 +105,7 @@ export default function InternalContainer({ __fullPage && isRefresh && !isMobile ); const contentId = useUniqueId(); + const headerId = useUniqueId('awsui-container-header-'); const hasDynamicHeight = isRefresh && variant === 'full-page'; @@ -162,6 +163,7 @@ export default function InternalContainer({
)} -
+ {/* tabIndex={0} makes the scrollable region keyboard-reachable (axe: scrollable-region-focusable). + role="region" + aria-labelledby is conditional on header presence because an unnamed region + (no accessible name) is worse than no landmark at all. */} +
Date: Thu, 9 Jul 2026 20:17:33 +0200 Subject: [PATCH 3/5] fix: Correct fit-height a11y and content-inner box-sizing Two coordinated corrections on top of 6d7985c06: 1. Add box-sizing: border-box to .content-inner so that block-size:100% + padding no longer overflows the grid track. Cloudscape's styles-reset only applies border-box on .root; .content-inner defaulted to content-box, producing (100% + 2*padding) total height and firing overflow:auto with the visual scrollbar seen on configurable dashboard widgets. 2. Rework the earlier scrollable-region-focusable fix: - Drop role=region + aria-labelledby (never contributed to axe rule pass; caused landmark-unique failures on permutations pages with duplicate header text). - Make tabIndex={0} conditional on absence of focusable descendants. axe scrollable-region-focusable passes when EITHER tabindex>=0 OR the region has a focusable descendant; preferring the descendant path avoids inserting an extra tab stop that broke board-layout tab-order tests. - Update accessibility.test.tsx to cover the focusable-descendant case. Rel: AWSUI-62091 --- .../__tests__/accessibility.test.tsx | 27 +++++++++++-------- src/container/internal.tsx | 27 ++++++++++++++----- src/container/styles.scss | 1 + 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/container/__tests__/accessibility.test.tsx b/src/container/__tests__/accessibility.test.tsx index fc9da96d53..450cfcb4dc 100644 --- a/src/container/__tests__/accessibility.test.tsx +++ b/src/container/__tests__/accessibility.test.tsx @@ -22,7 +22,7 @@ describe('Accessibility: scrollable-region-focusable (fitHeight)', () => { expect(contentDiv).not.toHaveAttribute('aria-labelledby'); }); - test('fitHeight=true with no header: tabIndex=0, no role, no aria-labelledby', () => { + test('fitHeight=true with no header and no focusable descendants: tabIndex=0, no role, no aria-labelledby', () => { const { wrapper } = renderContainer(content); const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); expect(contentDiv).toHaveAttribute('tabindex', '0'); @@ -30,22 +30,27 @@ describe('Accessibility: scrollable-region-focusable (fitHeight)', () => { expect(contentDiv).not.toHaveAttribute('aria-labelledby'); }); - test('fitHeight=true with header: tabIndex=0, role="region", aria-labelledby resolves to header wrapper', () => { - const { wrapper, container } = renderContainer( + test('fitHeight=true with header and no focusable descendants: tabIndex=0, no role, no aria-labelledby', () => { + const { wrapper } = renderContainer( content ); const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); expect(contentDiv).toHaveAttribute('tabindex', '0'); - expect(contentDiv).toHaveAttribute('role', 'region'); - - const labelledById = contentDiv.getAttribute('aria-labelledby'); - expect(labelledById).toBeTruthy(); + expect(contentDiv).not.toHaveAttribute('role'); + expect(contentDiv).not.toHaveAttribute('aria-labelledby'); + }); - // Verify the referenced element exists in the DOM and is the header wrapper - const headerElement = container.querySelector(`#${labelledById}`); - expect(headerElement).not.toBeNull(); - expect(headerElement).toHaveClass(styles.header); + test('fitHeight=true with a focusable descendant: no tabIndex, no role, no aria-labelledby', () => { + const { wrapper } = renderContainer( + + + + ); + const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); + expect(contentDiv).not.toHaveAttribute('tabindex'); + expect(contentDiv).not.toHaveAttribute('role'); + expect(contentDiv).not.toHaveAttribute('aria-labelledby'); }); }); diff --git a/src/container/internal.tsx b/src/container/internal.tsx index f48c9153a4..e8979bc3c8 100644 --- a/src/container/internal.tsx +++ b/src/container/internal.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useRef } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; @@ -105,7 +105,21 @@ export default function InternalContainer({ __fullPage && isRefresh && !isMobile ); const contentId = useUniqueId(); - const headerId = useUniqueId('awsui-container-header-'); + const contentRef = useRef(null); + const [hasFocusableDescendant, setHasFocusableDescendant] = useState(false); + + // axe scrollable-region-focusable passes when EITHER the scrollable element has tabindex≥0 + // OR it contains a focusable descendant. We prefer the descendant path to avoid breaking + // downstream tab order — only add tabIndex when no focusable child exists. + useLayoutEffect(() => { + if (!fitHeight || !contentRef.current) { + setHasFocusableDescendant(false); + return; + } + const focusableSelector = + 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + setHasFocusableDescendant(contentRef.current.querySelector(focusableSelector) !== null); + }, [fitHeight, children]); const hasDynamicHeight = isRefresh && variant === 'full-page'; @@ -163,7 +177,6 @@ export default function InternalContainer({
)} {/* tabIndex={0} makes the scrollable region keyboard-reachable (axe: scrollable-region-focusable). - role="region" + aria-labelledby is conditional on header presence because an unnamed region - (no accessible name) is worse than no landmark at all. */} + We only add tabIndex when no focusable descendant exists — descendant focusability satisfies + the rule and avoids inserting an extra tab stop that breaks downstream tab order. */}
Date: Thu, 9 Jul 2026 22:36:38 +0200 Subject: [PATCH 4/5] fix: Only add tabIndex when fit-height content actually scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board-components widgets (BoardItem) wrap a Container whose content never actually overflows — sizing to content, not fit-container. Our previous conditional (tabIndex when no focusable descendant) still added a tab stop on those containers because static-content items have no interactive descendants, breaking board-layout tab-order tests (item J unreachable in 3 tabs). Mirror axe scrollable-region-focusable's runtime evaluation instead: only apply tabIndex when the element is genuinely scrollable (scrollHeight > clientHeight OR scrollWidth > clientWidth) AND has no focusable descendant. Non-overflowing fit-height containers get no tabIndex, preserving downstream tab order. Dashboard widgets with static content that overflows still get tabIndex, satisfying the axe rule. Uses ResizeObserver on the content-fit-height element to re-evaluate whenever the box changes size. Test file uses scrollHeight/clientHeight property mocking to cover the tabIndex-applied path in JSDOM. Rel: AWSUI-62091 --- .../__tests__/accessibility.test.tsx | 46 ++++++++++++------- src/container/internal.tsx | 29 ++++++++---- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/container/__tests__/accessibility.test.tsx b/src/container/__tests__/accessibility.test.tsx index 450cfcb4dc..630cb12557 100644 --- a/src/container/__tests__/accessibility.test.tsx +++ b/src/container/__tests__/accessibility.test.tsx @@ -13,8 +13,12 @@ function renderContainer(jsx: React.ReactElement) { return { wrapper: createWrapper(container).findContainer()!, container }; } +// Regression: Container's fit-height wrapper must be keyboard-reachable when it +// actually scrolls AND has no focusable descendant (axe scrollable-region-focusable), +// but must not insert an extra tab stop otherwise (breaks downstream tab order in +// consumers such as board-components widget items). describe('Accessibility: scrollable-region-focusable (fitHeight)', () => { - test('fitHeight=false renders no tabIndex, role, or aria-labelledby on the content wrapper', () => { + test('fitHeight=false renders no tabIndex on the content wrapper', () => { const { wrapper } = renderContainer(content); const contentDiv = wrapper.findByClassName(styles.content)!.getElement(); expect(contentDiv).not.toHaveAttribute('tabindex'); @@ -22,35 +26,43 @@ describe('Accessibility: scrollable-region-focusable (fitHeight)', () => { expect(contentDiv).not.toHaveAttribute('aria-labelledby'); }); - test('fitHeight=true with no header and no focusable descendants: tabIndex=0, no role, no aria-labelledby', () => { + test('fitHeight=true but not actually scrolling: no tabIndex (JSDOM reports zero scroll dimensions)', () => { const { wrapper } = renderContainer(content); const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); - expect(contentDiv).toHaveAttribute('tabindex', '0'); + expect(contentDiv).not.toHaveAttribute('tabindex'); expect(contentDiv).not.toHaveAttribute('role'); expect(contentDiv).not.toHaveAttribute('aria-labelledby'); }); - test('fitHeight=true with header and no focusable descendants: tabIndex=0, no role, no aria-labelledby', () => { + test('fitHeight=true with a focusable descendant: still no tabIndex', () => { const { wrapper } = renderContainer( - - content + + ); const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); - expect(contentDiv).toHaveAttribute('tabindex', '0'); + expect(contentDiv).not.toHaveAttribute('tabindex'); expect(contentDiv).not.toHaveAttribute('role'); expect(contentDiv).not.toHaveAttribute('aria-labelledby'); }); - test('fitHeight=true with a focusable descendant: no tabIndex, no role, no aria-labelledby', () => { - const { wrapper } = renderContainer( - - - - ); - const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); - expect(contentDiv).not.toHaveAttribute('tabindex'); - expect(contentDiv).not.toHaveAttribute('role'); - expect(contentDiv).not.toHaveAttribute('aria-labelledby'); + test('fitHeight=true with mocked scroll overflow and no focusable descendant: tabIndex=0', () => { + // Simulate real browser behavior where scrollHeight > clientHeight on the fit-height wrapper. + // We patch these properties on any element that receives the .content-fit-height class after + // render, then force a resize to re-run the useLayoutEffect via ResizeObserver. + const origScroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); + const origClient = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'); + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, get: () => 300 }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 100 }); + try { + const { wrapper } = renderContainer(static content); + const contentDiv = wrapper.findByClassName(styles['content-fit-height'])!.getElement(); + expect(contentDiv).toHaveAttribute('tabindex', '0'); + expect(contentDiv).not.toHaveAttribute('role'); + expect(contentDiv).not.toHaveAttribute('aria-labelledby'); + } finally { + if (origScroll) Object.defineProperty(HTMLElement.prototype, 'scrollHeight', origScroll); + if (origClient) Object.defineProperty(HTMLElement.prototype, 'clientHeight', origClient); + } }); }); diff --git a/src/container/internal.tsx b/src/container/internal.tsx index e8979bc3c8..aa6f51a36b 100644 --- a/src/container/internal.tsx +++ b/src/container/internal.tsx @@ -107,18 +107,31 @@ export default function InternalContainer({ const contentId = useUniqueId(); const contentRef = useRef(null); const [hasFocusableDescendant, setHasFocusableDescendant] = useState(false); + const [isScrollable, setIsScrollable] = useState(false); - // axe scrollable-region-focusable passes when EITHER the scrollable element has tabindex≥0 - // OR it contains a focusable descendant. We prefer the descendant path to avoid breaking - // downstream tab order — only add tabIndex when no focusable child exists. + // axe scrollable-region-focusable fires only when an element is actually + // scrollable at runtime AND lacks keyboard access. We mirror that check: + // add tabIndex only when (a) the element genuinely overflows and (b) no + // focusable descendant already satisfies the rule. This avoids inserting + // an extra tab stop for non-scrolling fit-height containers (e.g. board + // widget items) whose downstream tab-order expectations would break. useLayoutEffect(() => { - if (!fitHeight || !contentRef.current) { + const el = contentRef.current; + if (!fitHeight || !el) { setHasFocusableDescendant(false); + setIsScrollable(false); return; } const focusableSelector = 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; - setHasFocusableDescendant(contentRef.current.querySelector(focusableSelector) !== null); + const update = () => { + setHasFocusableDescendant(el.querySelector(focusableSelector) !== null); + setIsScrollable(el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth); + }; + update(); + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(el); + return () => resizeObserver.disconnect(); }, [fitHeight, children]); const hasDynamicHeight = isRefresh && variant === 'full-page'; @@ -206,12 +219,12 @@ export default function InternalContainer({ )} {/* tabIndex={0} makes the scrollable region keyboard-reachable (axe: scrollable-region-focusable). - We only add tabIndex when no focusable descendant exists — descendant focusability satisfies - the rule and avoids inserting an extra tab stop that breaks downstream tab order. */} + Only apply when the element is actually scrollable AND has no focusable descendant — matches + axe's runtime evaluation and avoids inserting an extra tab stop into non-overflowing consumers. */}
Date: Thu, 9 Jul 2026 22:50:24 +0200 Subject: [PATCH 5/5] fix: Add curly braces to satisfy ESLint curly rule in accessibility test --- src/container/__tests__/accessibility.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/container/__tests__/accessibility.test.tsx b/src/container/__tests__/accessibility.test.tsx index 630cb12557..9352f485c9 100644 --- a/src/container/__tests__/accessibility.test.tsx +++ b/src/container/__tests__/accessibility.test.tsx @@ -61,8 +61,12 @@ describe('Accessibility: scrollable-region-focusable (fitHeight)', () => { expect(contentDiv).not.toHaveAttribute('role'); expect(contentDiv).not.toHaveAttribute('aria-labelledby'); } finally { - if (origScroll) Object.defineProperty(HTMLElement.prototype, 'scrollHeight', origScroll); - if (origClient) Object.defineProperty(HTMLElement.prototype, 'clientHeight', origClient); + if (origScroll) { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', origScroll); + } + if (origClient) { + Object.defineProperty(HTMLElement.prototype, 'clientHeight', origClient); + } } }); });