Skip to content

Commit 59bf277

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

6 files changed

Lines changed: 240 additions & 19 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
function StyledActionCard(props: ActionCardProps) {
12+
return (
13+
<div className={styles.cell}>
14+
<ActionCard
15+
description="Launch an instance from a template"
16+
{...props}
17+
{...{
18+
styleClassNames: {
19+
root: props.disabled ? styles['root-disabled'] : styles.root,
20+
header: styles.header,
21+
},
22+
}}
23+
/>
24+
</div>
25+
);
26+
}
27+
28+
export default function () {
29+
return (
30+
<SimplePage title="Action card - Style API v2" screenshotArea={{ disableAnimations: true }}>
31+
<SpaceBetween size="m" direction="horizontal">
32+
<StyledActionCard header="Default">Content</StyledActionCard>
33+
<StyledActionCard header="Icon" icon={<Icon name="angle-right" />} iconVerticalAlignment="top">
34+
Content
35+
</StyledActionCard>
36+
<StyledActionCard header="Disabled" disabled={true}>
37+
Content
38+
</StyledActionCard>
39+
</SpaceBetween>
40+
<SpaceBetween size="m" direction="horizontal">
41+
<StyledActionCard header="Link" href="#">
42+
Content
43+
</StyledActionCard>
44+
<StyledActionCard header="Embedded" variant="embedded">
45+
Content
46+
</StyledActionCard>
47+
<StyledActionCard header="Centered icon" icon={<Icon name="angle-right" />} iconVerticalAlignment="center">
48+
Content
49+
</StyledActionCard>
50+
</SpaceBetween>
51+
<SpaceBetween size="m" direction="horizontal">
52+
<StyledActionCard header="No content" />
53+
<StyledActionCard header="No content, icon" icon={<Icon name="angle-right" />} iconVerticalAlignment="top" />
54+
<StyledActionCard>Content</StyledActionCard>
55+
</SpaceBetween>
56+
</SimplePage>
57+
);
58+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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.$accent,
20+
$focus-ring-width: 3px,
21+
$focus-ring-radius: 20px
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+
);
40+
}
41+
42+
// Only the block-end side of the header border is visible: it is the divider between the header and the content.
43+
.header {
44+
@include action-card.header($background: palette.$genai-surface, $border-color: palette.$genai, $border-width: 2px);
45+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 next to a top-aligned icon', () => {
28+
const { container } = renderActionCard({
29+
header: 'Header',
30+
icon: <span />,
31+
iconVerticalAlignment: 'top',
32+
styleClassNames,
33+
});
34+
const header = container.querySelector('.b')!;
35+
expect(header).toHaveClass(styles.header);
36+
expect(header.parentElement).toHaveClass(styles['header-row']);
37+
expect(container.querySelectorAll('.b')).toHaveLength(1);
38+
});
39+
40+
test('applies the header class when only a description is set', () => {
41+
const { container } = renderActionCard({ description: 'Description', styleClassNames });
42+
expect(container.querySelector('.b')).toHaveClass(styles.header);
43+
});
44+
45+
test('renders no header class when neither header nor description is set', () => {
46+
const { container } = renderActionCard({ ariaLabel: 'Card', styleClassNames });
47+
expect(container.querySelector('.b')).toBeNull();
48+
});
49+
50+
test('applies the root class with href and when disabled', () => {
51+
const link = renderActionCard({ header: 'Link', href: '#', styleClassNames });
52+
expect(link.wrapper.getElement()).toHaveClass('a');
53+
const disabled = renderActionCard({ header: 'Disabled', disabled: true, styleClassNames });
54+
expect(disabled.wrapper.getElement()).toHaveClass('a');
55+
});
56+
57+
test('does not leak the styleClassNames prop to the DOM', () => {
58+
const { container } = renderActionCard({ header: 'Header', styleClassNames });
59+
expect(container.querySelector('[styleClassNames]')).toBeNull();
60+
});
61+
});

‎src/action-card/internal.tsx‎

Lines changed: 13 additions & 2 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>
@@ -143,7 +153,7 @@ const InternalActionCard = React.forwardRef(
143153
);
144154

145155
const headerSection = !headerRowEmpty ? (
146-
<div className={clsx(styles.header, disableHeaderPaddings && styles['no-padding'])}>
156+
<div className={clsx(styleClassNames?.header, styles.header, disableHeaderPaddings && styles['no-padding'])}>
147157
<InternalStructuredItem
148158
content={
149159
header && (
@@ -227,6 +237,7 @@ const InternalActionCard = React.forwardRef(
227237
role="group"
228238
aria-labelledby={header ? headerId : standaloneButtonId}
229239
className={clsx(
240+
styleClassNames?.root,
230241
styles.root,
231242
styles[`variant-${variant}`],
232243
disabled && styles.disabled,

‎src/action-card/mixins.scss‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
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+
// The border is painted by `.root::after` and the radius also by the header and overlay buttons and the header
10+
// corners, and the focus ring by `.root::before`: all tokens are re-anchored as carriers on the root.
11+
$tokens: style-api.resolve(
12+
(border-color, border-width, border-radius, focus-ring-color, focus-ring-width, focus-ring-radius),
13+
carrier
14+
);
15+
816
$variant-border-radius: (
917
'embedded': awsui.$border-radius-action-card-embedded,
1018
'default': awsui.$border-radius-action-card-default,
@@ -29,7 +37,7 @@ $variant-padding-inline: (
2937

3038
@mixin state-border-radius($variant: 'default', $state: 'default') {
3139
$token: map.get($variant-border-radius, $variant);
32-
@include border-radius-all($token);
40+
@include border-radius-all(style-api.value($tokens, border-radius, $token));
3341
}
3442

3543
@mixin state-border-radius-all-variants($state: 'default') {
@@ -55,9 +63,9 @@ $variant-padding-inline: (
5563
}
5664

5765
&: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');
66+
border-color: style-api.value($tokens, border-color, map.get($s, 'border-color'));
67+
border-block-width: style-api.value($tokens, border-width, map.get($s, 'border-width'));
68+
border-inline-width: style-api.value($tokens, border-width, map.get($s, 'border-width'));
6169
}
6270

6371
> .icon,

‎src/action-card/styles.scss‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,40 @@
88
@use '../internal/styles' as styles;
99
@use '../internal/styles/tokens' as awsui;
1010
@use '../internal/styles/utils/theming' as theming;
11+
@use '../internal/styles/style-api' as style-api;
1112
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
1213
@use './constants' as constants;
1314
@use './mixins' as mixins;
1415
@use './motion';
1516

17+
$tokens: mixins.$tokens;
18+
@include style-api.register($tokens);
19+
@include style-api.docs(
20+
'root',
21+
$tokens,
22+
$properties: (background),
23+
$descriptions: (
24+
slot:
25+
'This is NOT a semantic button element. Use `:has(:hover)` or `:has(:active)` to target semantic button states. `disabled` is set by you: pass a different class when it is true.',
26+
tokens: (
27+
border-color: 'The border is drawn over the card edges and takes no space.',
28+
border-width: 'The border is drawn over the card edges and takes no space.',
29+
border-radius: 'Also rounds the header corners.',
30+
)
31+
)
32+
);
33+
@include style-api.docs(
34+
'header',
35+
$properties: (background, border),
36+
$descriptions: (
37+
slot:
38+
'Rendered when `header` or `description` is set. The block-end border is the divider between the header and the content.'
39+
)
40+
);
41+
1642
.header {
43+
// Only the block-end side has a style: a consumer border colour and width draw the divider to the content.
44+
border-block-end: 0 solid transparent;
1745
&-inner {
1846
@include styles.font-heading-s;
1947
&.disabled {
@@ -111,6 +139,7 @@
111139
min-inline-size: 0;
112140
text-align: start;
113141
display: flex;
142+
@include style-api.carriers($tokens);
114143
$default-state: map.get(constants.$states, 'default');
115144
background: map.get($default-state, 'background');
116145
box-shadow: map.get($default-state, 'box-shadow');
@@ -124,8 +153,10 @@
124153
@include styles.base-pseudo-element;
125154

126155
box-shadow: none;
127-
border-block: solid map.get($default-state, 'border-width') map.get($default-state, 'border-color');
128-
border-inline: solid map.get($default-state, 'border-width') map.get($default-state, 'border-color');
156+
$border-width: style-api.value($tokens, border-width, map.get($default-state, 'border-width'));
157+
$border-color: style-api.value($tokens, border-color, map.get($default-state, 'border-color'));
158+
border-block: solid $border-width $border-color;
159+
border-inline: solid $border-width $border-color;
129160
}
130161

131162
&.variant-default,
@@ -175,17 +206,7 @@
175206

176207
&:has(.header-button:focus-visible),
177208
&:has(.overlay-button:focus-visible) {
178-
@include styles.focus-highlight(
179-
2px,
180-
awsui.$border-radius-control-default-focus-ring,
181-
0 0 0 2px awsui.$color-border-item-focused
182-
);
183-
184-
@each $variant in ('embedded', 'default') {
185-
&.variant-#{$variant}::before {
186-
@include mixins.border-radius-all(awsui.$border-radius-control-default-focus-ring);
187-
}
188-
}
209+
@include style-api.focus-highlight($gutter-block: 2px, $gutter-inline: 2px);
189210
}
190211

191212
@each $variant in ('embedded', 'default') {
@@ -195,6 +216,23 @@
195216
@include mixins.state-border-radius($variant);
196217
}
197218

219+
// The header corners that meet the card corners follow the card radius.
220+
$border-radius: style-api.value($tokens, border-radius, map.get(mixins.$variant-border-radius, $variant));
221+
> .inner-card > .header,
222+
> .inner-card > .header-row > .header {
223+
border-start-start-radius: $border-radius;
224+
}
225+
> .inner-card > .header:not(:has(+ .body)),
226+
> .inner-card > .header-row:not(:has(+ .body)) > .header {
227+
border-end-start-radius: $border-radius;
228+
}
229+
&:not(.has-icon) > .inner-card > .header {
230+
border-start-end-radius: $border-radius;
231+
&:not(:has(+ .body)) {
232+
border-end-end-radius: $border-radius;
233+
}
234+
}
235+
198236
$padding-block: map.get(mixins.$variant-padding-block, $variant);
199237
$padding-inline: map.get(mixins.$variant-padding-inline, $variant);
200238

0 commit comments

Comments
 (0)