diff --git a/pages/card/action-card-in-chat.page.tsx b/pages/card/action-card-in-chat.page.tsx new file mode 100644 index 0000000000..6e156d5e3d --- /dev/null +++ b/pages/card/action-card-in-chat.page.tsx @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Card from '~components/internal/components/card'; + +import { CardPage } from './common'; + +export default function ButtonsScenario() { + const [isActive, setActive] = useState(false); + return ( + + setActive(true)}> + A more detailed description of the action. + + + ); +} diff --git a/pages/card/action-card-in-list.page.tsx b/pages/card/action-card-in-list.page.tsx new file mode 100644 index 0000000000..8a6c6b85e2 --- /dev/null +++ b/pages/card/action-card-in-list.page.tsx @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Container from '~components/container'; +import Header from '~components/header'; +import Card from '~components/internal/components/card'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const accounts = [ + { + alias: 'Account alias', + id: '873423479685', + role: 'Dev', + email: 'john.doe@anycompany.com', + lastLogin: '1 minute ago', + }, + { + id: '63547903567', + role: 'ReadOnly', + email: 'john.doe@anycompany.com', + lastLogin: '10 minute ago', + }, + { + id: '583821526507', + role: 'Root', + lastLogin: '2 hours ago', + }, + { + alias: 'acme-staging-infra', + id: '886694904548', + role: 'Admin', + email: 'john.doe@anycompany.com', + lastLogin: '3 hours ago', + }, + { + alias: 'acme-prod-monitoring', + id: '634308714948', + role: 'PowerUser', + email: 'john.doe@anycompany.com', + lastLogin: '10 hours ago', + }, +]; + +export default function ButtonsScenario() { + const [activeId, setActiveId] = useState(); + return ( +
+

Action card: list selection

+ +
+ Choose an active session}> +
    + {accounts.map(({ alias, id, role, email, lastLogin }) => ( +
  1. + setActiveId(id)} + > + {`Logged in ${lastLogin}`} + +
  2. + ))} +
+
+
+
+
+ ); +} diff --git a/pages/card/code-snippet.page.tsx b/pages/card/code-snippet.page.tsx new file mode 100644 index 0000000000..f9014cac03 --- /dev/null +++ b/pages/card/code-snippet.page.tsx @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import CopyToClipboard from '~components/copy-to-clipboard'; +import Card from '~components/internal/components/card'; + +import { CardPage } from './common'; + +export default function ButtonsScenario() { + return ( + + + } + > + +
+            {`def lambda_handler(event, context):
+  bucket = event['Records'][0]['s3']['bucket']['name']
+  key = event['Records'][0]['s3']['object']['key']
+  print(f'New file uploaded: {key} in bucket {bucket}')
+  return {'statusCode': 200}`}
+          
+
+
+
+ ); +} diff --git a/pages/card/common.tsx b/pages/card/common.tsx new file mode 100644 index 0000000000..2eff09bba8 --- /dev/null +++ b/pages/card/common.tsx @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ReactNode } from 'react'; +import React from 'react'; + +import ScreenshotArea from '../utils/screenshot-area'; + +export function CardPage({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+
+ {children} +
+
+ ); +} diff --git a/pages/card/image-preview-with-custom-header.page.tsx b/pages/card/image-preview-with-custom-header.page.tsx new file mode 100644 index 0000000000..90538e1d42 --- /dev/null +++ b/pages/card/image-preview-with-custom-header.page.tsx @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import ButtonGroup from '~components/button-group'; +import Card from '~components/internal/components/card'; + +import image from '../container/images/16-9.png'; +import { CardPage } from './common'; + +export default function ButtonsScenario() { + return ( +
+ + +
+
image-title.jpg
+ null} + items={[ + { + type: 'icon-button', + id: 'download', + iconName: 'download', + text: 'Download', + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Expand', + }, + ]} + variant={'icon'} + /> +
+ Metadata about file - 4GB + + } + disableContentPaddings={true} + > +
+ + +
+ ); +} diff --git a/pages/card/image-preview.page.tsx b/pages/card/image-preview.page.tsx new file mode 100644 index 0000000000..02be9d3b4f --- /dev/null +++ b/pages/card/image-preview.page.tsx @@ -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 ButtonGroup from '~components/button-group'; +import Card from '~components/internal/components/card'; + +import image from '../container/images/16-9.png'; +import { CardPage } from './common'; + +export default function ButtonsScenario() { + return ( +
+ + null} + items={[ + { + type: 'icon-button', + id: 'download', + iconName: 'download', + text: 'Download', + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Expand', + }, + ]} + variant={'icon'} + /> + } + disableContentPaddings={true} + > +
+ + +
+ ); +} diff --git a/pages/card/preview.page.tsx b/pages/card/preview.page.tsx new file mode 100644 index 0000000000..ba4977f894 --- /dev/null +++ b/pages/card/preview.page.tsx @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import ButtonGroup from '~components/button-group'; +import Card from '~components/internal/components/card'; + +import image from '../container/images/16-9.png'; +import ScreenshotArea from '../utils/screenshot-area'; + +export default function ButtonsScenario() { + return ( +
+

Preview

+ + null} + items={[ + { + type: 'icon-button', + id: 'download', + iconName: 'download', + text: 'Download', + }, + { + type: 'icon-button', + id: 'expand', + iconName: 'expand', + text: 'Expand', + }, + ]} + variant={'icon'} + /> + } + disableContentPaddings={true} + > + + + +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 86add834db..c5114c6806 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -381,6 +381,7 @@ exports[`test-utils selectors 1`] = ` "awsui_filtering-match-highlight_1p2cx", "awsui_footer_dgs8z", "awsui_has-background_15o6u", + "awsui_header-inner_1xxz5", "awsui_header_dgs8z", "awsui_highlighted_15o6u", "awsui_icon_x6dl3", diff --git a/src/cards/index.tsx b/src/cards/index.tsx index 93207b5624..aa7358234e 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); @@ -317,7 +317,6 @@ const CardsList = ({ return (
  • ({ }, })} > -
    + +
    + ) + } + active={selectable && selected} + header={ + cardDefinition.header ? ( +
    + {cardDefinition.header(item)} +
    + ) : ( + '' + ) + } + metadataAttributes={ + canClickEntireCard && !disabled ? getAnalyticsMetadataAttribute(selectionAnalyticsMetadata) : {} + } onClick={ canClickEntireCard ? event => { @@ -346,28 +368,13 @@ const CardsList = ({ : undefined } > -
    -
    - {cardDefinition.header ? cardDefinition.header(item) : ''} -
    - {selectionProps && ( -
    - -
    - )} -
    {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 02c6a97dcc..92a34898c7 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 { @@ -123,37 +85,15 @@ padding-inline-start: awsui.$space-grid-gutter; padding-inline-end: 0; list-style: none; - &-inner { - position: relative; - background-color: awsui.$color-background-container-content; - margin-block: 0; - margin-inline: 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%; + /* Used in test utils */ + padding-block-start: 4px; } } &-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; - } - } + /* Used in test utils */ } } diff --git a/src/internal/components/card/index.tsx b/src/internal/components/card/index.tsx new file mode 100644 index 0000000000..70f370889c --- /dev/null +++ b/src/internal/components/card/index.tsx @@ -0,0 +1,57 @@ +// 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 InternalIcon from '../../../icon/internal'; +import { useVisualRefresh } from '../../hooks/use-visual-mode'; +import { InternalCardProps } from './interfaces'; + +import styles from './styles.css.js'; + +export default function Card({ + actions, + active, + children, + className, + header, + description, + metadataAttributes, + onClick, + disableContentPaddings, + variant = 'default', +}: InternalCardProps) { + const isRefresh = useVisualRefresh(); + + const hasActions = !!actions || variant === 'action'; + + return ( +
    +
    +
    +
    {header}
    + {hasActions && ( +
    +
    {actions || }
    +
    + )} +
    + {description &&
    {description}
    } +
    +
    {children}
    +
    + ); +} diff --git a/src/internal/components/card/interfaces.ts b/src/internal/components/card/interfaces.ts new file mode 100644 index 0000000000..62b50b6831 --- /dev/null +++ b/src/internal/components/card/interfaces.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React 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. + */ + actions?: 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; + + /** + * Supplementary text below the heading. + */ + description?: 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; + + disableContentPaddings?: boolean; + + metadataAttributes?: Record; + + variant?: 'action' | 'default'; +} 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..d234ff50df --- /dev/null +++ b/src/internal/components/card/styles.scss @@ -0,0 +1,161 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use 'sass:math'; + +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; +@use '../../styles' as styles; +@use '../../styles/tokens' as awsui; +@use './motion'; + +@mixin card-style { + @include styles.styles-reset(); + + 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; + + // Override button styles for action cards + padding-inline: 0; + border-block: 0 none; + border-inline: 0 none; + user-select: text; + + &::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; + border-inline: solid awsui.$border-divider-section-width; + } + &:not(.variant-action)::after { + border-color: awsui.$color-border-divider-default; + } +} + +.root { + @include styles.styles-reset(); + box-sizing: border-box; + position: relative; + background-color: transparent; + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; + inline-size: 100%; + min-inline-size: 0; + @include card-style; + /* stylelint-disable-next-line selector-combinator-disallowed-list */ + &:not(.active):not(:hover) .actions-inner { + color: awsui.$color-border-button-normal-default; + } + &.active { + &::before { + border-block: awsui.$border-item-width solid; + border-inline: awsui.$border-item-width solid; + } + &:not(.variant-action) { + background-color: awsui.$color-background-item-selected; + &::before { + border-color: awsui.$color-border-item-selected; + } + } + &.variant-action::before { + border-color: awsui.$color-border-button-normal-hover; + } + } + &.variant-action { + cursor: pointer; + + &:not(:hover) { + &::after { + border-color: awsui.$color-border-button-normal-default; + } + } + &:hover::after { + border-color: awsui.$color-border-button-normal-hover; + } + @include focus-visible.when-visible { + &::before { + border-block-width: 1px; + border-inline-width: 1px; + } + @include styles.focus-highlight( + $gutter: 0, + $border-radius: awsui.$border-radius-container, + $box-shadow: styles.$box-shadow-focused-light + ); + } + } +} + +.header { + padding-block-start: 8px; + padding-inline-start: awsui.$space-card-horizontal; + padding-inline-end: calc(#{awsui.$space-card-horizontal} - 4px); + &-inner { + @include styles.font-heading-s; + } +} + +.header-inner, +.actions { + display: flex; + align-items: center; + min-block-size: 32px; +} + +.body { + &:not(.no-padding) { + padding-block-end: awsui.$space-card-vertical; + padding-inline: awsui.$space-card-horizontal; + } + &.no-padding { + border-end-start-radius: awsui.$border-radius-container; + border-end-end-radius: awsui.$border-radius-container; + overflow: scroll; + } +} + +.with-actions > .header > .header-top-row { + display: flex; + inline-size: 100%; + justify-content: space-between; + > .header-inner { + min-block-size: 32px; + display: flex; + align-items: center; + } +} + +.actions { + flex-shrink: 0; +} + +.description { + color: awsui.$color-text-heading-secondary; + margin-block-end: 4px; +} diff --git a/src/test-utils/dom/cards/index.ts b/src/test-utils/dom/cards/index.ts index adc16584c3..b7f72352b8 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.find(`:is(.${cardStyles['header-inner']}, .${styles['card-header-inner']})`); } findSelectionArea(): ElementWrapper | null { @@ -45,7 +46,7 @@ export default class CardsWrapper extends ComponentWrapper { private containerWrapper = new ContainerWrapper(this.getElement()); findItems(): Array { - return this.findAllByClassName(styles.card).map(c => new CardWrapper(c.getElement())); + return this.findAll(styles.card).map(c => new CardWrapper(c.getElement())); } findSelectedItems(): Array {