Callout component (#37590)

This commit is contained in:
Echo
2026-01-23 16:53:48 +01:00
committed by GitHub
parent a1acf8f4bc
commit c1414f1161
8 changed files with 399 additions and 0 deletions

View File

@@ -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 (
<div style={{ minWidth: 'min(400px, calc(100vw - 2rem))' }}>
<Callout {...args} />
</div>
);
},
} satisfies Meta<typeof Callout>;
export default meta;
type Story = StoryObj<typeof meta>;
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',
},
};

View File

@@ -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<DismissibleCalloutProps> = (props) => {
const { dismiss, wasDismissed } = useDismissible(props.id);
const { onClose } = props;
const handleClose = useCallback(() => {
dismiss();
onClose?.();
}, [dismiss, onClose]);
if (wasDismissed) {
return null;
}
return <Callout {...props} onClose={handleClose} />;
};

View File

@@ -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<CalloutProps> = ({
className,
variant = 'default',
title,
children,
icon,
onPrimary: primaryAction,
primaryLabel,
onSecondary: secondaryAction,
secondaryLabel,
onClose,
id,
}) => {
const intl = useIntl();
return (
<aside
className={classNames(
className,
classes.wrapper,
variantClasses[variant],
)}
data-variant={variant}
id={id}
>
<CalloutIcon variant={variant} icon={icon} />
<div className={classes.content}>
<div className={classes.body}>
{title && <h3>{title}</h3>}
{children}
</div>
{(primaryAction ?? secondaryAction) && (
<div className={classes.actionWrapper}>
{secondaryAction && (
<button
type='button'
onClick={secondaryAction}
className={classes.action}
>
{secondaryLabel ?? 'Click'}
</button>
)}
{primaryAction && (
<button
type='button'
onClick={primaryAction}
className={classes.action}
>
{primaryLabel ?? 'Click'}
</button>
)}
</div>
)}
</div>
{onClose && (
<IconButton
icon='close'
title={intl.formatMessage({
id: 'callout.dismiss',
defaultMessage: 'Dismiss',
})}
iconComponent={CloseIcon}
className={classes.close}
onClick={onClose}
/>
)}
</aside>
);
};
const CalloutIcon: FC<Pick<CalloutProps, 'variant' | 'icon'>> = ({
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 <Icon id={variant} icon={icon} className={classes.icon} />;
};

View File

@@ -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);
}
}

View File

@@ -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": "<sr>Slide</sr> {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.",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@@ -399,6 +399,7 @@ export default tseslint.config([
allowNumber: true,
},
],
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
},
},
{