mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 02:36:36 -05:00
Redesign: Card component (#40227)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
app/javascript/mastodon/components/card/card.stories.tsx
Normal file
124
app/javascript/mastodon/components/card/card.stories.tsx
Normal 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'),
|
||||
},
|
||||
};
|
||||
144
app/javascript/mastodon/components/card/index.tsx
Normal file
144
app/javascript/mastodon/components/card/index.tsx
Normal 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 •
|
||||
<span>
|
||||
•
|
||||
<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>
|
||||
);
|
||||
};
|
||||
91
app/javascript/mastodon/components/card/styles.module.scss
Normal file
91
app/javascript/mastodon/components/card/styles.module.scss
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user