Skip to content
Open
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>
);
}
102 changes: 100 additions & 2 deletions src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
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 Expand Up @@ -33806,10 +33895,19 @@ exports[`Test-utils dom definitions match the snapshot 1`] = `
{
"methods": [
{
"description": "Performs a click by triggering a mouse event on the internal button.
"description": "Performs a click by triggering a mouse event on the internal button or link.
Note that programmatic events ignore disabled attribute and will trigger listeners even if the element is disabled.",
"name": "click",
"parameters": [],
"parameters": [
{
"description": "optional mouse event properties, for example modifier keys (\`ctrlKey\`, \`metaKey\`) or the mouse \`button\`.",
"flags": {
"isOptional": true,
},
"name": "params",
"typeName": "MouseEventInit",
},
],
"returnType": {
"isNullable": false,
"name": "void",
Expand Down
126 changes: 126 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,132 @@ 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('does not set rel by default when target is not _blank', () => {
const wrapper = renderActionCard({ header: 'Header', href: '#test' });
expect(wrapper.getElement().querySelector('a')!).not.toHaveAttribute('rel');
});

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');
Comment thread
SpyZzey marked this conversation as resolved.
expect(wrapper.getElement().querySelector('button')).toBeNull();
});

test('disabled link stays focusable and announced as a link', () => {

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.

Why is it focusable when disabled?

@SpyZzey SpyZzey Jul 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It matches the current behavior in Action card with button tag.

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('does not fire onFollow when a modifier key is pressed while clicking', () => {
const onClickSpy = jest.fn();
const onFollowSpy = jest.fn();
const wrapper = renderActionCard({ header: 'Header', href: '#test', onClick: onClickSpy, onFollow: onFollowSpy });
wrapper.click({ button: 0, ctrlKey: true });
expect(onFollowSpy).not.toHaveBeenCalled();
expect(onClickSpy).toHaveBeenCalledTimes(1);
});

test('fires onFollow for the standalone (no header) anchor variant', () => {
const onFollowSpy = jest.fn();
const wrapper = renderActionCard({ ariaLabel: 'Card', href: '#test', target: '_blank', onFollow: onFollowSpy });
wrapper.click();
expect(onFollowSpy).toHaveBeenCalledTimes(1);
expect(onFollowSpy).toHaveBeenCalledWith(
expect.objectContaining({ detail: { href: '#test', target: '_blank' } })
);
});

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
Loading
Loading