mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Callout component (#37590)
This commit is contained in:
@@ -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',
|
||||
},
|
||||
};
|
||||
27
app/javascript/mastodon/components/callout/dismissible.tsx
Normal file
27
app/javascript/mastodon/components/callout/dismissible.tsx
Normal 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} />;
|
||||
};
|
||||
150
app/javascript/mastodon/components/callout/index.tsx
Normal file
150
app/javascript/mastodon/components/callout/index.tsx
Normal 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} />;
|
||||
};
|
||||
125
app/javascript/mastodon/components/callout/styles.module.css
Normal file
125
app/javascript/mastodon/components/callout/styles.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
1
app/javascript/material-icons/400-24px/error-fill.svg
Normal file
1
app/javascript/material-icons/400-24px/error-fill.svg
Normal 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 |
1
app/javascript/material-icons/400-24px/error.svg
Normal file
1
app/javascript/material-icons/400-24px/error.svg
Normal 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 |
@@ -399,6 +399,7 @@ export default tseslint.config([
|
||||
allowNumber: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user