diff --git a/app/javascript/mastodon/components/callout/callout.stories.tsx b/app/javascript/mastodon/components/callout/callout.stories.tsx new file mode 100644 index 0000000000..f9bba1ec14 --- /dev/null +++ b/app/javascript/mastodon/components/callout/callout.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; + +import { Callout } from '.'; + +const meta = { + title: 'Components/Callout', + args: { + children: 'Contents here', + title: 'Title', + onPrimary: action('Primary action clicked'), + primaryLabel: 'Primary', + onSecondary: action('Secondary action clicked'), + secondaryLabel: 'Secondary', + onClose: action('Close clicked'), + }, + component: Callout, + render(args) { + return ( +
+ +
+ ); + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: 'default', + }, +}; + +export const NoIcon: Story = { + args: { + icon: false, + }, +}; + +export const NoActions: Story = { + args: { + onPrimary: undefined, + onSecondary: undefined, + }, +}; + +export const OnlyText: Story = { + args: { + onClose: undefined, + onPrimary: undefined, + onSecondary: undefined, + icon: false, + }, +}; + +// export const Subtle: Story = { +// args: { +// variant: 'subtle', +// }, +// }; + +export const Feature: Story = { + args: { + variant: 'feature', + }, +}; + +export const Inverted: Story = { + args: { + variant: 'inverted', + }, +}; + +export const Success: Story = { + args: { + variant: 'success', + }, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, +}; + +export const Error: Story = { + args: { + variant: 'error', + }, +}; diff --git a/app/javascript/mastodon/components/callout/dismissible.tsx b/app/javascript/mastodon/components/callout/dismissible.tsx new file mode 100644 index 0000000000..70a5c850b6 --- /dev/null +++ b/app/javascript/mastodon/components/callout/dismissible.tsx @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import type { FC } from 'react'; + +import { useDismissible } from '@/mastodon/hooks/useDismissible'; + +import { Callout } from '.'; +import type { CalloutProps } from '.'; + +type DismissibleCalloutProps = CalloutProps & { + id: string; +}; + +export const DismissibleCallout: FC = (props) => { + const { dismiss, wasDismissed } = useDismissible(props.id); + + const { onClose } = props; + const handleClose = useCallback(() => { + dismiss(); + onClose?.(); + }, [dismiss, onClose]); + + if (wasDismissed) { + return null; + } + + return ; +}; diff --git a/app/javascript/mastodon/components/callout/index.tsx b/app/javascript/mastodon/components/callout/index.tsx new file mode 100644 index 0000000000..e7ab410a9c --- /dev/null +++ b/app/javascript/mastodon/components/callout/index.tsx @@ -0,0 +1,150 @@ +import type { FC, ReactNode } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import ErrorIcon from '@/material-icons/400-24px/error.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; + +import type { IconProp } from '../icon'; +import { Icon } from '../icon'; +import { IconButton } from '../icon_button'; + +import classes from './styles.module.css'; + +export interface CalloutProps { + variant?: + | 'default' + // | 'subtle' + | 'feature' + | 'inverted' + | 'success' + | 'warning' + | 'error'; + title?: ReactNode; + children: ReactNode; + className?: string; + /** Set to false to hide the icon. */ + icon?: IconProp | boolean; + onPrimary?: () => void; + primaryLabel?: string; + onSecondary?: () => void; + secondaryLabel?: string; + onClose?: () => void; + id?: string; +} + +const variantClasses = { + default: classes.variantDefault as string, + // subtle: classes.variantSubtle as string, + feature: classes.variantFeature as string, + inverted: classes.variantInverted as string, + success: classes.variantSuccess as string, + warning: classes.variantWarning as string, + error: classes.variantError as string, +} as const; + +export const Callout: FC = ({ + className, + variant = 'default', + title, + children, + icon, + onPrimary: primaryAction, + primaryLabel, + onSecondary: secondaryAction, + secondaryLabel, + onClose, + id, +}) => { + const intl = useIntl(); + + return ( + + ); +}; + +const CalloutIcon: FC> = ({ + variant = 'default', + icon, +}) => { + if (icon === false) { + return null; + } + + if (!icon || icon === true) { + switch (variant) { + case 'inverted': + case 'success': + icon = CheckIcon; + break; + case 'warning': + icon = WarningIcon; + break; + case 'error': + icon = ErrorIcon; + break; + default: + icon = InfoIcon; + } + } + + return ; +}; diff --git a/app/javascript/mastodon/components/callout/styles.module.css b/app/javascript/mastodon/components/callout/styles.module.css new file mode 100644 index 0000000000..5e6e28d122 --- /dev/null +++ b/app/javascript/mastodon/components/callout/styles.module.css @@ -0,0 +1,125 @@ +.wrapper { + display: flex; + align-items: start; + padding: 12px; + gap: 8px; + background-color: var(--color-bg-brand-softer); + color: var(--color-text-primary); + border-radius: 12px; +} + +.icon { + padding: 4px; + border-radius: 9999px; + width: 1rem; + height: 1rem; +} + +.content { + display: flex; + gap: 8px; + flex-direction: column; + flex-grow: 1; +} + +@media screen and (width >= 630px) { + .content { + flex-direction: row; + } +} + +.icon + .content, +.wrapper:has(.close) .content { + margin-top: 2px; +} + +.body { + flex-grow: 1; + + h3 { + font-weight: 500; + } +} + +.actionWrapper { + display: flex; + gap: 8px; + align-items: start; +} + +.action { + appearance: none; + background: none; + border: none; + color: inherit; + font-weight: 500; + padding: 0; + text-decoration: underline; + transition: color 0.1s ease-in-out; + + &:hover { + color: var(--color-text-brand-soft); + } +} + +.close { + color: inherit; + + svg { + width: 20px; + height: 20px; + } +} + +.variantDefault { + .icon { + background-color: var(--color-bg-brand-soft); + } +} + +/* .variantSubtle { + border: 1px solid var(--color-bg-brand-softer); + background-color: var(--color-bg-primary); + + .icon { + background-color: var(--color-bg-brand-softer); + } +} */ + +.variantFeature { + background-color: var(--color-bg-brand-base); + color: var(--color-text-on-brand-base); + + button:hover { + color: color-mix(var(--color-text-on-brand-base), transparent 20%); + } +} + +.variantInverted { + background-color: var(--color-bg-inverted); + color: var(--color-text-on-inverted); +} + +.variantSuccess { + background-color: var(--color-bg-success-softer); + + .icon { + background-color: var(--color-bg-success-soft); + } +} + +.variantWarning { + background-color: var(--color-bg-warning-softer); + + .icon { + background-color: var(--color-bg-warning-soft); + } +} + +.variantError { + background-color: var(--color-bg-error-softer); + + .icon { + background-color: var(--color-bg-error-soft); + } +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 3020e0d34e..72ab261702 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -187,6 +187,7 @@ "bundle_modal_error.close": "Close", "bundle_modal_error.message": "Something went wrong while loading this screen.", "bundle_modal_error.retry": "Try again", + "callout.dismiss": "Dismiss", "carousel.current": "Slide {current, number} / {max, number}", "carousel.slide": "Slide {current, number} of {max, number}", "closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.", diff --git a/app/javascript/material-icons/400-24px/error-fill.svg b/app/javascript/material-icons/400-24px/error-fill.svg new file mode 100644 index 0000000000..5125e9acce --- /dev/null +++ b/app/javascript/material-icons/400-24px/error-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/error.svg b/app/javascript/material-icons/400-24px/error.svg new file mode 100644 index 0000000000..86c4555326 --- /dev/null +++ b/app/javascript/material-icons/400-24px/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index a9dc9732bf..c7ba755d67 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -399,6 +399,7 @@ export default tseslint.config([ allowNumber: true, }, ], + '@typescript-eslint/non-nullable-type-assertion-style': 'off', }, }, {