From 06108f0f536982ab8be9511293ef954e7acca1b8 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 3 Dec 2025 10:11:17 +0100 Subject: [PATCH 1/3] chore: Refactor card into separate internal component --- src/cards/index.tsx | 103 +++++++++--------- src/cards/styles.scss | 83 +------------- src/internal/components/card/index.tsx | 49 +++++++++ src/internal/components/card/interfaces.ts | 56 ++++++++++ .../components/card}/motion.scss | 4 +- src/internal/components/card/styles.scss | 94 ++++++++++++++++ src/test-utils/dom/cards/index.ts | 5 +- 7 files changed, 258 insertions(+), 136 deletions(-) create mode 100644 src/internal/components/card/index.tsx create mode 100644 src/internal/components/card/interfaces.ts rename src/{cards => internal/components/card}/motion.scss (89%) create mode 100644 src/internal/components/card/styles.scss diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 93207b5624..a60e217e71 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -12,6 +12,7 @@ import { InternalContainerAsSubstep } from '../container/internal'; import { useInternalI18n } from '../i18n/context'; import { AnalyticsFunnelSubStep } from '../internal/analytics/components/analytics-funnel'; import { getBaseProps } from '../internal/base-component'; +import Card from '../internal/components/card'; import { CollectionLabelContext } from '../internal/context/collection-label-context'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; import useBaseComponent from '../internal/hooks/use-base-component'; @@ -269,7 +270,6 @@ const CardsList = ({ }) => { const selectable = !!selectionType; const canClickEntireCard = selectable && entireCardClickable; - const isRefresh = useVisualRefresh(); const { moveFocusDown, moveFocusUp } = useSelectionFocusMove(selectionType, items.length); @@ -315,60 +315,61 @@ const CardsList = ({ }, }; return ( -
  • + + + ) + } + active={selectable && selected} + className={styles.card} + header={ +
    + {cardDefinition.header ? cardDefinition.header(item) : ''} +
    + } + innerMetadataAttributes={ + entireCardClickable && !disabled ? getAnalyticsMetadataAttribute(selectionAnalyticsMetadata) : {} + } + key={index} + metadataAttributes={{ + ...getAnalyticsMetadataAttribute({ + component: { + innerContext: { + position: `${index + 1}`, + item: `${key}`, + }, + }, + }), + ...focusMarkers.item, + }} + onClick={ + canClickEntireCard + ? event => { + selectionProps?.onChange(); + // Manually move focus to the native input (checkbox or radio button) + event.currentTarget.querySelector('input')?.focus(); + } + : undefined + } onFocus={onFocus} - {...(focusMarkers && focusMarkers.item)} role={listItemRole} - {...getAnalyticsMetadataAttribute({ - component: { - innerContext: { - position: `${index + 1}`, - item: `${key}`, - }, - }, - })} + TagName="li" > -
    { - selectionProps?.onChange(); - // Manually move focus to the native input (checkbox or radio button) - event.currentTarget.querySelector('input')?.focus(); - } - : undefined - } - > -
    -
    - {cardDefinition.header ? cardDefinition.header(item) : ''} -
    - {selectionProps && ( -
    - -
    - )} + {visibleSectionsDefinition.map(({ width = 100, header, content, id }, index) => ( +
    + {header ?
    {header}
    : ''} + {content ?
    {content(item)}
    : ''}
    - {visibleSectionsDefinition.map(({ width = 100, header, content, id }, index) => ( -
    - {header ?
    {header}
    : ''} - {content ?
    {content(item)}
    : ''} -
    - ))} -
    -
  • + ))} + ); })} diff --git a/src/cards/styles.scss b/src/cards/styles.scss index b2075f281b..52f98ba971 100644 --- a/src/cards/styles.scss +++ b/src/cards/styles.scss @@ -7,47 +7,9 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; -@use '../container/shared' as container; -@use './motion'; - -@mixin card-style { - border-start-start-radius: awsui.$border-radius-container; - border-start-end-radius: awsui.$border-radius-container; - border-end-start-radius: awsui.$border-radius-container; - border-end-end-radius: awsui.$border-radius-container; - box-sizing: border-box; - - &::before { - @include styles.base-pseudo-element; - // Reset border color to prevent it from flashing black during card selection animation - border-color: transparent; - border-block-start: awsui.$border-container-top-width solid awsui.$color-border-container-top; - border-start-start-radius: awsui.$border-radius-container; - border-start-end-radius: awsui.$border-radius-container; - border-end-start-radius: awsui.$border-radius-container; - border-end-end-radius: awsui.$border-radius-container; - z-index: 1; - } - - &::after { - @include styles.base-pseudo-element; - border-start-start-radius: awsui.$border-radius-container; - border-start-end-radius: awsui.$border-radius-container; - border-end-start-radius: awsui.$border-radius-container; - border-end-end-radius: awsui.$border-radius-container; - } - &:not(.refresh)::after { - box-shadow: awsui.$shadow-container; - } - &.refresh::after { - border-block: solid awsui.$border-divider-section-width awsui.$color-border-divider-default; - border-inline: solid awsui.$border-divider-section-width awsui.$color-border-divider-default; - } -} .root { @include styles.styles-reset(); - @include styles.default-text-style; } .header { @@ -111,49 +73,8 @@ } } -.card { - display: flex; - overflow-wrap: break-word; - word-wrap: break-word; - margin-block: 0; - margin-inline: 0; - padding-block: 0; - padding-inline: 0; - list-style: none; - &-inner { - position: relative; - background-color: awsui.$color-background-container-content; - margin-block-start: 0; - margin-block-end: awsui.$space-grid-gutter; - margin-inline-start: awsui.$space-grid-gutter; - margin-inline-end: 0; - padding-block: awsui.$space-card-vertical; - padding-inline: awsui.$space-card-horizontal; - inline-size: 100%; - min-inline-size: 0; - @include card-style; - } - &-header { - @include styles.font-heading-m; - &-inner { - inline-size: 100%; - display: inline-block; - } - } - &-selectable { - > .card-inner > .card-header { - inline-size: 90%; - } - } - &-selected { - > .card-inner { - background-color: awsui.$color-background-item-selected; - &::before { - border-block: awsui.$border-item-width solid awsui.$color-border-item-selected; - border-inline: awsui.$border-item-width solid awsui.$color-border-item-selected; - } - } - } +.card-header { + @include styles.font-heading-m; } .section { diff --git a/src/internal/components/card/index.tsx b/src/internal/components/card/index.tsx new file mode 100644 index 0000000000..29ed5aaf7e --- /dev/null +++ b/src/internal/components/card/index.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { useVisualRefresh } from '../../hooks/use-visual-mode'; +import { InternalCardProps } from './interfaces'; + +import styles from './styles.css.js'; + +export default function Card({ + action, + active, + children, + className, + header, + innerMetadataAttributes, + metadataAttributes, + onClick, + onFocus, + role, + TagName = 'div', +}: InternalCardProps) { + const isRefresh = useVisualRefresh(); + + return ( + +
    +
    +
    {header}
    + {action &&
    {action}
    } +
    + {children} +
    +
    + ); +} diff --git a/src/internal/components/card/interfaces.ts b/src/internal/components/card/interfaces.ts new file mode 100644 index 0000000000..c37d4c2d61 --- /dev/null +++ b/src/internal/components/card/interfaces.ts @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { FocusEventHandler } from 'react'; + +import { BaseComponentProps } from '../../base-component'; + +export interface InternalCardProps extends BaseComponentProps { + /** + * Specifies an action for the card. + * It is recommended to use a button with inline-icon variant. + */ + action?: React.ReactNode; + + /** + * Specifies whether the card is in active state. + */ + active?: boolean; + + /** + * Optional URL for an image which will be displayed cropped as a background of the card. + * When this property is used, a dark gradient is overlayed and the text above defaults to bright colors. + * Make sure that any content you place on the card has sufficient contrast with the overlayed image behind. + */ + imageUrl?: string; + + /** + * Primary content displayed in the card. + */ + children?: React.ReactNode; + + /** + * Heading text. + */ + header?: React.ReactNode; + + /** + * Icon which will be displayed at the top of the card, + * inline at the start of the content. + */ + icon?: React.ReactNode; + + /** + * Called when the user clicks on the card. + */ + onClick?: React.MouseEventHandler; + + onFocus?: FocusEventHandler; + + role?: string; + + TagName?: 'li' | 'div'; + + metadataAttributes: Record; + + innerMetadataAttributes: Record; +} diff --git a/src/cards/motion.scss b/src/internal/components/card/motion.scss similarity index 89% rename from src/cards/motion.scss rename to src/internal/components/card/motion.scss index 385aa1d4b2..4dcf4658d3 100644 --- a/src/cards/motion.scss +++ b/src/internal/components/card/motion.scss @@ -3,8 +3,8 @@ SPDX-License-Identifier: Apache-2.0 */ -@use '../internal/styles' as styles; -@use '../internal/styles/tokens' as awsui; +@use '../../styles' as styles; +@use '../../styles/tokens' as awsui; .card-inner { @include styles.with-motion { diff --git a/src/internal/components/card/styles.scss b/src/internal/components/card/styles.scss new file mode 100644 index 0000000000..35f93abb15 --- /dev/null +++ b/src/internal/components/card/styles.scss @@ -0,0 +1,94 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use 'sass:math'; + +@use '../../styles' as styles; +@use '../../styles/tokens' as awsui; +@use './motion'; + +@mixin card-style { + border-start-start-radius: awsui.$border-radius-container; + border-start-end-radius: awsui.$border-radius-container; + border-end-start-radius: awsui.$border-radius-container; + border-end-end-radius: awsui.$border-radius-container; + box-sizing: border-box; + + &::before { + @include styles.base-pseudo-element; + // Reset border color to prevent it from flashing black during card selection animation + border-color: transparent; + border-block-start: awsui.$border-container-top-width solid awsui.$color-border-container-top; + border-start-start-radius: awsui.$border-radius-container; + border-start-end-radius: awsui.$border-radius-container; + border-end-start-radius: awsui.$border-radius-container; + border-end-end-radius: awsui.$border-radius-container; + z-index: 1; + } + + &::after { + @include styles.base-pseudo-element; + border-start-start-radius: awsui.$border-radius-container; + border-start-end-radius: awsui.$border-radius-container; + border-end-start-radius: awsui.$border-radius-container; + border-end-end-radius: awsui.$border-radius-container; + } + &:not(.refresh)::after { + box-shadow: awsui.$shadow-container; + } + &.refresh::after { + border-block: solid awsui.$border-divider-section-width awsui.$color-border-divider-default; + border-inline: solid awsui.$border-divider-section-width awsui.$color-border-divider-default; + } +} + +.root { + @include styles.styles-reset(); +} + +.card { + display: flex; + overflow-wrap: break-word; + word-wrap: break-word; + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; + list-style: none; + &-inner { + position: relative; + background-color: awsui.$color-background-container-content; + margin-block-start: 0; + margin-block-end: awsui.$space-grid-gutter; + margin-inline-start: awsui.$space-grid-gutter; + margin-inline-end: 0; + padding-block: awsui.$space-card-vertical; + padding-inline: awsui.$space-card-horizontal; + inline-size: 100%; + min-inline-size: 0; + @include card-style; + } + &-header { + @include styles.font-heading-m; + &-inner { + inline-size: 100%; + display: inline-block; + } + } + &-with-action { + > .card-inner > .card-header { + inline-size: 90%; + } + } + &-active { + > .card-inner { + background-color: awsui.$color-background-item-selected; + &::before { + border-block: awsui.$border-item-width solid awsui.$color-border-item-selected; + border-inline: awsui.$border-item-width solid awsui.$color-border-item-selected; + } + } + } +} diff --git a/src/test-utils/dom/cards/index.ts b/src/test-utils/dom/cards/index.ts index adc16584c3..13b8277a87 100644 --- a/src/test-utils/dom/cards/index.ts +++ b/src/test-utils/dom/cards/index.ts @@ -8,6 +8,7 @@ import PaginationWrapper from '../pagination'; import TextFilterWrapper from '../text-filter'; import styles from '../../../cards/styles.selectors.js'; +import cardStyles from '../../../internal/components/card/styles.selectors.js'; import tableStyles from '../../../table/styles.selectors.js'; class CardSectionWrapper extends ComponentWrapper { @@ -31,7 +32,7 @@ class CardWrapper extends ComponentWrapper { } findCardHeader(): ElementWrapper | null { - return this.findByClassName(styles['card-header-inner']); + return this.findByClassName(cardStyles['card-header-inner']); } findSelectionArea(): ElementWrapper | null { @@ -49,7 +50,7 @@ export default class CardsWrapper extends ComponentWrapper { } findSelectedItems(): Array { - return this.findAllByClassName(styles['card-selected']).map(c => new CardWrapper(c.getElement())); + return this.findAllByClassName(cardStyles['card-active']).map(c => new CardWrapper(c.getElement())); } findHeader(): ElementWrapper | null { From 5738e5fd9eee35c5ad649bd7d6d23ef4ef10f51a Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Wed, 3 Dec 2025 18:11:00 +0100 Subject: [PATCH 2/3] Fix analytics metadata --- src/cards/index.tsx | 12 ++++++++---- src/internal/components/card/index.tsx | 2 +- src/internal/components/card/styles.scss | 3 --- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/cards/index.tsx b/src/cards/index.tsx index a60e217e71..7e7a00392f 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -331,12 +331,16 @@ const CardsList = ({ active={selectable && selected} className={styles.card} header={ -
    - {cardDefinition.header ? cardDefinition.header(item) : ''} -
    + cardDefinition.header ? ( +
    + {cardDefinition.header(item)} +
    + ) : ( + '' + ) } innerMetadataAttributes={ - entireCardClickable && !disabled ? getAnalyticsMetadataAttribute(selectionAnalyticsMetadata) : {} + canClickEntireCard && !disabled ? getAnalyticsMetadataAttribute(selectionAnalyticsMetadata) : {} } key={index} metadataAttributes={{ diff --git a/src/internal/components/card/index.tsx b/src/internal/components/card/index.tsx index 29ed5aaf7e..b9fa73119c 100644 --- a/src/internal/components/card/index.tsx +++ b/src/internal/components/card/index.tsx @@ -25,7 +25,7 @@ export default function Card({ return ( Date: Wed, 3 Dec 2025 18:14:36 +0100 Subject: [PATCH 3/3] Minor refactor --- src/cards/index.tsx | 2 +- src/internal/components/card/index.tsx | 2 +- src/internal/components/card/interfaces.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 7e7a00392f..6ea70bbf35 100644 --- a/src/cards/index.tsx +++ b/src/cards/index.tsx @@ -365,7 +365,7 @@ const CardsList = ({ } onFocus={onFocus} role={listItemRole} - TagName="li" + tagName="li" > {visibleSectionsDefinition.map(({ width = 100, header, content, id }, index) => (
    diff --git a/src/internal/components/card/index.tsx b/src/internal/components/card/index.tsx index b9fa73119c..00172ac483 100644 --- a/src/internal/components/card/index.tsx +++ b/src/internal/components/card/index.tsx @@ -19,7 +19,7 @@ export default function Card({ onClick, onFocus, role, - TagName = 'div', + tagName: TagName = 'div', }: InternalCardProps) { const isRefresh = useVisualRefresh(); diff --git a/src/internal/components/card/interfaces.ts b/src/internal/components/card/interfaces.ts index c37d4c2d61..159f8fc505 100644 --- a/src/internal/components/card/interfaces.ts +++ b/src/internal/components/card/interfaces.ts @@ -48,7 +48,7 @@ export interface InternalCardProps extends BaseComponentProps { role?: string; - TagName?: 'li' | 'div'; + tagName?: 'li' | 'div'; metadataAttributes: Record;