diff --git a/src/container/__tests__/accessibility.test.tsx b/src/container/__tests__/accessibility.test.tsx new file mode 100644 index 0000000000..9352f485c9 --- /dev/null +++ b/src/container/__tests__/accessibility.test.tsx @@ -0,0 +1,72 @@ +// 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 }; +} + +// 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 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 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).not.toHaveAttribute('tabindex'); + expect(contentDiv).not.toHaveAttribute('role'); + expect(contentDiv).not.toHaveAttribute('aria-labelledby'); + }); + + test('fitHeight=true with a focusable descendant: still no tabIndex', () => { + 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 fd69f793ae..aa6f51a36b 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,6 +105,34 @@ export default function InternalContainer({ __fullPage && isRefresh && !isMobile ); const contentId = useUniqueId(); + const contentRef = useRef(null); + const [hasFocusableDescendant, setHasFocusableDescendant] = useState(false); + const [isScrollable, setIsScrollable] = useState(false); + + // 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(() => { + 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"])'; + 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'; @@ -190,7 +218,14 @@ export default function InternalContainer({ )} -
+ {/* tabIndex={0} makes the scrollable region keyboard-reachable (axe: scrollable-region-focusable). + 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. */} +