Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/container/__tests__/accessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Container header="Header">content</Container>);
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(<Container fitHeight={true}>content</Container>);
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(
<Container fitHeight={true}>
<button>action</button>
</Container>
);
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(<Container fitHeight={true}>static content</Container>);
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);
}
}
});
});
39 changes: 37 additions & 2 deletions src/container/internal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -105,6 +105,34 @@ export default function InternalContainer({
__fullPage && isRefresh && !isMobile
);
const contentId = useUniqueId();
const contentRef = useRef<HTMLDivElement>(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';

Expand Down Expand Up @@ -190,7 +218,14 @@ export default function InternalContainer({
</StickyHeaderContext.Provider>
</ContainerHeaderContextProvider>
)}
<div className={clsx(styles.content, fitHeight && styles['content-fit-height'])}>
{/* 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. */}
<div
ref={contentRef}
className={clsx(styles.content, fitHeight && styles['content-fit-height'])}
{...(fitHeight && isScrollable && !hasFocusableDescendant && { tabIndex: 0 })}
>
<div
className={clsx(styles['content-inner'], testStyles['content-inner'], {
[styles['with-paddings']]: !disableContentPaddings,
Expand Down
10 changes: 7 additions & 3 deletions src/container/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,16 @@
flex: 1;
&-fit-height {
overflow: auto;
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: 1fr;
grid-template-columns: minmax(0, 1fr);
}
}
.content-inner {
flex: 1;
box-sizing: border-box;
block-size: 100%;
min-block-size: 0;
min-inline-size: 0;

&.with-paddings {
padding-block: awsui.$space-scaled-l;
Expand Down
Loading