Skip to content

Commit c3fab2c

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

6 files changed

Lines changed: 291 additions & 19 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 = (
12+
<span className={styles.icon}>
13+
<Icon name="angle-right" />
14+
</span>
15+
);
16+
17+
function StyledActionCard(props: ActionCardProps) {
18+
return (
19+
<div className={styles.cell}>
20+
<ActionCard
21+
description="Launch an instance from a template"
22+
{...props}
23+
{...{
24+
styleClassNames: {
25+
root: props.disabled ? styles['root-disabled'] : styles.root,
26+
header: props.disabled ? styles['header-disabled'] : styles.header,
27+
},
28+
}}
29+
/>
30+
</div>
31+
);
32+
}
33+
34+
export default function () {
35+
return (
36+
<SimplePage title="Action card - Style API v2" screenshotArea={{ disableAnimations: true }}>
37+
<SpaceBetween size="m" direction="horizontal">
38+
<StyledActionCard header="Default">Content</StyledActionCard>
39+
<StyledActionCard header="Icon" icon={icon} iconVerticalAlignment="top">
40+
Content
41+
</StyledActionCard>
42+
<StyledActionCard header="Disabled" disabled={true}>
43+
Content
44+
</StyledActionCard>
45+
</SpaceBetween>
46+
<SpaceBetween size="m" direction="horizontal">
47+
<StyledActionCard header="Link" href="#">
48+
Content
49+
</StyledActionCard>
50+
<StyledActionCard header="Embedded" variant="embedded">
51+
Content
52+
</StyledActionCard>
53+
<StyledActionCard header="Centered icon" icon={icon} iconVerticalAlignment="center">
54+
Content
55+
</StyledActionCard>
56+
</SpaceBetween>
57+
<SpaceBetween size="m" direction="horizontal">
58+
<StyledActionCard header="No content" />
59+
<StyledActionCard header="No content, icon" icon={icon} iconVerticalAlignment="top" />
60+
<StyledActionCard>Content</StyledActionCard>
61+
</SpaceBetween>
62+
</SimplePage>
63+
);
64+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
display: flex;
74+
color: palette.$genai;
75+
}
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: 22 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` only: its tokens stay public and `::after` inherits them from the root,
10+
// so no descendant of the card sees them. The radius is also read by the header and overlay buttons and the header
11+
// corners, and the focus ring by `.root::before`: these tokens are re-anchored as carriers on the root.
12+
$border-tokens: style-api.resolve((border-color, border-width), public);
13+
$carrier-tokens: style-api.resolve((border-radius, focus-ring-color, focus-ring-width, focus-ring-radius), carrier);
14+
$tokens: map.merge($border-tokens, $carrier-tokens);
15+
816
$variant-border-radius: (
917
'embedded': awsui.$border-radius-action-card-embedded,
1018
'default': awsui.$border-radius-action-card-default,
@@ -29,7 +37,17 @@ $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($carrier-tokens, border-radius, $token));
41+
}
42+
43+
// Gives `.root::after` the root's public border tokens. They are registered as non-inheriting, so without this
44+
// the pseudo-element would not see them.
45+
@mixin inherit-border-tokens {
46+
@if style-api.enabled() {
47+
@each $token, $property in $border-tokens {
48+
#{$property}: inherit;
49+
}
50+
}
3351
}
3452

3553
@mixin state-border-radius-all-variants($state: 'default') {
@@ -55,9 +73,9 @@ $variant-padding-inline: (
5573
}
5674

5775
&: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');
76+
border-color: style-api.value($border-tokens, border-color, map.get($s, 'border-color'));
77+
border-block-width: style-api.value($border-tokens, border-width, map.get($s, 'border-width'));
78+
border-inline-width: style-api.value($border-tokens, border-width, map.get($s, 'border-width'));
6179
}
6280

6381
> .icon,

0 commit comments

Comments
 (0)