diff --git a/app/javascript/mastodon/components/button/redesign.module.scss b/app/javascript/mastodon/components/button/redesign.module.scss index ed856b06587..07a0775c486 100644 --- a/app/javascript/mastodon/components/button/redesign.module.scss +++ b/app/javascript/mastodon/components/button/redesign.module.scss @@ -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); } } diff --git a/app/javascript/mastodon/components/card/card.stories.tsx b/app/javascript/mastodon/components/card/card.stories.tsx new file mode 100644 index 00000000000..9ff326254d9 --- /dev/null +++ b/app/javascript/mastodon/components/card/card.stories.tsx @@ -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) =>

{line}

) + : bodyText; + + const props = linked + ? ({ + as: 'a', + href: 'https://joinmastodon.org', + target: '_blank', + } as const) + : {}; + + return ( + + + {titleText} + + {body} + {image && } + + ); + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + redesign: true, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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'), + }, +}; diff --git a/app/javascript/mastodon/components/card/index.tsx b/app/javascript/mastodon/components/card/index.tsx new file mode 100644 index 00000000000..ea93334cfc6 --- /dev/null +++ b/app/javascript/mastodon/components/card/index.tsx @@ -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 = PolymorphicProps< + { + children: React.ReactNode; + className?: string; + onDelete?: React.MouseEventHandler; + }, + As +>; + +interface CardContext { + id: string; + registerImage?: (ref: HTMLImageElement) => void; +} + +const CardContext = createContext({ id: '' }); + +export const Card = ({ + as: asComp, + children, + className, + onDelete, + ...props +}: CardProps) => { + const Comp = asComp ?? 'div'; + + const id = useId(); + const [hasImage, registerImage] = useState(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 ( + + + {children} + + + {!hideButton && ( + + + + )} + + ); +}; + +interface CardTitleProps { + children: React.ReactNode; + imageSrc?: string; + imageAlt?: string; + timestamp?: string; +} + +export const CardTitle: React.FC = ({ + children, + imageSrc, + imageAlt = '', + timestamp, +}) => { + const { id } = use(CardContext); + + return ( +
+ {imageSrc && ( + {imageAlt} + )} + + {children} + + {timestamp && ( + // eslint-disable-next-line no-restricted-syntax -- Allow • + +  •  + + + )} +
+ ); +}; + +type CardBodyProps = PolymorphicProps< + { + children: React.ReactNode; + className?: string; + noClamp?: boolean; + }, + As +>; + +export const CardBody = ({ + as: asComp, + children, + className, + noClamp, + ...props +}: CardBodyProps) => { + const Comp = asComp ?? 'div'; + return ( + + {children} + + ); +}; + +export const CardImage: React.FC> = ({ + alt, + ...props +}) => { + const { registerImage } = use(CardContext); + + return ( +
+ {alt} +
+ ); +}; diff --git a/app/javascript/mastodon/components/card/styles.module.scss b/app/javascript/mastodon/components/card/styles.module.scss new file mode 100644 index 00000000000..3259907641d --- /dev/null +++ b/app/javascript/mastodon/components/card/styles.module.scss @@ -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); +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3f1b59ec13b..63b89267990 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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": "Slide {current, number} / {max, number}", "carousel.slide": "Slide {current, number} of {max, number}", "character_counter.recommended": "{currentLength}/{maxLength} recommended characters",