Redesign: Card component (#40227)

This commit is contained in:
Echo
2026-08-21 13:47:35 +00:00
committed by GitHub
parent 50415729b9
commit 3737cf5084
5 changed files with 361 additions and 0 deletions

View File

@@ -100,6 +100,7 @@ a.base {
&.destructive {
--bg: var(--color-bg-error-base);
--bg-hover: var(--color-bg-error-base-hover);
--fg: var(--color-text-on-error-base);
}
}

View File

@@ -0,0 +1,124 @@
import { useMemo } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { Card, CardBody, CardImage, CardTitle } from './index';
interface StoryProps {
avatar: boolean;
bodyText: string;
clamp: boolean;
delete: boolean;
image: boolean;
linked: boolean;
timestamp: boolean;
titleText: string;
}
const img =
'https://images.pexels.com/photos/16859306/pexels-photo-16859306.jpeg';
const deleteCb = fn().mockName('onDelete');
const meta = {
title: 'Redesign/Card',
args: {
avatar: false,
bodyText: 'Here is some card text.',
clamp: true,
delete: false,
image: false,
linked: false,
timestamp: false,
titleText: 'Example title',
},
render({
avatar,
bodyText,
clamp,
delete: deleteBtn,
image,
linked,
timestamp,
titleText,
}) {
const tsString = useMemo(
() =>
timestamp
? new Date(new Date().setMonth(0, 1)).toDateString()
: undefined,
[timestamp],
);
const body = bodyText.includes('\n')
? bodyText.split('\n').map((line, index) => <p key={index}>{line}</p>)
: bodyText;
const props = linked
? ({
as: 'a',
href: 'https://joinmastodon.org',
target: '_blank',
} as const)
: {};
return (
<Card {...props} onDelete={deleteBtn ? deleteCb : undefined}>
<CardTitle timestamp={tsString} imageSrc={avatar ? img : undefined}>
{titleText}
</CardTitle>
<CardBody noClamp={!clamp}>{body}</CardBody>
{image && <CardImage src={img} />}
</Card>
);
},
decorators: [
(Story) => (
<div style={{ maxWidth: '400px' }}>
<Story />
</div>
),
],
parameters: {
redesign: true,
},
} satisfies Meta<StoryProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Plain: Story = {};
export const TimeAvatar: Story = {
args: {
bodyText: 'This card has both an avatar and time.',
timestamp: true,
titleText: 'Avatar and time',
},
};
export const Image: Story = {
args: {
bodyText: 'This card has an image.',
image: true,
titleText: 'Image Card',
},
};
export const Linked: Story = {
args: {
linked: true,
},
};
export const Long: Story = {
args: {
bodyText: [
'Fugit in voluptatem occaecati voluptates ut cumque eos. Aspernatur neque rerum ipsum. Sed similique libero odio sit quod sed facere. Quo sint eum aliquam voluptatem alias possimus ullam. Deleniti praesentium id odit qui ut perferendis. Praesentium numquam dolorem eveniet quasi nobis id.',
'Consequatur assumenda minus aperiam et. Accusantium qui corporis illum. Aliquid omnis et voluptate voluptates sit. Qui corrupti at nihil occaecati aut non. Tenetur possimus occaecati architecto est. Sed et non temporibus quam minus autem nisi.',
'Illo dolorem quasi quasi porro consequatur ut culpa. Impedit omnis mollitia molestiae voluptates et. Quisquam incidunt at rerum. Eum eos aut et. Architecto sed et non.',
'Suscipit quisquam et saepe officia. Dolores natus reiciendis beatae. Nisi aut porro nihil vel placeat inventore. Sed deserunt voluptatem est aut non praesentium sit error.',
].join('\n'),
},
};

View File

@@ -0,0 +1,144 @@
import type React from 'react';
import { createContext, use, useId, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { TrashIcon } from '@phosphor-icons/react';
import type { PolymorphicProps } from '@/types/polymorphic';
import { IconButton } from '../button/redesign';
import { RelativeTimestamp } from '../relative_timestamp';
import classes from './styles.module.scss';
type CardProps<As extends React.ElementType> = PolymorphicProps<
{
children: React.ReactNode;
className?: string;
onDelete?: React.MouseEventHandler<HTMLButtonElement>;
},
As
>;
interface CardContext {
id: string;
registerImage?: (ref: HTMLImageElement) => void;
}
const CardContext = createContext<CardContext>({ id: '' });
export const Card = <As extends React.ElementType>({
as: asComp,
children,
className,
onDelete,
...props
}: CardProps<As>) => {
const Comp = asComp ?? 'div';
const id = useId();
const [hasImage, registerImage] = useState<HTMLImageElement | null>(null);
// Disable the button if this is an interactive element, including a Link component.
const hideButton =
!onDelete || asComp === 'a' || asComp === 'button' || 'to' in props;
return (
<Comp {...props} className={classNames(className, classes.root)}>
<CardContext.Provider value={{ id, registerImage }}>
{children}
</CardContext.Provider>
{!hideButton && (
<IconButton
icon={TrashIcon}
onClick={onDelete}
color='destructive'
className={classes.delete}
size={hasImage ? 'sm' : 'xs'}
aria-describedby={`${id}_title`}
data-color-scheme={hasImage ? 'dark' : undefined}
>
<FormattedMessage id='card.delete' defaultMessage='Remove this' />
</IconButton>
)}
</Comp>
);
};
interface CardTitleProps {
children: React.ReactNode;
imageSrc?: string;
imageAlt?: string;
timestamp?: string;
}
export const CardTitle: React.FC<CardTitleProps> = ({
children,
imageSrc,
imageAlt = '',
timestamp,
}) => {
const { id } = use(CardContext);
return (
<div className={classes.title}>
{imageSrc && (
<img src={imageSrc} alt={imageAlt} className={classes.titleImage} />
)}
<span id={`${id}_title`}>{children}</span>
{timestamp && (
// eslint-disable-next-line no-restricted-syntax -- Allow &bull;
<span>
&nbsp;&bull;&nbsp;
<RelativeTimestamp timestamp={timestamp} />
</span>
)}
</div>
);
};
type CardBodyProps<As extends React.ElementType> = PolymorphicProps<
{
children: React.ReactNode;
className?: string;
noClamp?: boolean;
},
As
>;
export const CardBody = <As extends React.ElementType>({
as: asComp,
children,
className,
noClamp,
...props
}: CardBodyProps<As>) => {
const Comp = asComp ?? 'div';
return (
<Comp
{...props}
className={classNames(className, classes.body, !noClamp && classes.clamp)}
>
{children}
</Comp>
);
};
export const CardImage: React.FC<React.ComponentPropsWithRef<'img'>> = ({
alt,
...props
}) => {
const { registerImage } = use(CardContext);
return (
<div className={classes.image} ref={registerImage}>
<img {...props} alt={alt} />
</div>
);
};

View File

@@ -0,0 +1,91 @@
@use '@/styles/mastodon/mixins';
.root {
border: 0.5px solid var(--color-border-primary);
border-radius: var(--radius-md);
display: block;
overflow: hidden;
padding: var(--space-sm);
position: relative;
&:has(.image) {
column-gap: var(--space-sm);
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
padding-inline-end: 0;
}
}
// Handle cases where the root is a link
a.root {
&:hover {
text-decoration: none;
}
&:focus,
&:focus-visible {
border-radius: var(--radius-md);
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}
.title {
@include mixins.type-label-md;
color: var(--color-text-secondary);
display: flex;
margin-bottom: var(--space-2xs);
a {
color: inherit;
text-decoration: none;
}
}
.titleImage {
border-radius: var(--radius-round);
height: var(--space-lg);
margin-inline-end: var(--space-xs);
overflow: hidden;
width: var(--space-lg);
}
.body {
@include mixins.type-body-compact;
overflow-wrap: break-word;
a {
color: var(--color-text-brand);
text-decoration: none;
&:hover,
&:focus {
text-decoration: underline;
}
}
}
.image {
contain: size;
grid-column: 2;
grid-row: 1 / span 2;
margin-block: calc(-1 * var(--space-sm));
img {
height: 100%;
object-fit: cover;
width: 100%;
}
}
.clamp {
@include mixins.line-clamp(2);
}
.delete {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
}

View File

@@ -353,6 +353,7 @@
"bundle_modal_error.message": "Something went wrong while loading this screen.",
"bundle_modal_error.retry": "Try again",
"callout.dismiss": "Dismiss",
"card.delete": "Remove this",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} of {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} recommended characters",