Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions pages/action-card/style-api-v2.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import { ActionCard, ActionCardProps, SpaceBetween } from '~components';

import { SimplePage } from '../app/templates';

import styles from './style-api-v2.scss';

// No card has an icon: `icon` is consumer content without a slot, so it would show in its default colour here.
function StyledActionCard(props: ActionCardProps) {
return (
<div className={styles.cell}>
<ActionCard
description="Launch an instance from a template"
{...props}
{...{
styleClassNames: {
root: props.disabled ? styles['root-disabled'] : styles.root,
header: props.disabled ? styles['header-disabled'] : styles.header,
},
}}
/>
</div>
);
}

export default function () {
return (
<SimplePage title="Action card - Style API v2" screenshotArea={{ disableAnimations: true }}>
<SpaceBetween size="m" direction="horizontal">
<StyledActionCard header="Default">Content</StyledActionCard>
<StyledActionCard header="Disabled" disabled={true}>
Content
</StyledActionCard>
<StyledActionCard header="Link" href="#">
Content
</StyledActionCard>
<StyledActionCard header="Embedded" variant="embedded">
Content
</StyledActionCard>
</SpaceBetween>
<SpaceBetween size="m" direction="horizontal">
<StyledActionCard header="No content" />
<StyledActionCard>Content</StyledActionCard>
<StyledActionCard ariaLabel="Content only" description={undefined}>
Content only
</StyledActionCard>
</SpaceBetween>
</SimplePage>
);
}
65 changes: 65 additions & 0 deletions pages/action-card/style-api-v2.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

@use '~components/styling/action-card';
@use '../style-api/palette' as palette;

.cell {
inline-size: 240px;
}

.root {
@include action-card.root(
$background: palette.$surface,
$border-color: palette.$border-accent,
$border-width: 2px,
$border-radius: 16px,
$focus-ring-color: palette.$info,
$focus-ring-width: 3px,
$focus-ring-radius: 18px
);

&:has(:hover) {
@include action-card.root($background: palette.$subtle-hover, $border-color: palette.$accent-hover);
}

&:has(:active) {
@include action-card.root($background: palette.$subtle-active, $border-color: palette.$accent-active);
}
}

.root-disabled {
@include action-card.root(
$background: palette.$control-bg-disabled,
$border-color: palette.$border-disabled,
$border-width: 2px,
$border-radius: 16px,
$focus-ring-color: palette.$info,
$focus-ring-width: 3px,
$focus-ring-radius: 18px
);
}

.header {
@include action-card.header($background: palette.$genai-surface, $border-color: palette.$genai, $border-width: 2px);
}

// stylelint-disable-next-line selector-combinator-disallowed-list
.root:has(:hover) .header {
@include action-card.header($background: palette.$subtle-hover, $border-color: palette.$genai-hover);
}

// stylelint-disable-next-line selector-combinator-disallowed-list
.root:has(:active) .header {
@include action-card.header($background: palette.$subtle-active, $border-color: palette.$genai-active);
}

.header-disabled {
@include action-card.header(
$background: palette.$control-bg-disabled,
$border-color: palette.$border-disabled,
$border-width: 2px
);
}
119 changes: 119 additions & 0 deletions src/action-card/__tests__/style-api.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { render } from '@testing-library/react';

import ActionCard, { ActionCardProps } from '../../../lib/components/action-card';
import createWrapper from '../../../lib/components/test-utils/dom';

import styles from '../../../lib/components/action-card/styles.css.js';

function renderActionCard(props: ActionCardProps & { styleClassNames?: Record<string, string> }) {
const { container } = render(<ActionCard {...props}>Content</ActionCard>);
return { container, wrapper: createWrapper(container).findActionCard()! };
}

const styleClassNames = { root: 'a', header: 'b', unknown: 'c' };

describe('ActionCard Style API v2', () => {
test('applies each slot class to its element and ignores unknown keys', () => {
const { container, wrapper } = renderActionCard({ header: 'Header', description: 'Description', styleClassNames });
expect(wrapper.getElement()).toHaveClass('a');
expect(container.querySelector('.b')).toHaveClass(styles.header);
expect(container.querySelectorAll('.a, .b')).toHaveLength(2);
expect(container.querySelector('.c')).toBeNull();
});

test('applies the header class to the header row next to a top-aligned icon', () => {
const { container } = renderActionCard({
header: 'Header',
icon: <span />,
iconVerticalAlignment: 'top',
styleClassNames,
});
const headers = container.querySelectorAll('.b');
expect(headers).toHaveLength(1);
expect(headers[0]).toHaveClass(styles['header-row']);
expect(container.querySelector(`.${styles.header}`)).not.toHaveClass('b');
});

test('applies the header class to the header row with a top-aligned icon and only a description', () => {
const { container, wrapper } = renderActionCard({
description: 'Description',
ariaLabel: 'Card',
icon: <span />,
iconVerticalAlignment: 'top',
styleClassNames,
});
const headers = container.querySelectorAll('.b');
expect(headers).toHaveLength(1);
expect(headers[0]).toHaveClass(styles['header-row']);
expect(headers[0]).toContainElement(wrapper.findIcon()!.getElement());
expect(headers[0]).toContainElement(wrapper.findDescription()!.getElement());
});

test('renders the overlay button on the root with a top-aligned icon and only a description', () => {
const onClick = jest.fn();
const { wrapper } = renderActionCard({
description: 'Description',
ariaLabel: 'Card',
icon: <span />,
iconVerticalAlignment: 'top',
onClick,
styleClassNames,
});
const buttons = wrapper.getElement().querySelectorAll('button');
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveClass(styles['overlay-button']);
expect(buttons[0].parentElement).toBe(wrapper.getElement());
wrapper.click();
expect(onClick).toHaveBeenCalledTimes(1);
});

test('applies the header class to the header row with a top-aligned icon and no content', () => {
const { container } = render(
<ActionCard header="Header" icon={<span />} iconVerticalAlignment="top" {...{ styleClassNames }} />
);
const wrapper = createWrapper(container).findActionCard()!;
expect(wrapper.findContent()).toBeNull();
const headers = container.querySelectorAll('.b');
expect(headers).toHaveLength(1);
expect(headers[0]).toHaveClass(styles['header-row']);
expect(headers[0]).toContainElement(wrapper.findIcon()!.getElement());
});

test('applies the header class to the header with a centered icon', () => {
const { container } = renderActionCard({
header: 'Header',
icon: <span />,
iconVerticalAlignment: 'center',
styleClassNames,
});
expect(container.querySelectorAll('.b')).toHaveLength(1);
expect(container.querySelector('.b')).toHaveClass(styles.header);
expect(container.querySelector(`.${styles['header-row']}`)).toBeNull();
});

test('applies the header class when only a description is set', () => {
const { container } = renderActionCard({ description: 'Description', styleClassNames });
expect(container.querySelectorAll('.b')).toHaveLength(1);
expect(container.querySelector('.b')).toHaveClass(styles.header);
});

test('renders no header class when neither header nor description is set', () => {
const { container } = renderActionCard({ ariaLabel: 'Card', styleClassNames });
expect(container.querySelector('.b')).toBeNull();
});

test('applies the root class with href and when disabled', () => {
const link = renderActionCard({ header: 'Link', href: '#', styleClassNames });
expect(link.wrapper.getElement()).toHaveClass('a');
const disabled = renderActionCard({ header: 'Disabled', disabled: true, styleClassNames });
expect(disabled.wrapper.getElement()).toHaveClass('a');
});

test('does not leak the styleClassNames prop to the DOM', () => {
const { container } = renderActionCard({ header: 'Header', styleClassNames });
expect(container.querySelector('[styleClassNames]')).toBeNull();
});
});
25 changes: 21 additions & 4 deletions src/action-card/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ import { ActionCardProps } from './interfaces';
import styles from './styles.css.js';
import testStyles from './test-classes/styles.css.js';

export type InternalActionCardProps = ActionCardProps & InternalBaseComponentProps;
export type InternalActionCardProps = ActionCardProps &
InternalBaseComponentProps & {
styleClassNames?: StyleClassNames;
};

// Style API v2
export interface StyleClassNames {
root?: string;
header?: string;
}

const InternalActionCard = React.forwardRef(
(
Expand All @@ -42,6 +51,7 @@ const InternalActionCard = React.forwardRef(
nativeButtonAttributes,
nativeAnchorAttributes,
__internalRootRef,
styleClassNames,
...rest
}: InternalActionCardProps,
ref: React.Ref<ActionCardProps.Ref>
Expand Down Expand Up @@ -83,7 +93,7 @@ const InternalActionCard = React.forwardRef(
{icon}
</div>
);
const iconInHeaderRow = icon && iconVerticalAlignment === 'top' && !!header;
const iconInHeaderRow = icon && iconVerticalAlignment === 'top' && !headerRowEmpty;

const mergedRootRef = useMergeRefs(rootRef, __internalRootRef);

Expand Down Expand Up @@ -143,7 +153,13 @@ const InternalActionCard = React.forwardRef(
);

const headerSection = !headerRowEmpty ? (
<div className={clsx(styles.header, disableHeaderPaddings && styles['no-padding'])}>
<div
className={clsx(
!iconInHeaderRow && styleClassNames?.header,
styles.header,
disableHeaderPaddings && styles['no-padding']
)}
>
<InternalStructuredItem
content={
header && (
Expand Down Expand Up @@ -202,7 +218,7 @@ const InternalActionCard = React.forwardRef(
const contentElement = (
<div className={styles['inner-card']}>
{iconInHeaderRow ? (
<div className={styles['header-row']}>
<div className={clsx(styleClassNames?.header, styles['header-row'])}>
{headerSection}
{iconWrapper}
</div>
Expand All @@ -227,6 +243,7 @@ const InternalActionCard = React.forwardRef(
role="group"
aria-labelledby={header ? headerId : standaloneButtonId}
className={clsx(
styleClassNames?.root,
styles.root,
styles[`variant-${variant}`],
disabled && styles.disabled,
Expand Down
22 changes: 18 additions & 4 deletions src/action-card/mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@

@use 'sass:map';
@use '../internal/styles/tokens' as awsui;
@use '../internal/styles/style-api' as style-api;
@use './constants' as constants;

// Border tokens stay public: as carriers they would inherit into nested components that read the same carrier name.
$border-tokens: style-api.resolve((border-color, border-width), public);
$carrier-tokens: style-api.resolve((border-radius, focus-ring-color, focus-ring-width, focus-ring-radius), carrier);
$tokens: map.merge($border-tokens, $carrier-tokens);

$variant-border-radius: (
'embedded': awsui.$border-radius-action-card-embedded,
'default': awsui.$border-radius-action-card-default,
Expand All @@ -29,7 +35,15 @@ $variant-padding-inline: (

@mixin state-border-radius($variant: 'default', $state: 'default') {
$token: map.get($variant-border-radius, $variant);
@include border-radius-all($token);
@include border-radius-all(style-api.value($carrier-tokens, border-radius, $token));
}

@mixin inherit-border-tokens {
@if style-api.enabled() {
@each $token, $property in $border-tokens {
#{$property}: inherit;
}
}
}

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

&:after {
border-color: map.get($s, 'border-color');
border-block-width: map.get($s, 'border-width');
border-inline-width: map.get($s, 'border-width');
border-color: style-api.value($border-tokens, border-color, map.get($s, 'border-color'));
border-block-width: style-api.value($border-tokens, border-width, map.get($s, 'border-width'));
border-inline-width: style-api.value($border-tokens, border-width, map.get($s, 'border-width'));
}

> .icon,
Expand Down
Loading
Loading