[Glitch] Create reusable Alert/Snackbar component

Port 085e9ea676 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion
2025-09-17 17:24:39 +02:00
committed by Claire
parent 21cf2673f2
commit 1e70fbfc52
4 changed files with 230 additions and 41 deletions

View File

@@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn, expect } from 'storybook/test';
import { Alert } from '.';
const meta = {
title: 'Components/Alert',
component: Alert,
args: {
isActive: true,
animateFrom: 'side',
title: '',
message: '',
action: '',
onActionClick: fn(),
},
argTypes: {
isActive: {
control: 'boolean',
type: 'boolean',
description: 'Animate to the active (displayed) state of the alert',
},
animateFrom: {
control: 'radio',
type: 'string',
options: ['side', 'below'],
description:
'Direction that the alert animates in from when activated. `side` is dependent on reading direction, defaulting to left in ltr languages.',
},
title: {
control: 'text',
type: 'string',
description: '(Optional) title of the alert',
},
message: {
control: 'text',
type: 'string',
description: 'Main alert text',
},
action: {
control: 'text',
type: 'string',
description:
'Label of the alert action (requires `onActionClick` handler)',
},
},
tags: ['test'],
} satisfies Meta<typeof Alert>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {
args: {
message: 'Post published.',
},
render: (args) => (
<div style={{ overflow: 'clip', padding: '1rem' }}>
<Alert {...args} />
</div>
),
};
export const WithAction: Story = {
args: {
...Simple.args,
action: 'Open',
},
render: Simple.render,
play: async ({ args, canvas, userEvent }) => {
const button = await canvas.findByRole('button', { name: 'Open' });
await userEvent.click(button);
await expect(args.onActionClick).toHaveBeenCalled();
},
};
export const WithTitle: Story = {
args: {
title: 'Warning:',
message: 'This is an alert',
},
render: Simple.render,
};
export const WithDismissButton: Story = {
args: {
message: 'More replies found',
action: 'Show',
onDismiss: fn(),
},
render: Simple.render,
};
export const InSizedContainer: Story = {
args: WithDismissButton.args,
render: (args) => (
<div
style={{
overflow: 'clip',
padding: '1rem',
width: '380px',
maxWidth: '100%',
boxSizing: 'border-box',
}}
>
<Alert {...args} />
</div>
),
};

View File

@@ -0,0 +1,68 @@
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { IconButton } from '../icon_button';
/**
* Snackbar/Toast-style notification component.
*/
export const Alert: React.FC<{
isActive?: boolean;
animateFrom?: 'side' | 'below';
title?: string;
message: string;
action?: string;
onActionClick?: () => void;
onDismiss?: () => void;
}> = ({
isActive,
animateFrom = 'side',
title,
message,
action,
onActionClick,
onDismiss,
}) => {
const intl = useIntl();
const hasAction = Boolean(action && onActionClick);
return (
<div
className={classNames('notification-bar', {
'notification-bar--active': isActive,
'from-side': animateFrom === 'side',
'from-below': animateFrom === 'below',
})}
>
<span className='notification-bar__content'>
{Boolean(title) && (
<span className='notification-bar__title'>{title}</span>
)}
{message}
</span>
{hasAction && (
<button className='notification-bar__action' onClick={onActionClick}>
{action}
</button>
)}
{onDismiss && (
<IconButton
title={intl.formatMessage({
id: 'dismissable_banner.dismiss',
defaultMessage: 'Dismiss',
})}
icon='times'
iconComponent={CloseIcon}
className='notification-bar__dismiss-button'
onClick={onDismiss}
/>
)}
</div>
);
};

View File

@@ -3,16 +3,16 @@ import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import type { IntlShape } from 'react-intl';
import classNames from 'classnames';
import { dismissAlert } from 'flavours/glitch/actions/alerts';
import type {
Alert,
Alert as AlertType,
TranslatableString,
TranslatableValues,
} from 'flavours/glitch/models/alert';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { Alert } from './alert';
const formatIfNeeded = (
intl: IntlShape,
message: TranslatableString,
@@ -25,8 +25,8 @@ const formatIfNeeded = (
return message;
};
const Alert: React.FC<{
alert: Alert;
const TimedAlert: React.FC<{
alert: AlertType;
dismissAfter: number;
}> = ({
alert: { key, title, message, values, action, onClick },
@@ -62,29 +62,13 @@ const Alert: React.FC<{
}, [dispatch, setActive, key, dismissAfter]);
return (
<div
className={classNames('notification-bar', {
'notification-bar-active': active,
})}
>
<div className='notification-bar-wrapper'>
{title && (
<span className='notification-bar-title'>
{formatIfNeeded(intl, title, values)}
</span>
)}
<span className='notification-bar-message'>
{formatIfNeeded(intl, message, values)}
</span>
{action && (
<button className='notification-bar-action' onClick={onClick}>
{formatIfNeeded(intl, action, values)}
</button>
)}
</div>
</div>
<Alert
isActive={active}
title={title ? formatIfNeeded(intl, title, values) : undefined}
message={formatIfNeeded(intl, message, values)}
action={action ? formatIfNeeded(intl, action, values) : undefined}
onActionClick={onClick}
/>
);
};
@@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => {
return (
<div className='notification-list'>
{alerts.map((alert, idx) => (
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
<TimedAlert
key={alert.key}
alert={alert}
dismissAfter={5000 + idx * 1000}
/>
))}
</div>
);