Skip to content
Draft
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
62 changes: 62 additions & 0 deletions pages/action-card/link.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';

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

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

export default function ActionCardLinkPage() {
const [lastFollowed, setLastFollowed] = React.useState<string | null>(null);

return (
<SimplePage title="Action Card link page" screenshotArea={{}}>
<SpaceBetween size="l">
<div>Last followed: {lastFollowed ?? 'None'}</div>

<div style={{ maxInlineSize: '400px' }}>
<SpaceBetween size="m">
<ActionCard
header={<b>Navigates with href</b>}
description="Renders as an anchor element"
href="#in-page"
icon={<Icon name="angle-right" />}
onFollow={event => {
event.preventDefault();
setLastFollowed('Header card');
}}
/>

<ActionCard
ariaLabel="Standalone link card"
href="#standalone"
icon={<Icon name="angle-right" />}
iconVerticalAlignment="center"
onFollow={event => {
event.preventDefault();
setLastFollowed('Standalone card');
}}
>
Standalone link card
</ActionCard>

<ActionCard
header={<b>External link (new tab)</b>}
description="Opens in a new tab"
href="https://cloudscape.design/"
target="_blank"
icon={<Icon name="external" />}
/>

<ActionCard
header={<b>Disabled link</b>}
description="href is removed when disabled"
href="#disabled"
disabled={true}
/>
</SpaceBetween>
</div>
</SpaceBetween>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ exports[`Components definition for action-card matches the snapshot: action-card
"description": "Called when the user clicks on the action card.",
"name": "onClick",
},
{
"cancelable": true,
"description": "Called when the user clicks on the action card with the left mouse button without pressing
modifier keys (that is, CTRL, ALT, SHIFT, META), and the action card has an \`href\` set.",
"detailInlineType": {
"name": "BaseNavigationDetail",
"properties": [
{
"name": "external",
"optional": true,
"type": "boolean",
},
{
"name": "href",
"optional": true,
"type": "string",
},
{
"name": "target",
"optional": true,
"type": "string",
},
],
"type": "object",
},
"detailType": "BaseNavigationDetail",
"name": "onFollow",
},
],
"functions": [
{
Expand Down Expand Up @@ -60,6 +88,30 @@ exports[`Components definition for action-card matches the snapshot: action-card
"optional": true,
"type": "boolean",
},
{
"description": "Specifies whether the linked URL, when selected, will prompt the user to download instead of navigate.
You can specify a string value that will be suggested as the name of the downloaded file.
This property only applies when an \`href\` is provided.",
"inlineType": {
"name": "string | boolean",
"type": "union",
"values": [
"string",
"false",
"true",
],
},
"name": "download",
"optional": true,
"type": "string | boolean",
},
{
"description": "Turns the action card into a link, pointing to the given URL. The card is rendered using an \`a\` element instead of a \`button\`.
For example, use this property if selecting the card should navigate the user to another page.",
"name": "href",
"optional": true,
"type": "string",
},
{
"defaultValue": "'top'",
"description": "Specifies the vertical alignment of the icon.",
Expand All @@ -84,6 +136,28 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
"optional": true,
"type": "string",
},
{
"description": "Attributes to add to the native \`a\` element (when \`href\` is provided).
Some attributes will be automatically combined with internal attribute values:
- \`className\` will be appended.
- Event handlers will be chained, unless the default is prevented.

We do not support using this attribute to apply custom styling.",
"inlineType": {
"name": "Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & Record<\`data-\${string}\`, string>",
"type": "union",
"values": [
"Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children">",
"Record<\`data-\${string}\`, string>",
],
},
"name": "nativeAnchorAttributes",
"optional": true,
"systemTags": [
"core",
],
"type": "Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & Record<\`data-\${string}\`, string>",
},
{
"description": "Attributes to add to the native button element.
Some attributes will be automatically combined with internal attribute values:
Expand All @@ -106,6 +180,21 @@ We do not support using this attribute to apply custom styling.",
],
"type": "Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & Record<\`data-\${string}\`, string>",
},
{
"description": "Adds a \`rel\` attribute to the link. By default, the component sets the \`rel\` attribute to "noopener noreferrer" when \`target\` is \`"_blank"\`.
If the \`rel\` property is provided, it overrides the default behavior.
This property only applies when an \`href\` is provided.",
"name": "rel",
"optional": true,
"type": "string",
},
{
"description": "Specifies where to open the linked URL (for example, to open in a new browser window or tab use \`_blank\`).
This property only applies when an \`href\` is provided.",
"name": "target",
"optional": true,
"type": "string",
},
{
"defaultValue": "'default'",
"description": "Specifies the visual variant of the card, which controls the border radius and padding.
Expand Down
101 changes: 101 additions & 0 deletions src/action-card/__tests__/action-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,107 @@ describe('ActionCard Component', () => {
});
});

describe('href', () => {
test('renders a button by default', () => {
const wrapper = renderActionCard({ header: 'Header' });
expect(wrapper.getElement().querySelector('button')).toBeTruthy();
expect(wrapper.getElement().querySelector('a')).toBeNull();
});

test('renders an anchor with href when href is provided', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test' });
const anchor = wrapper.getElement().querySelector('a')!;
expect(anchor).toBeTruthy();
expect(anchor).toHaveAttribute('href', '#test');
expect(wrapper.getElement().querySelector('button')).toBeNull();
});

test('renders an anchor for the standalone (no header) variant', () => {
const wrapper = renderActionCard({ ariaLabel: 'Card', href: '#test' });
const anchor = wrapper.getElement().querySelector('a')!;
expect(anchor).toHaveAttribute('href', '#test');
expect(anchor).toHaveAttribute('aria-label', 'Card');
});

test('applies target and default rel for _blank', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test', target: '_blank' });
const anchor = wrapper.getElement().querySelector('a')!;
expect(anchor).toHaveAttribute('target', '_blank');
expect(anchor).toHaveAttribute('rel', 'noopener noreferrer');
});

test('custom rel overrides the default', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test', target: '_blank', rel: 'nofollow' });
expect(wrapper.getElement().querySelector('a')!).toHaveAttribute('rel', 'nofollow');
});

test('applies download attribute', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test', download: 'file.txt' });
expect(wrapper.getElement().querySelector('a')!).toHaveAttribute('download', 'file.txt');
});

test('removes href when disabled', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test', disabled: true });
const anchor = wrapper.getElement().querySelector('a')!;
expect(anchor).not.toHaveAttribute('href');
expect(anchor).toHaveAttribute('aria-disabled', 'true');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to add a check for the button element here

});

test('disabled link stays focusable and announced as a link', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test', disabled: true });
const anchor = wrapper.getElement().querySelector('a')!;
expect(anchor).toHaveAttribute('role', 'link');
anchor.focus();
expect(document.activeElement).toBe(anchor);
});
});

describe('onFollow', () => {
test('fires onFollow with href and target when href is set', () => {
const onFollowSpy = jest.fn();
const wrapper = renderActionCard({ header: 'Header', href: '#test', target: '_blank', onFollow: onFollowSpy });
wrapper.click();
expect(onFollowSpy).toHaveBeenCalledTimes(1);
expect(onFollowSpy).toHaveBeenCalledWith(
expect.objectContaining({ detail: { href: '#test', target: '_blank' } })
);
});

test('does not fire onFollow when no href is set', () => {
const onFollowSpy = jest.fn();
const wrapper = renderActionCard({ header: 'Header', onFollow: onFollowSpy });
wrapper.click();
expect(onFollowSpy).not.toHaveBeenCalled();
});

test('does not fire onFollow when disabled', () => {
const onFollowSpy = jest.fn();
const wrapper = renderActionCard({ header: 'Header', href: '#test', disabled: true, onFollow: onFollowSpy });
wrapper.click();
expect(onFollowSpy).not.toHaveBeenCalled();
});

test('still fires onClick alongside onFollow', () => {
const onClickSpy = jest.fn();
const onFollowSpy = jest.fn();
const wrapper = renderActionCard({ header: 'Header', href: '#test', onClick: onClickSpy, onFollow: onFollowSpy });
wrapper.click();
expect(onClickSpy).toHaveBeenCalledTimes(1);
expect(onFollowSpy).toHaveBeenCalledTimes(1);
});
});

describe('nativeAnchorAttributes', () => {
test('passes custom attributes to the anchor element', () => {
const wrapper = renderActionCard({
header: 'Header',
href: '#test',
nativeAnchorAttributes: { 'data-testid': 'test-anchor' },
});
expect(wrapper.getElement().querySelector('a')!).toHaveAttribute('data-testid', 'test-anchor');
});
});

describe('ariaLabel', () => {
test('root always has role=group', () => {
const withHeader = renderActionCard({ header: 'Header' });
Expand Down
48 changes: 47 additions & 1 deletion src/action-card/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import React, { ReactNode } from 'react';

import { BaseComponentProps } from '../types/base-component';
import { CancelableEventHandler } from '../types/events';
import { BaseNavigationDetail, CancelableEventHandler } from '../types/events';
import { NativeAttributes } from '../types/native-attributes';

export interface ActionCardProps extends BaseComponentProps {
Expand All @@ -27,6 +27,38 @@ export interface ActionCardProps extends BaseComponentProps {
*/
onClick?: CancelableEventHandler<ActionCardProps.ClickDetail>;

/**
* Turns the action card into a link, pointing to the given URL. The card is rendered using an `a` element instead of a `button`.
* For example, use this property if selecting the card should navigate the user to another page.
*/
href?: string;

/**
* Specifies where to open the linked URL (for example, to open in a new browser window or tab use `_blank`).
* This property only applies when an `href` is provided.
*/
target?: string;

/**
* Adds a `rel` attribute to the link. By default, the component sets the `rel` attribute to "noopener noreferrer" when `target` is `"_blank"`.
* If the `rel` property is provided, it overrides the default behavior.
* This property only applies when an `href` is provided.
*/
rel?: string;

/**
* Specifies whether the linked URL, when selected, will prompt the user to download instead of navigate.
* You can specify a string value that will be suggested as the name of the downloaded file.
* This property only applies when an `href` is provided.
*/
download?: boolean | string;

/**
* Called when the user clicks on the action card with the left mouse button without pressing
* modifier keys (that is, CTRL, ALT, SHIFT, META), and the action card has an `href` set.
*/
onFollow?: CancelableEventHandler<ActionCardProps.FollowDetail>;

/**
* Adds an aria-label to the action card.
*/
Expand Down Expand Up @@ -81,12 +113,26 @@ export interface ActionCardProps extends BaseComponentProps {
* @awsuiSystem core
*/
nativeButtonAttributes?: NativeAttributes<React.ButtonHTMLAttributes<HTMLButtonElement>>;

/**
* Attributes to add to the native `a` element (when `href` is provided).
* Some attributes will be automatically combined with internal attribute values:
* - `className` will be appended.
* - Event handlers will be chained, unless the default is prevented.
*
* We do not support using this attribute to apply custom styling.
*
* @awsuiSystem core
*/
nativeAnchorAttributes?: NativeAttributes<React.AnchorHTMLAttributes<HTMLAnchorElement>>;
}

export namespace ActionCardProps {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ClickDetail {}

export type FollowDetail = BaseNavigationDetail;

export type IconVerticalAlignment = 'top' | 'center';
export type Variant = 'default' | 'embedded';

Expand Down
Loading
Loading