From bfd7debf274e02efbaf6f407fce9fedfa7ae008b Mon Sep 17 00:00:00 2001 From: Jessica Kuelz <15003460+jkuelz@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:06:00 -0700 Subject: [PATCH 1/5] feat: support end-positioned expand icon in expandable section Add internal __expandIconPosition ('start' | 'end', default 'start') to InternalExpandableSection. When 'end', the expand caret is DOM-reordered to the inline-end (preserving focus order for accessibility) across all variants; the outside-header caret (container and default-with-actions) renders as an accessible button. Default 'start' behavior is unchanged and there is no public API change (internal __-prefixed prop). Groundwork for the side-navigation icon layout. Note: the navigation variant now wraps its header text in a span (styled only in the end layout), a minor DOM change for that side-nav-only variant. --- .../__tests__/expandable-section.test.tsx | 270 ++++++++++++++++++ .../expandable-section-header.tsx | 190 +++++++++--- src/expandable-section/internal.tsx | 8 + src/expandable-section/styles.scss | 96 +++++++ 4 files changed, 532 insertions(+), 32 deletions(-) diff --git a/src/expandable-section/__tests__/expandable-section.test.tsx b/src/expandable-section/__tests__/expandable-section.test.tsx index 06a20b7f65..502cd1f0c6 100644 --- a/src/expandable-section/__tests__/expandable-section.test.tsx +++ b/src/expandable-section/__tests__/expandable-section.test.tsx @@ -8,10 +8,13 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import '../../__a11y__/to-validate-a11y'; import Button from '../../../lib/components/button'; import ExpandableSection, { ExpandableSectionProps } from '../../../lib/components/expandable-section'; +import InternalExpandableSection from '../../../lib/components/expandable-section/internal'; import Header from '../../../lib/components/header'; import Link from '../../../lib/components/link'; import createWrapper, { ExpandableSectionWrapper } from '../../../lib/components/test-utils/dom'; +import styles from '../../../lib/components/expandable-section/styles.selectors.js'; + jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), warnOnce: jest.fn(), @@ -509,3 +512,270 @@ describe('headerText', () => { }); } }); + +describe('__expandIconPosition', () => { + function renderInternalExpandableSection( + props: Partial[0]> = {} + ): ExpandableSectionWrapper { + const { container } = render(); + return createWrapper(container).findExpandableSection()!; + } + + describe('default behavior (start position)', () => { + test('icon renders before header text for default variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'default' }); + const expandButton = wrapper.findExpandButton().getElement(); + const children = Array.from(expandButton.children); + const iconIndex = children.findIndex(el => el.classList.contains(styles['icon-container'])); + const textIndex = children.findIndex(el => el.classList.contains(styles['header-text'])); + expect(iconIndex).toBeLessThan(textIndex); + }); + + test('icon renders before header text for footer variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'footer' }); + const expandButton = wrapper.findExpandButton().getElement(); + const children = Array.from(expandButton.children); + const iconIndex = children.findIndex(el => el.classList.contains(styles['icon-container'])); + const textIndex = children.findIndex(el => el.classList.contains(styles['header-text'])); + expect(iconIndex).toBeLessThan(textIndex); + }); + + test('icon renders before header content for navigation variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'navigation' }); + const header = wrapper.findHeader().getElement(); + const children = Array.from(header.children); + const iconIndex = children.findIndex(el => el.classList.contains(styles['icon-container'])); + // Icon should be the first child (index 0) + expect(iconIndex).toBe(0); + }); + + test('does not apply icon-end modifier classes when position is start', () => { + const wrapper = renderInternalExpandableSection({ variant: 'footer', __expandIconPosition: 'start' }); + const icon = wrapper.findExpandIcon().getElement(); + expect(icon.classList.contains(styles['icon-container-end'])).toBe(false); + }); + }); + + describe('end position', () => { + test('icon renders after header text for footer variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'footer', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + const children = Array.from(expandButton.children); + const iconIndex = children.findIndex(el => el.classList.contains(styles['icon-container'])); + const textIndex = children.findIndex(el => el.classList.contains(styles['header-text'])); + expect(iconIndex).toBeGreaterThan(textIndex); + }); + + test('icon renders after header text for default variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'default', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + const children = Array.from(expandButton.children); + const iconIndex = children.findIndex(el => el.classList.contains(styles['icon-container'])); + const textIndex = children.findIndex(el => el.classList.contains(styles['header-text'])); + expect(iconIndex).toBeGreaterThan(textIndex); + }); + + test('icon renders after header content for navigation variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'navigation', __expandIconPosition: 'end' }); + const header = wrapper.findHeader().getElement(); + const children = Array.from(header.children); + const iconIndex = children.findIndex(el => el.classList.contains(styles['icon-container'])); + // Icon should be the last child + expect(iconIndex).toBe(children.length - 1); + }); + + test('icon renders after header text for container variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'container', __expandIconPosition: 'end' }); + // For container variant, icon is rendered outside InternalHeader + const icon = wrapper.findExpandIcon().getElement(); + expect(icon.classList.contains(styles['icon-container-end'])).toBe(true); + }); + + test('applies icon-container-end modifier class for footer variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'footer', __expandIconPosition: 'end' }); + const icon = wrapper.findExpandIcon().getElement(); + expect(icon.classList.contains(styles['icon-container-end'])).toBe(true); + }); + + test('applies header-icon-end modifier class for navigation variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'navigation', __expandIconPosition: 'end' }); + const header = wrapper.findHeader().getElement(); + expect(header.classList.contains(styles['header-icon-end'])).toBe(true); + }); + + test('applies header-button-icon-end modifier class for footer variant', () => { + const wrapper = renderInternalExpandableSection({ variant: 'footer', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + expect(expandButton.classList.contains(styles['header-button-icon-end'])).toBe(true); + }); + + test('icon renders outside header actions for default variant with headerActions', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'default', + __expandIconPosition: 'end', + headerActions: , + }); + // The icon should be rendered outside the header-content wrapper, + // as a direct child of the header-icon-end wrapper — matching the container variant pattern. + const header = wrapper.findHeader().getElement(); + const headerContent = header.querySelector(`.${styles['header-content']}`); + expect(headerContent).toBeTruthy(); + const icon = wrapper.findExpandIcon().getElement(); + // Icon should be a sibling of the header-content wrapper, not nested inside it. + expect(icon.parentElement).toBe(headerContent!.parentElement); + expect(icon.previousElementSibling).not.toBe(null); + }); + }); + + describe('expand/collapse functionality with end position', () => { + test('navigation variant toggles aria-expanded on click', () => { + const wrapper = renderInternalExpandableSection({ variant: 'navigation', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + wrapper.findExpandButton().click(); + expect(expandButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('footer variant toggles aria-expanded on click', () => { + const wrapper = renderInternalExpandableSection({ variant: 'footer', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + wrapper.findExpandButton().click(); + expect(expandButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('default variant toggles aria-expanded on click', () => { + const wrapper = renderInternalExpandableSection({ variant: 'default', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + wrapper.findExpandButton().click(); + expect(expandButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('container variant toggles aria-expanded on click', () => { + const wrapper = renderInternalExpandableSection({ variant: 'container', __expandIconPosition: 'end' }); + const expandButton = wrapper.findExpandButton().getElement(); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + wrapper.findExpandButton().click(); + expect(expandButton).toHaveAttribute('aria-expanded', 'true'); + }); + + test('content is shown when expanded with end position', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'footer', + __expandIconPosition: 'end', + defaultExpanded: true, + children: 'Expanded content', + }); + const expandedContent = wrapper.findExpandedContent()?.getElement(); + expect(expandedContent).toHaveTextContent('Expanded content'); + }); + }); + + describe('outside icon button (end position with renderIconOutsideHeader)', () => { + test('container variant renders outside caret as a button with aria-expanded', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'container', + __expandIconPosition: 'end', + headerText: 'Header', + }); + const iconButton = wrapper.findExpandIcon().getElement(); + expect(iconButton.tagName).toBe('BUTTON'); + expect(iconButton).toHaveAttribute('aria-expanded', 'false'); + expect(iconButton).toHaveAttribute('aria-controls'); + }); + + test('default variant with headerActions renders outside caret as a button with aria-expanded', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'default', + __expandIconPosition: 'end', + headerText: 'Header', + headerActions: , + }); + const iconButton = wrapper.findExpandIcon().getElement(); + expect(iconButton.tagName).toBe('BUTTON'); + expect(iconButton).toHaveAttribute('aria-expanded', 'false'); + expect(iconButton).toHaveAttribute('aria-controls'); + }); + + test('clicking the outside caret button toggles expansion for container variant', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'container', + __expandIconPosition: 'end', + headerText: 'Header', + children: 'Content', + }); + const iconButton = wrapper.findExpandIcon().getElement(); + expect(iconButton).toHaveAttribute('aria-expanded', 'false'); + iconButton.click(); + expect(iconButton).toHaveAttribute('aria-expanded', 'true'); + expect(wrapper.findExpandedContent()?.getElement()).toHaveTextContent('Content'); + }); + + test('clicking the outside caret button toggles expansion for default with actions', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'default', + __expandIconPosition: 'end', + headerText: 'Header', + headerActions: , + children: 'Content', + }); + const iconButton = wrapper.findExpandIcon().getElement(); + expect(iconButton).toHaveAttribute('aria-expanded', 'false'); + iconButton.click(); + expect(iconButton).toHaveAttribute('aria-expanded', 'true'); + expect(wrapper.findExpandedContent()?.getElement()).toHaveTextContent('Content'); + }); + + test('container without actions: clicking caret toggles exactly once (no double-toggle)', () => { + const onChange = jest.fn(); + const { container } = render( + + Content + + ); + const wrapper = createWrapper(container).findExpandableSection()!; + const iconButton = wrapper.findExpandIcon().getElement(); + iconButton.click(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { expanded: true } })); + }); + + test('outside caret button has aria-controls matching content region id', () => { + const wrapper = renderInternalExpandableSection({ + variant: 'container', + __expandIconPosition: 'end', + headerText: 'Header', + children: 'Content', + defaultExpanded: true, + }); + const iconButton = wrapper.findExpandIcon().getElement(); + const contentRegion = wrapper.findContent().getElement(); + expect(iconButton.getAttribute('aria-controls')).toBe(contentRegion.getAttribute('id')); + }); + + test('keyboard Enter on outside caret button toggles expansion', () => { + const onChange = jest.fn(); + const { container } = render( + + Content + + ); + const wrapper = createWrapper(container).findExpandableSection()!; + const iconButton = wrapper.findExpandIcon().getElement(); + // Native button handles Enter/Space natively, triggering click + iconButton.click(); + expect(onChange).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/expandable-section/expandable-section-header.tsx b/src/expandable-section/expandable-section-header.tsx index 2d8192d0a2..85700248f7 100644 --- a/src/expandable-section/expandable-section-header.tsx +++ b/src/expandable-section/expandable-section-header.tsx @@ -27,6 +27,54 @@ import styles from './styles.css.js'; const componentName = 'ExpandableSection'; +interface ExpandIconButtonProps { + icon: JSX.Element; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaControls: string; + expanded: boolean; + onClick: MouseEventHandler; + stopPropagation?: boolean; + className?: string; +} + +/** + * Standalone expand/collapse caret button used when the icon renders OUTSIDE + * the main clickable header area (navigation variant) or outside the headerButton + * (end-position with actions/container). This is always an independent interactive + * element with its own ARIA attributes. + */ +const ExpandIconButton = ({ + icon, + ariaLabel, + ariaLabelledBy, + ariaControls, + expanded, + onClick, + stopPropagation, + className, +}: ExpandIconButtonProps) => ( + +); + interface ExpandableDefaultHeaderProps { id: string; descriptionId?: string; @@ -40,6 +88,7 @@ interface ExpandableDefaultHeaderProps { onClick: MouseEventHandler; icon: JSX.Element; variant: InternalVariant; + expandIconPosition?: 'start' | 'end'; } interface ExpandableNavigationHeaderProps extends Omit { @@ -125,22 +174,40 @@ const ExpandableNavigationHeader = ({ expanded, children, icon, + expandIconPosition = 'start', }: ExpandableNavigationHeaderProps) => { + const expandButton = ( + + ); return ( -
- - {children} +
+ {expandIconPosition === 'end' ? ( + <> + {children} + {expandButton} + + ) : ( + <> + {expandButton} + {children} + + )}
); }; @@ -163,6 +230,7 @@ const ExpandableHeaderTextWrapper = ({ headingTagOverride, onKeyUp, onKeyDown, + expandIconPosition = 'start', }: ExpandableHeaderTextWrapperProps) => { const isContainer = variant === 'container'; const HeadingTag = headingTagOverride || 'div'; @@ -175,6 +243,11 @@ const ExpandableHeaderTextWrapper = ({ ); const listeners = { onClick, onKeyDown, onKeyUp }; + const iconAtEnd = expandIconPosition === 'end'; + // For the variants that allow actions, when end placement is active, the icon is rendered outside the + // InternalHeader so it appears at the inline-end of the wrapper, past any header actions. + // Other variants render the icon at the end of the headerButton itself. + const renderIconOutsideHeader = iconAtEnd && (isContainer || !!actions); // If interactive elements are present, constrain the clickable area to only the icon and the header text // to prevent nesting interactive elements. @@ -185,11 +258,49 @@ const ExpandableHeaderTextWrapper = ({ const headingTagListeners = !headerButtonListeners && !isContainer && description ? listeners : undefined; // For all other cases, make the entire header clickable for backwards compatibility. const wrapperListeners = !headerButtonListeners && !headingTagListeners ? listeners : undefined; + + // Standalone caret button — used when the icon is rendered OUTSIDE the headerButton + // (end-position with container or actions). Must be its own + + Information + , + ], + }, +]); export default function ExpandableSectionPermutations() { return ( @@ -166,6 +186,14 @@ export default function ExpandableSectionPermutations() {

Expandable Section permutations

} /> + ( + + Variant {permutation.variant} section content + + )} + /> ); diff --git a/pages/expandable-section/test.page.tsx b/pages/expandable-section/test.page.tsx index 4ad5b58040..fb7b90c481 100644 --- a/pages/expandable-section/test.page.tsx +++ b/pages/expandable-section/test.page.tsx @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; +import Button from '~components/button'; import ExpandableSection from '~components/expandable-section'; +import InternalExpandableSection from '~components/expandable-section/internal'; export default function ExpandableSectionTestPage() { return ( @@ -15,6 +17,28 @@ export default function ExpandableSectionTestPage() { After you enable your S3 bucket for static website hosting, web browsers can access your content through the Amazon S3 website endpoint for the bucket. + +
+ Action} + > + Content of the end-position expandable section used for keyboard toggle and focus tests. + +
+ +
+ Action} + > + Content of the wrapping-header end-position expandable section used for the below-caret click test. + +
); } diff --git a/src/expandable-section/__integ__/expandable-section.test.ts b/src/expandable-section/__integ__/expandable-section.test.ts index 6ea2b608b5..057a3c4ac6 100644 --- a/src/expandable-section/__integ__/expandable-section.test.ts +++ b/src/expandable-section/__integ__/expandable-section.test.ts @@ -5,6 +5,8 @@ import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; import createWrapper from '../../../lib/components/test-utils/selectors'; +import styles from '../../../lib/components/expandable-section/styles.selectors.js'; + const focusTargetSelector = '#focus-target'; const expandableSectionWrapper = createWrapper().findExpandableSection(); @@ -37,3 +39,141 @@ describe('Expandable Section', () => { }) ); }); + +// --- End-position integration tests --- +// These sections use headerActions to trigger the OUTSIDE caret button path +// (renderIconOutsideHeader = iconAtEnd && (isContainer || !!actions)). +// The outside , }); - const iconButton = wrapper.findExpandIcon().getElement(); - expect(iconButton.tagName).toBe('BUTTON'); - expect(iconButton).toHaveAttribute('aria-expanded', 'false'); - expect(iconButton).toHaveAttribute('aria-controls'); + const caret = wrapper.findExpandIcon().getElement(); + // Native -); +}: ExpandIconButtonProps) => { + const stopKeyPropagation = stopPropagation + ? (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.stopPropagation(); + } + } + : undefined; + return ( + + ); +}; interface ExpandableDefaultHeaderProps { id: string; diff --git a/src/expandable-section/styles.scss b/src/expandable-section/styles.scss index 48e3e90964..9ea3c929eb 100644 --- a/src/expandable-section/styles.scss +++ b/src/expandable-section/styles.scss @@ -378,6 +378,11 @@ $icon-total-space-medium: calc(#{$icon-width-medium} + #{$icon-margin-left} + #{ margin-inline-end: 0; flex-shrink: 0; + // Stretch the button to fill the parent's block-size so the entire caret + // column is clickable/hoverable, matching start-position behavior. + align-self: stretch; + align-items: flex-start; + // Reset native