Skip to content

Commit 52f7ab6

Browse files
committed
chore: Adds internal style api v2 to action card
1 parent ab62434 commit 52f7ab6

6 files changed

Lines changed: 425 additions & 22 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
5+
import { ActionCard, ActionCardProps, Icon, SpaceBetween } from '~components';
6+
7+
import { SimplePage } from '../app/templates';
8+
9+
import styles from './style-api-v2.scss';
10+
11+
const icon = <Icon name="angle-right" />;
12+
13+
function StyledActionCard(props: ActionCardProps) {
14+
return (
15+
<div className={styles.cell}>
16+
<ActionCard
17+
description="Launch an instance from a template"
18+
{...props}
19+
{...{
20+
styleClassNames: {
21+
root: props.disabled ? styles['root-disabled'] : styles.root,
22+
header: props.disabled ? styles['header-disabled'] : styles.header,
23+
iconBox: props.disabled ? styles['icon-disabled'] : styles.icon,
24+
},
25+
}}
26+
/>
27+
</div>
28+
);
29+
}
30+
31+
export default function () {
32+
return (
33+
<SimplePage title="Action card - Style API v2" screenshotArea={{ disableAnimations: true }}>
34+
<SpaceBetween size="m" direction="horizontal">
35+
<StyledActionCard header="Default">Content</StyledActionCard>
36+
<StyledActionCard header="Icon" icon={icon} iconVerticalAlignment="top">
37+
Content
38+
</StyledActionCard>
39+
<StyledActionCard header="Disabled" disabled={true}>
40+
Content
41+
</StyledActionCard>
42+
</SpaceBetween>
43+
<SpaceBetween size="m" direction="horizontal">
44+
<StyledActionCard header="Link" href="#">
45+
Content
46+
</StyledActionCard>
47+
<StyledActionCard header="Embedded" variant="embedded">
48+
Content
49+
</StyledActionCard>
50+
<StyledActionCard header="Centered icon" icon={icon} iconVerticalAlignment="center">
51+
Content
52+
</StyledActionCard>
53+
</SpaceBetween>
54+
<SpaceBetween size="m" direction="horizontal">
55+
<StyledActionCard header="No content" />
56+
<StyledActionCard header="No content, icon" icon={icon} iconVerticalAlignment="top" />
57+
<StyledActionCard>Content</StyledActionCard>
58+
</SpaceBetween>
59+
<SpaceBetween size="m" direction="horizontal">
60+
<StyledActionCard ariaLabel="Description only, icon" icon={icon} iconVerticalAlignment="top">
61+
Content
62+
</StyledActionCard>
63+
<StyledActionCard header="Disabled, icon" icon={icon} iconVerticalAlignment="top" disabled={true}>
64+
Content
65+
</StyledActionCard>
66+
<StyledActionCard header="No content, centered icon" icon={icon} iconVerticalAlignment="center" />
67+
<StyledActionCard
68+
ariaLabel="Content only, icon"
69+
icon={icon}
70+
iconVerticalAlignment="top"
71+
description={undefined}
72+
>
73+
Content only, icon
74+
</StyledActionCard>
75+
</SpaceBetween>
76+
</SimplePage>
77+
);
78+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
@use '~components/styling/action-card';
7+
@use '../style-api/palette' as palette;
8+
9+
.cell {
10+
inline-size: 240px;
11+
}
12+
13+
// The ring sits 2px outside the 16px card corner, so an 18px radius keeps it concentric.
14+
.root {
15+
@include action-card.root(
16+
$background: palette.$surface,
17+
$border-color: palette.$border-accent,
18+
$border-width: 2px,
19+
$border-radius: 16px,
20+
$focus-ring-color: palette.$info,
21+
$focus-ring-width: 3px,
22+
$focus-ring-radius: 18px
23+
);
24+
25+
&:has(:hover) {
26+
@include action-card.root($background: palette.$subtle-hover, $border-color: palette.$accent-hover);
27+
}
28+
29+
&:has(:active) {
30+
@include action-card.root($background: palette.$subtle-active, $border-color: palette.$accent-active);
31+
}
32+
}
33+
34+
// Disabled cards stay focusable, so they keep the focus ring.
35+
.root-disabled {
36+
@include action-card.root(
37+
$background: palette.$control-bg-disabled,
38+
$border-color: palette.$border-disabled,
39+
$border-width: 2px,
40+
$border-radius: 16px,
41+
$focus-ring-color: palette.$info,
42+
$focus-ring-width: 3px,
43+
$focus-ring-radius: 18px
44+
);
45+
}
46+
47+
// Only the block-end side of the header border is visible: it is the divider between the header and the content.
48+
.header {
49+
@include action-card.header($background: palette.$genai-surface, $border-color: palette.$genai, $border-width: 2px);
50+
}
51+
52+
// The header follows the card state, so the whole card reacts. The header slot sits inside the root slot, and a
53+
// consumer reaches it the same way.
54+
// stylelint-disable-next-line selector-combinator-disallowed-list
55+
.root:has(:hover) .header {
56+
@include action-card.header($background: palette.$subtle-hover, $border-color: palette.$genai-hover);
57+
}
58+
59+
// stylelint-disable-next-line selector-combinator-disallowed-list
60+
.root:has(:active) .header {
61+
@include action-card.header($background: palette.$subtle-active, $border-color: palette.$genai-active);
62+
}
63+
64+
.header-disabled {
65+
@include action-card.header(
66+
$background: palette.$control-bg-disabled,
67+
$border-color: palette.$border-disabled,
68+
$border-width: 2px
69+
);
70+
}
71+
72+
.icon {
73+
@include action-card.iconBox($color: palette.$genai);
74+
}
75+
76+
// The icon follows the card state. The icon slot sits inside the root slot, and a consumer reaches it the same way.
77+
// stylelint-disable-next-line selector-combinator-disallowed-list
78+
.root:has(:hover) .icon {
79+
@include action-card.iconBox($color: palette.$genai-hover);
80+
}
81+
82+
// stylelint-disable-next-line selector-combinator-disallowed-list
83+
.root:has(:active) .icon {
84+
@include action-card.iconBox($color: palette.$genai-active);
85+
}
86+
87+
.icon-disabled {
88+
@include action-card.iconBox($color: palette.$text-disabled);
89+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import { render } from '@testing-library/react';
5+
6+
import ActionCard, { ActionCardProps } from '../../../lib/components/action-card';
7+
import createWrapper from '../../../lib/components/test-utils/dom';
8+
9+
import styles from '../../../lib/components/action-card/styles.css.js';
10+
11+
function renderActionCard(props: ActionCardProps & { styleClassNames?: Record<string, string> }) {
12+
const { container } = render(<ActionCard {...props}>Content</ActionCard>);
13+
return { container, wrapper: createWrapper(container).findActionCard()! };
14+
}
15+
16+
const styleClassNames = { root: 'a', header: 'b', iconBox: 'd', unknown: 'c' };
17+
18+
describe('ActionCard Style API v2', () => {
19+
test('applies each slot class to its element and ignores unknown keys', () => {
20+
const { container, wrapper } = renderActionCard({ header: 'Header', description: 'Description', styleClassNames });
21+
expect(wrapper.getElement()).toHaveClass('a');
22+
expect(container.querySelector('.b')).toHaveClass(styles.header);
23+
expect(container.querySelectorAll('.a, .b')).toHaveLength(2);
24+
expect(container.querySelector('.c')).toBeNull();
25+
});
26+
27+
test('applies the header class to the header row next to a top-aligned icon', () => {
28+
const { container } = renderActionCard({
29+
header: 'Header',
30+
icon: <span />,
31+
iconVerticalAlignment: 'top',
32+
styleClassNames,
33+
});
34+
const headers = container.querySelectorAll('.b');
35+
expect(headers).toHaveLength(1);
36+
expect(headers[0]).toHaveClass(styles['header-row']);
37+
expect(container.querySelector(`.${styles.header}`)).not.toHaveClass('b');
38+
});
39+
40+
test('applies the header class to the header row with a top-aligned icon and only a description', () => {
41+
const { container, wrapper } = renderActionCard({
42+
description: 'Description',
43+
ariaLabel: 'Card',
44+
icon: <span />,
45+
iconVerticalAlignment: 'top',
46+
styleClassNames,
47+
});
48+
const headers = container.querySelectorAll('.b');
49+
expect(headers).toHaveLength(1);
50+
expect(headers[0]).toHaveClass(styles['header-row']);
51+
expect(headers[0]).toContainElement(wrapper.findIcon()!.getElement());
52+
expect(headers[0]).toContainElement(wrapper.findDescription()!.getElement());
53+
});
54+
55+
test('renders the overlay button on the root with a top-aligned icon and only a description', () => {
56+
const onClick = jest.fn();
57+
const { wrapper } = renderActionCard({
58+
description: 'Description',
59+
ariaLabel: 'Card',
60+
icon: <span />,
61+
iconVerticalAlignment: 'top',
62+
onClick,
63+
styleClassNames,
64+
});
65+
const buttons = wrapper.getElement().querySelectorAll('button');
66+
expect(buttons).toHaveLength(1);
67+
expect(buttons[0]).toHaveClass(styles['overlay-button']);
68+
expect(buttons[0].parentElement).toBe(wrapper.getElement());
69+
wrapper.click();
70+
expect(onClick).toHaveBeenCalledTimes(1);
71+
});
72+
73+
test('applies the header class to the header row with a top-aligned icon and no content', () => {
74+
const { container } = render(
75+
<ActionCard header="Header" icon={<span />} iconVerticalAlignment="top" {...{ styleClassNames }} />
76+
);
77+
const wrapper = createWrapper(container).findActionCard()!;
78+
expect(wrapper.findContent()).toBeNull();
79+
const headers = container.querySelectorAll('.b');
80+
expect(headers).toHaveLength(1);
81+
expect(headers[0]).toHaveClass(styles['header-row']);
82+
expect(headers[0]).toContainElement(wrapper.findIcon()!.getElement());
83+
});
84+
85+
test('applies the header class to the header with a centered icon', () => {
86+
const { container } = renderActionCard({
87+
header: 'Header',
88+
icon: <span />,
89+
iconVerticalAlignment: 'center',
90+
styleClassNames,
91+
});
92+
expect(container.querySelectorAll('.b')).toHaveLength(1);
93+
expect(container.querySelector('.b')).toHaveClass(styles.header);
94+
expect(container.querySelector(`.${styles['header-row']}`)).toBeNull();
95+
});
96+
97+
test('applies the header class when only a description is set', () => {
98+
const { container } = renderActionCard({ description: 'Description', styleClassNames });
99+
expect(container.querySelectorAll('.b')).toHaveLength(1);
100+
expect(container.querySelector('.b')).toHaveClass(styles.header);
101+
});
102+
103+
test.each(['top', 'center'] as const)('applies the iconBox class to the icon wrapper (%s)', iconVerticalAlignment => {
104+
const { container, wrapper } = renderActionCard({
105+
header: 'Header',
106+
icon: <span />,
107+
iconVerticalAlignment,
108+
styleClassNames,
109+
});
110+
expect(wrapper.findIcon()!.getElement()).toHaveClass('d');
111+
expect(container.querySelectorAll('.d')).toHaveLength(1);
112+
});
113+
114+
test('renders no iconBox class without an icon', () => {
115+
const { container } = renderActionCard({ header: 'Header', styleClassNames });
116+
expect(container.querySelector('.d')).toBeNull();
117+
});
118+
119+
test('renders no header class when neither header nor description is set', () => {
120+
const { container } = renderActionCard({ ariaLabel: 'Card', styleClassNames });
121+
expect(container.querySelector('.b')).toBeNull();
122+
});
123+
124+
test('applies the root class with href and when disabled', () => {
125+
const link = renderActionCard({ header: 'Link', href: '#', styleClassNames });
126+
expect(link.wrapper.getElement()).toHaveClass('a');
127+
const disabled = renderActionCard({ header: 'Disabled', disabled: true, styleClassNames });
128+
expect(disabled.wrapper.getElement()).toHaveClass('a');
129+
});
130+
131+
test('does not leak the styleClassNames prop to the DOM', () => {
132+
const { container } = renderActionCard({ header: 'Header', styleClassNames });
133+
expect(container.querySelector('[styleClassNames]')).toBeNull();
134+
});
135+
});

‎src/action-card/internal.tsx‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,17 @@ import { ActionCardProps } from './interfaces';
1717
import styles from './styles.css.js';
1818
import testStyles from './test-classes/styles.css.js';
1919

20-
export type InternalActionCardProps = ActionCardProps & InternalBaseComponentProps;
20+
export type InternalActionCardProps = ActionCardProps &
21+
InternalBaseComponentProps & {
22+
styleClassNames?: StyleClassNames;
23+
};
24+
25+
// Style API v2
26+
export interface StyleClassNames {
27+
root?: string;
28+
header?: string;
29+
iconBox?: string;
30+
}
2131

2232
const InternalActionCard = React.forwardRef(
2333
(
@@ -42,6 +52,7 @@ const InternalActionCard = React.forwardRef(
4252
nativeButtonAttributes,
4353
nativeAnchorAttributes,
4454
__internalRootRef,
55+
styleClassNames,
4556
...rest
4657
}: InternalActionCardProps,
4758
ref: React.Ref<ActionCardProps.Ref>
@@ -79,11 +90,15 @@ const InternalActionCard = React.forwardRef(
7990
const headerRowEmpty = !header && !description;
8091

8192
const iconWrapper = icon && (
82-
<div className={clsx(styles.icon, testStyles.icon)} aria-hidden="true" data-awsui-motion-target="">
93+
<div
94+
className={clsx(styleClassNames?.iconBox, styles.icon, testStyles.icon)}
95+
aria-hidden="true"
96+
data-awsui-motion-target=""
97+
>
8398
{icon}
8499
</div>
85100
);
86-
const iconInHeaderRow = icon && iconVerticalAlignment === 'top' && !!header;
101+
const iconInHeaderRow = icon && iconVerticalAlignment === 'top' && !headerRowEmpty;
87102

88103
const mergedRootRef = useMergeRefs(rootRef, __internalRootRef);
89104

@@ -143,7 +158,13 @@ const InternalActionCard = React.forwardRef(
143158
);
144159

145160
const headerSection = !headerRowEmpty ? (
146-
<div className={clsx(styles.header, disableHeaderPaddings && styles['no-padding'])}>
161+
<div
162+
className={clsx(
163+
!iconInHeaderRow && styleClassNames?.header,
164+
styles.header,
165+
disableHeaderPaddings && styles['no-padding']
166+
)}
167+
>
147168
<InternalStructuredItem
148169
content={
149170
header && (
@@ -202,7 +223,7 @@ const InternalActionCard = React.forwardRef(
202223
const contentElement = (
203224
<div className={styles['inner-card']}>
204225
{iconInHeaderRow ? (
205-
<div className={styles['header-row']}>
226+
<div className={clsx(styleClassNames?.header, styles['header-row'])}>
206227
{headerSection}
207228
{iconWrapper}
208229
</div>
@@ -227,6 +248,7 @@ const InternalActionCard = React.forwardRef(
227248
role="group"
228249
aria-labelledby={header ? headerId : standaloneButtonId}
229250
className={clsx(
251+
styleClassNames?.root,
230252
styles.root,
231253
styles[`variant-${variant}`],
232254
disabled && styles.disabled,

0 commit comments

Comments
 (0)