Skip to content

Commit b8fadbc

Browse files
authored
chore: Adds internal style api v2 to action card (#5247)
1 parent 8c00352 commit b8fadbc

6 files changed

Lines changed: 339 additions & 21 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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, SpaceBetween } from '~components';
6+
7+
import { SimplePage } from '../app/templates';
8+
9+
import styles from './style-api-v2.scss';
10+
11+
// No card has an icon: `icon` is consumer content without a slot, so it would show in its default colour here.
12+
function StyledActionCard(props: ActionCardProps) {
13+
return (
14+
<div className={styles.cell}>
15+
<ActionCard
16+
description="Launch an instance from a template"
17+
{...props}
18+
{...{
19+
styleClassNames: {
20+
root: props.disabled ? styles['root-disabled'] : styles.root,
21+
header: props.disabled ? styles['header-disabled'] : styles.header,
22+
},
23+
}}
24+
/>
25+
</div>
26+
);
27+
}
28+
29+
export default function () {
30+
return (
31+
<SimplePage title="Action card - Style API v2" screenshotArea={{ disableAnimations: true }}>
32+
<SpaceBetween size="m" direction="horizontal">
33+
<StyledActionCard header="Default">Content</StyledActionCard>
34+
<StyledActionCard header="Disabled" disabled={true}>
35+
Content
36+
</StyledActionCard>
37+
<StyledActionCard header="Link" href="#">
38+
Content
39+
</StyledActionCard>
40+
<StyledActionCard header="Embedded" variant="embedded">
41+
Content
42+
</StyledActionCard>
43+
</SpaceBetween>
44+
<SpaceBetween size="m" direction="horizontal">
45+
<StyledActionCard header="No content" />
46+
<StyledActionCard>Content</StyledActionCard>
47+
<StyledActionCard ariaLabel="Content only" description={undefined}>
48+
Content only
49+
</StyledActionCard>
50+
</SpaceBetween>
51+
</SimplePage>
52+
);
53+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
.root {
14+
@include action-card.root(
15+
$background: palette.$surface,
16+
$border-color: palette.$border-accent,
17+
$border-width: 2px,
18+
$border-radius: 16px,
19+
$focus-ring-color: palette.$info,
20+
$focus-ring-width: 3px,
21+
$focus-ring-radius: 18px
22+
);
23+
24+
&:has(:hover) {
25+
@include action-card.root($background: palette.$subtle-hover, $border-color: palette.$accent-hover);
26+
}
27+
28+
&:has(:active) {
29+
@include action-card.root($background: palette.$subtle-active, $border-color: palette.$accent-active);
30+
}
31+
}
32+
33+
.root-disabled {
34+
@include action-card.root(
35+
$background: palette.$control-bg-disabled,
36+
$border-color: palette.$border-disabled,
37+
$border-width: 2px,
38+
$border-radius: 16px,
39+
$focus-ring-color: palette.$info,
40+
$focus-ring-width: 3px,
41+
$focus-ring-radius: 18px
42+
);
43+
}
44+
45+
.header {
46+
@include action-card.header($background: palette.$genai-surface, $border-color: palette.$genai, $border-width: 2px);
47+
}
48+
49+
// stylelint-disable-next-line selector-combinator-disallowed-list
50+
.root:has(:hover) .header {
51+
@include action-card.header($background: palette.$subtle-hover, $border-color: palette.$genai-hover);
52+
}
53+
54+
// stylelint-disable-next-line selector-combinator-disallowed-list
55+
.root:has(:active) .header {
56+
@include action-card.header($background: palette.$subtle-active, $border-color: palette.$genai-active);
57+
}
58+
59+
.header-disabled {
60+
@include action-card.header(
61+
$background: palette.$control-bg-disabled,
62+
$border-color: palette.$border-disabled,
63+
$border-width: 2px
64+
);
65+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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', 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('renders no header class when neither header nor description is set', () => {
104+
const { container } = renderActionCard({ ariaLabel: 'Card', styleClassNames });
105+
expect(container.querySelector('.b')).toBeNull();
106+
});
107+
108+
test('applies the root class with href and when disabled', () => {
109+
const link = renderActionCard({ header: 'Link', href: '#', styleClassNames });
110+
expect(link.wrapper.getElement()).toHaveClass('a');
111+
const disabled = renderActionCard({ header: 'Disabled', disabled: true, styleClassNames });
112+
expect(disabled.wrapper.getElement()).toHaveClass('a');
113+
});
114+
115+
test('does not leak the styleClassNames prop to the DOM', () => {
116+
const { container } = renderActionCard({ header: 'Header', styleClassNames });
117+
expect(container.querySelector('[styleClassNames]')).toBeNull();
118+
});
119+
});

‎src/action-card/internal.tsx‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@ 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+
}
2130

2231
const InternalActionCard = React.forwardRef(
2332
(
@@ -42,6 +51,7 @@ const InternalActionCard = React.forwardRef(
4251
nativeButtonAttributes,
4352
nativeAnchorAttributes,
4453
__internalRootRef,
54+
styleClassNames,
4555
...rest
4656
}: InternalActionCardProps,
4757
ref: React.Ref<ActionCardProps.Ref>
@@ -83,7 +93,7 @@ const InternalActionCard = React.forwardRef(
8393
{icon}
8494
</div>
8595
);
86-
const iconInHeaderRow = icon && iconVerticalAlignment === 'top' && !!header;
96+
const iconInHeaderRow = icon && iconVerticalAlignment === 'top' && !headerRowEmpty;
8797

8898
const mergedRootRef = useMergeRefs(rootRef, __internalRootRef);
8999

@@ -143,7 +153,13 @@ const InternalActionCard = React.forwardRef(
143153
);
144154

145155
const headerSection = !headerRowEmpty ? (
146-
<div className={clsx(styles.header, disableHeaderPaddings && styles['no-padding'])}>
156+
<div
157+
className={clsx(
158+
!iconInHeaderRow && styleClassNames?.header,
159+
styles.header,
160+
disableHeaderPaddings && styles['no-padding']
161+
)}
162+
>
147163
<InternalStructuredItem
148164
content={
149165
header && (
@@ -202,7 +218,7 @@ const InternalActionCard = React.forwardRef(
202218
const contentElement = (
203219
<div className={styles['inner-card']}>
204220
{iconInHeaderRow ? (
205-
<div className={styles['header-row']}>
221+
<div className={clsx(styleClassNames?.header, styles['header-row'])}>
206222
{headerSection}
207223
{iconWrapper}
208224
</div>
@@ -227,6 +243,7 @@ const InternalActionCard = React.forwardRef(
227243
role="group"
228244
aria-labelledby={header ? headerId : standaloneButtonId}
229245
className={clsx(
246+
styleClassNames?.root,
230247
styles.root,
231248
styles[`variant-${variant}`],
232249
disabled && styles.disabled,

‎src/action-card/mixins.scss‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33

44
@use 'sass:map';
55
@use '../internal/styles/tokens' as awsui;
6+
@use '../internal/styles/style-api' as style-api;
67
@use './constants' as constants;
78

9+
// Border tokens stay public: as carriers they would inherit into nested components that read the same carrier name.
10+
$border-tokens: style-api.resolve((border-color, border-width), public);
11+
$carrier-tokens: style-api.resolve((border-radius, focus-ring-color, focus-ring-width, focus-ring-radius), carrier);
12+
$tokens: map.merge($border-tokens, $carrier-tokens);
13+
814
$variant-border-radius: (
915
'embedded': awsui.$border-radius-action-card-embedded,
1016
'default': awsui.$border-radius-action-card-default,
@@ -29,7 +35,15 @@ $variant-padding-inline: (
2935

3036
@mixin state-border-radius($variant: 'default', $state: 'default') {
3137
$token: map.get($variant-border-radius, $variant);
32-
@include border-radius-all($token);
38+
@include border-radius-all(style-api.value($carrier-tokens, border-radius, $token));
39+
}
40+
41+
@mixin inherit-border-tokens {
42+
@if style-api.enabled() {
43+
@each $token, $property in $border-tokens {
44+
#{$property}: inherit;
45+
}
46+
}
3347
}
3448

3549
@mixin state-border-radius-all-variants($state: 'default') {
@@ -55,9 +69,9 @@ $variant-padding-inline: (
5569
}
5670

5771
&:after {
58-
border-color: map.get($s, 'border-color');
59-
border-block-width: map.get($s, 'border-width');
60-
border-inline-width: map.get($s, 'border-width');
72+
border-color: style-api.value($border-tokens, border-color, map.get($s, 'border-color'));
73+
border-block-width: style-api.value($border-tokens, border-width, map.get($s, 'border-width'));
74+
border-inline-width: style-api.value($border-tokens, border-width, map.get($s, 'border-width'));
6175
}
6276

6377
> .icon,

0 commit comments

Comments
 (0)