Skip to content

Commit f1e9235

Browse files
feat: add headerSecondaryContent and hideHeaderText to ExpandableSection (#5088)
1 parent a51116b commit f1e9235

16 files changed

Lines changed: 765 additions & 51 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState } from 'react';
4+
5+
import Badge from '~components/badge';
6+
import Box from '~components/box';
7+
import Button from '~components/button';
8+
import ButtonGroup from '~components/button-group';
9+
import ExpandableSection from '~components/expandable-section';
10+
import Input from '~components/input';
11+
import Link from '~components/link';
12+
import Popover from '~components/popover';
13+
import Select, { SelectProps } from '~components/select';
14+
import SpaceBetween from '~components/space-between';
15+
16+
import { SimplePage } from '../app/templates';
17+
18+
const languageOptions: SelectProps.Options = [
19+
{ label: 'SQL', value: 'sql' },
20+
{ label: 'PPL', value: 'ppl' },
21+
{ label: 'Logstash', value: 'logstash' },
22+
];
23+
24+
function StandardExample() {
25+
return (
26+
<ExpandableSection variant="container" defaultExpanded={true} headerText="Standard expandable section">
27+
Content
28+
</ExpandableSection>
29+
);
30+
}
31+
32+
function CreateQueryExample() {
33+
const [expanded, setExpanded] = useState(true);
34+
const [language, setLanguage] = useState<SelectProps.Option>(languageOptions[0]);
35+
return (
36+
<ExpandableSection
37+
variant="container"
38+
headerText="Create query"
39+
expanded={expanded}
40+
onChange={({ detail }) => setExpanded(detail.expanded)}
41+
headerSecondaryContent={
42+
<Select
43+
selectedOption={language}
44+
onChange={({ detail }) => setLanguage(detail.selectedOption)}
45+
options={languageOptions}
46+
ariaLabel="Query language"
47+
/>
48+
}
49+
headerActions={
50+
expanded ? (
51+
<SpaceBetween direction="horizontal" size="xs">
52+
<ButtonGroup
53+
ariaLabel="Query actions"
54+
variant="icon"
55+
items={[
56+
{ type: 'icon-button', id: 'copy', iconName: 'copy', text: 'Copy' },
57+
{ type: 'icon-button', id: 'remove', iconName: 'remove', text: 'Clear' },
58+
]}
59+
/>
60+
<Button>Save</Button>
61+
<Button variant="primary">Run</Button>
62+
</SpaceBetween>
63+
) : (
64+
<Button variant="primary">Run</Button>
65+
)
66+
}
67+
>
68+
<Box color="text-body-secondary">SELECT * FROM logs WHERE status = 500 ORDER BY timestamp DESC LIMIT 100</Box>
69+
</ExpandableSection>
70+
);
71+
}
72+
73+
function DescriptionAsSecondaryContentExample() {
74+
return (
75+
<ExpandableSection
76+
variant="container"
77+
defaultExpanded={true}
78+
headerText="Production database"
79+
headerSecondaryContent={<Box variant="small">Last synced 3 minutes ago</Box>}
80+
headerActions={<Button>Refresh</Button>}
81+
>
82+
<Box color="text-body-secondary">
83+
Supporting text can live in the secondary content slot next to the header text, instead of the description slot
84+
below it.
85+
</Box>
86+
</ExpandableSection>
87+
);
88+
}
89+
90+
function HiddenHeaderTextExample() {
91+
const sectionName = 'Data source A';
92+
const [language, setLanguage] = useState<SelectProps.Option>(languageOptions[0]);
93+
return (
94+
<ExpandableSection
95+
variant="container"
96+
defaultExpanded={true}
97+
headerText={sectionName}
98+
hideHeaderText={true}
99+
headerSecondaryContent={
100+
<SpaceBetween direction="horizontal" size="xs" alignItems="center">
101+
<Badge color="severity-low">
102+
<Box padding={{ vertical: 'xxs' }} color="inherit">
103+
<Popover content="Edit name...">
104+
<Box fontSize="heading-m" color="inherit">
105+
A
106+
</Box>
107+
</Popover>
108+
</Box>
109+
</Badge>
110+
<Select
111+
selectedOption={language}
112+
onChange={({ detail }) => setLanguage(detail.selectedOption)}
113+
options={languageOptions}
114+
ariaLabel="Query language"
115+
/>
116+
</SpaceBetween>
117+
}
118+
>
119+
<Box color="text-body-secondary">Configuration for {sectionName}.</Box>
120+
</ExpandableSection>
121+
);
122+
}
123+
124+
function HiddenHeaderTextSearchExample() {
125+
const [value, setValue] = useState('');
126+
return (
127+
<ExpandableSection
128+
variant="container"
129+
defaultExpanded={true}
130+
headerText="Search results"
131+
hideHeaderText={true}
132+
headerSecondaryContent={
133+
<Input
134+
type="search"
135+
value={value}
136+
onChange={({ detail }) => setValue(detail.value)}
137+
placeholder="Search results"
138+
ariaLabel="Search results"
139+
/>
140+
}
141+
>
142+
<Box color="text-body-secondary">Results matching “{value || 'your query'}”.</Box>
143+
</ExpandableSection>
144+
);
145+
}
146+
147+
function AllHeaderSlotsExample() {
148+
const [language, setLanguage] = useState<SelectProps.Option>(languageOptions[0]);
149+
return (
150+
<ExpandableSection
151+
variant="container"
152+
defaultExpanded={true}
153+
headerText="Queries"
154+
headerCounter="(3)"
155+
headerInfo={<Link variant="info">Info</Link>}
156+
headerDescription="Saved queries for the production log group."
157+
headerSecondaryContent={
158+
<Select
159+
selectedOption={language}
160+
onChange={({ detail }) => setLanguage(detail.selectedOption)}
161+
options={languageOptions}
162+
ariaLabel="Query language"
163+
/>
164+
}
165+
headerActions={<Button variant="primary">Run</Button>}
166+
>
167+
<Box color="text-body-secondary">Counter, info link, description, secondary content and actions together.</Box>
168+
</ExpandableSection>
169+
);
170+
}
171+
172+
function LongContentExample() {
173+
const [language, setLanguage] = useState<SelectProps.Option>(languageOptions[0]);
174+
return (
175+
<ExpandableSection
176+
variant="container"
177+
defaultExpanded={true}
178+
headerText="Create query for the aggregated multi-region production log analytics dataset"
179+
headerSecondaryContent={
180+
<Select
181+
selectedOption={language}
182+
onChange={({ detail }) => setLanguage(detail.selectedOption)}
183+
options={languageOptions}
184+
ariaLabel="Query language"
185+
/>
186+
}
187+
headerActions={
188+
<SpaceBetween direction="horizontal" size="xs">
189+
<Button>Save</Button>
190+
<Button variant="primary">Run</Button>
191+
</SpaceBetween>
192+
}
193+
>
194+
<Box color="text-body-secondary">Stress case: long header text next to an interactive control and actions.</Box>
195+
</ExpandableSection>
196+
);
197+
}
198+
199+
export default function ExpandableSectionHeaderSecondaryContentPage() {
200+
return (
201+
<SimplePage title="Expandable section — header secondary content" screenshotArea={{}}>
202+
<SpaceBetween size="l">
203+
<div>
204+
<h2>Standard</h2>
205+
<StandardExample />
206+
</div>
207+
<div>
208+
<h2>Interactive control alongside visible header text</h2>
209+
<CreateQueryExample />
210+
</div>
211+
<div>
212+
<h2>Description-style text as secondary content</h2>
213+
<DescriptionAsSecondaryContentExample />
214+
</div>
215+
<div>
216+
<h2>Interactive control with hidden header text</h2>
217+
<HiddenHeaderTextExample />
218+
</div>
219+
<div>
220+
<h2>Search input with hidden header text</h2>
221+
<HiddenHeaderTextSearchExample />
222+
</div>
223+
<div>
224+
<h2>Counter, info link and description with secondary content</h2>
225+
<AllHeaderSlotsExample />
226+
</div>
227+
<div>
228+
<h2>Long header text with control and actions</h2>
229+
<LongContentExample />
230+
</div>
231+
</SpaceBetween>
232+
</SimplePage>
233+
);
234+
}

‎src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14238,6 +14238,14 @@ slot (not with the deprecated \`header\`), and not with the navigation variant."
1423814238
"optional": true,
1423914239
"type": "string",
1424014240
},
14241+
{
14242+
"description": "Visually hides the header text while keeping it available to assistive technology.
14243+
Use with \`headerSecondaryContent\` to present a purely interactive header control.
14244+
Use with the default or container variant.",
14245+
"name": "hideHeaderText",
14246+
"optional": true,
14247+
"type": "boolean",
14248+
},
1424114249
{
1424214250
"deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases,
1424314251
use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must
@@ -14296,6 +14304,12 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
1429614304
"isDefault": false,
1429714305
"name": "headerInfo",
1429814306
},
14307+
{
14308+
"description": "Secondary content displayed next to the header text, such as an interactive control or supporting text.
14309+
Use with the default or container variant.",
14310+
"isDefault": false,
14311+
"name": "headerSecondaryContent",
14312+
},
1429914313
{
1430014314
"description": "The heading text. Use plain text. When using the container variant, you can use additional header props like \`headerDescription\` and \`headerCounter\` to display other elements in the header.",
1430114315
"isDefault": false,
@@ -16201,16 +16215,19 @@ exports[`Components definition for header matches the snapshot: header 1`] = `
1620116215
},
1620216216
{
1620316217
"description": "Overrides the default [HTML heading tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements)
16204-
provided by the variant. Using this property does not change the visual appearance of the component.",
16218+
provided by the variant. Set to \`div\` to render the title without heading semantics. Using this property does not
16219+
change the visual appearance of the component.",
1620516220
"inlineType": {
1620616221
"name": "HeaderProps.HeadingTag",
1620716222
"type": "union",
1620816223
"values": [
16224+
"div",
1620916225
"h1",
1621016226
"h2",
1621116227
"h3",
1621216228
"h4",
1621316229
"h5",
16230+
"h6",
1621416231
],
1621516232
},
1621616233
"name": "headingTagOverride",

‎src/container/__tests__/analytics-metadata.test.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ describe('Container renders correct analytics metadata', () => {
7373
expect(getGeneratedAnalyticsMetadata(element)).toEqual(getMetadataContexts(label));
7474
});
7575
});
76+
77+
describe.each<HeaderProps.HeadingTag>(['h4', 'h6', 'div'])(
78+
'With a Header component and headingTagOverride=%s',
79+
tag => {
80+
test('resolves the label from the header text', () => {
81+
const label = 'Container header';
82+
const element = renderContainer({
83+
header: (
84+
<Header counter="100" info="Info" headingTagOverride={tag}>
85+
{label}
86+
</Header>
87+
),
88+
});
89+
validateComponentNameAndLabels(element, labels);
90+
expect(getGeneratedAnalyticsMetadata(element)).toEqual(getMetadataContexts(label));
91+
});
92+
}
93+
);
7694
});
7795

7896
test('Internal Container does not render "component" metadata', () => {

‎src/container/internal.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ContainerProps } from './interfaces';
2121
import { getContentStyles, getFooterStyles, getHeaderStyles, getMediaStyles, getRootStyles } from './style';
2222
import { StickyHeaderContext, useStickyHeader } from './use-sticky-header';
2323

24+
import headerAnalyticsSelectors from '../header/analytics-metadata/styles.css.js';
2425
import analyticsSelectors from './analytics-metadata/styles.css.js';
2526
import styles from './styles.css.js';
2627
import testStyles from './test-classes/styles.css.js';
@@ -53,6 +54,12 @@ export interface InternalContainerProps extends Omit<ContainerProps, 'variant'>,
5354
__contentKey?: string;
5455

5556
styleClassNames?: StyleClassNames;
57+
58+
/**
59+
* Native attributes spread onto the container root, for ARIA that `getBaseProps` does not forward
60+
* (it forwards only id/className/data-*).
61+
*/
62+
__nativeAttributes?: React.HTMLAttributes<HTMLDivElement>;
5663
}
5764

5865
// Style API v2
@@ -103,6 +110,7 @@ const InternalContainer = React.forwardRef(function InternalContainer(
103110
__subStepRef,
104111
__contentKey,
105112
styleClassNames,
113+
__nativeAttributes,
106114
...restProps
107115
}: InternalContainerProps,
108116
ref: React.Ref<ContainerProps.Ref>
@@ -167,6 +175,7 @@ const InternalContainer = React.forwardRef(function InternalContainer(
167175
<div
168176
{...baseProps}
169177
{...__funnelSubStepProps}
178+
{...__nativeAttributes}
170179
className={clsx(
171180
styleClassNames?.root,
172181
baseProps.className,
@@ -182,7 +191,8 @@ const InternalContainer = React.forwardRef(function InternalContainer(
182191
ref={mergedRef}
183192
onBlur={onRootBlur}
184193
{...getAnalyticsLabelAttribute(
185-
`.${analyticsSelectors.header} h1, .${analyticsSelectors.header} h2, .${analyticsSelectors.header} h3`
194+
// heading-text covers Header titles rendered outside h1-h3 via headingTagOverride.
195+
`.${analyticsSelectors.header} :is(h1, h2, h3, .${headerAnalyticsSelectors['heading-text']})`
186196
)}
187197
style={getRootStyles(style)}
188198
>

‎src/expandable-section/__tests__/analytics-metadata.test.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ describe('Expandable section renders correct analytics metadata', () => {
8181
);
8282
});
8383
});
84+
test.each(['container', 'stacked'])(
85+
'%s variant resolves the label from header text when hideHeaderText hides the title',
86+
variant => {
87+
const wrapper = renderExpandableSection({
88+
headerText: 'header',
89+
children: 'content',
90+
variant: variant as ExpandableSectionProps['variant'],
91+
hideHeaderText: true,
92+
});
93+
// The title renders as a div (not h1/h2/h3) when hidden, so the label must resolve from the
94+
// header-text marker class instead of the heading element.
95+
expect(getGeneratedAnalyticsMetadata(wrapper.findExpandButton()!.getElement())).toEqual(
96+
getExpandMetadata('header', variant, false)
97+
);
98+
}
99+
);
84100
test.each(['container', 'stacked'])('%s variant with additional properties', variant => {
85101
const wrapper = renderExpandableSection({
86102
headerText: 'header',

0 commit comments

Comments
 (0)