From 5c996a1f7ac9f05622969aae21f2c677b793644b Mon Sep 17 00:00:00 2001 From: Simon Brebeck Date: Wed, 1 Jul 2026 14:47:35 +0200 Subject: [PATCH 1/3] feat: Add href to action card --- pages/action-card/link.page.tsx | 62 ++++++++++ .../__tests__/action-card.test.tsx | 101 +++++++++++++++ src/action-card/interfaces.ts | 48 ++++++- src/action-card/internal.tsx | 117 +++++++++++++----- src/action-card/styles.scss | 6 +- 5 files changed, 300 insertions(+), 34 deletions(-) create mode 100644 pages/action-card/link.page.tsx diff --git a/pages/action-card/link.page.tsx b/pages/action-card/link.page.tsx new file mode 100644 index 0000000000..a8c10696d7 --- /dev/null +++ b/pages/action-card/link.page.tsx @@ -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(null); + + return ( + + +
Last followed: {lastFollowed ?? 'None'}
+ +
+ + Navigates with href} + description="Renders as an anchor element" + href="#in-page" + icon={} + onFollow={event => { + event.preventDefault(); + setLastFollowed('Header card'); + }} + /> + + } + iconVerticalAlignment="center" + onFollow={event => { + event.preventDefault(); + setLastFollowed('Standalone card'); + }} + > + Standalone link card + + + External link (new tab)} + description="Opens in a new tab" + href="https://cloudscape.design/" + target="_blank" + icon={} + /> + + Disabled link} + description="href is removed when disabled" + href="#disabled" + disabled={true} + /> + +
+
+
+ ); +} diff --git a/src/action-card/__tests__/action-card.test.tsx b/src/action-card/__tests__/action-card.test.tsx index bbeb036ffb..6b0c68f7ce 100644 --- a/src/action-card/__tests__/action-card.test.tsx +++ b/src/action-card/__tests__/action-card.test.tsx @@ -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'); + }); + + 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' }); diff --git a/src/action-card/interfaces.ts b/src/action-card/interfaces.ts index 901ca6601c..b04dc274aa 100644 --- a/src/action-card/interfaces.ts +++ b/src/action-card/interfaces.ts @@ -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 { @@ -27,6 +27,38 @@ export interface ActionCardProps extends BaseComponentProps { */ onClick?: CancelableEventHandler; + /** + * 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; + /** * Adds an aria-label to the action card. */ @@ -81,12 +113,26 @@ export interface ActionCardProps extends BaseComponentProps { * @awsuiSystem core */ nativeButtonAttributes?: NativeAttributes>; + + /** + * 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>; } 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'; diff --git a/src/action-card/internal.tsx b/src/action-card/internal.tsx index 88f18f301f..3b38438602 100644 --- a/src/action-card/internal.tsx +++ b/src/action-card/internal.tsx @@ -7,9 +7,10 @@ import { useMergeRefs, useUniqueId, warnOnce } from '@cloudscape-design/componen import { getBaseProps } from '../internal/base-component'; import InternalStructuredItem from '../internal/components/structured-item'; -import { fireCancelableEvent } from '../internal/events'; +import { fireCancelableEvent, isPlainLeftClick } from '../internal/events'; import useForwardFocus from '../internal/hooks/forward-focus'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { checkSafeUrl } from '../internal/utils/check-safe-url'; import WithNativeAttributes from '../internal/utils/with-native-attributes'; import { ActionCardProps } from './interfaces'; @@ -25,6 +26,11 @@ const InternalActionCard = React.forwardRef( description, children, onClick, + onFollow, + href, + target, + rel, + download, ariaLabel, ariaDescribedby, disabled, @@ -34,13 +40,14 @@ const InternalActionCard = React.forwardRef( iconVerticalAlignment, variant, nativeButtonAttributes, + nativeAnchorAttributes, __internalRootRef, ...rest }: InternalActionCardProps, ref: React.Ref ) => { const baseProps = getBaseProps(rest); - const buttonRef = useRef(null); + const buttonRef = useRef(null); const rootRef = useRef(null); const headerId = useUniqueId('action-card-header-'); const standaloneButtonId = useUniqueId('action-card-button-'); @@ -49,6 +56,9 @@ const InternalActionCard = React.forwardRef( useForwardFocus(ref, buttonRef); + checkSafeUrl('ActionCard', href); + const isAnchor = Boolean(href); + if (!header && !ariaLabel) { warnOnce( 'ActionCard', @@ -56,10 +66,13 @@ const InternalActionCard = React.forwardRef( ); } - const handleButtonClick = (event: React.MouseEvent) => { + const handleButtonClick = (event: React.MouseEvent) => { if (disabled) { return event.preventDefault(); } + if (isAnchor && isPlainLeftClick(event)) { + fireCancelableEvent(onFollow, { href, target }, event); + } fireCancelableEvent(onClick, {}, event); }; @@ -80,8 +93,8 @@ const InternalActionCard = React.forwardRef( ariaDescribedby = descriptionId; } - const buttonProps = { - type: 'button' as const, + // Shared props between -tag and