From 085e9ea676f11cad4ae7fafce5ceadb16ecd0f83 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 17 Sep 2025 17:24:39 +0200 Subject: [PATCH] Create reusable Alert/Snackbar component (#36141) --- .storybook/preview-body.html | 2 + .../components/alert/alert.stories.tsx | 110 ++++++++++++++++++ .../mastodon/components/alert/index.tsx | 68 +++++++++++ .../mastodon/components/alerts_controller.tsx | 46 +++----- .../styles/mastodon/components.scss | 47 ++++++-- 5 files changed, 232 insertions(+), 41 deletions(-) create mode 100644 .storybook/preview-body.html create mode 100644 app/javascript/mastodon/components/alert/alert.stories.tsx create mode 100644 app/javascript/mastodon/components/alert/index.tsx diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html new file mode 100644 index 00000000000..1870d95b8fe --- /dev/null +++ b/.storybook/preview-body.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/javascript/mastodon/components/alert/alert.stories.tsx b/app/javascript/mastodon/components/alert/alert.stories.tsx new file mode 100644 index 00000000000..4d5f8acb65b --- /dev/null +++ b/app/javascript/mastodon/components/alert/alert.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = { + args: { + message: 'Post published.', + }, + render: (args) => ( +
+ +
+ ), +}; + +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) => ( +
+ +
+ ), +}; diff --git a/app/javascript/mastodon/components/alert/index.tsx b/app/javascript/mastodon/components/alert/index.tsx new file mode 100644 index 00000000000..1009e77524b --- /dev/null +++ b/app/javascript/mastodon/components/alert/index.tsx @@ -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 ( +
+ + {Boolean(title) && ( + {title} + )} + {message} + + + {hasAction && ( + + )} + + {onDismiss && ( + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx index 26749fa1037..aa97feeca58 100644 --- a/app/javascript/mastodon/components/alerts_controller.tsx +++ b/app/javascript/mastodon/components/alerts_controller.tsx @@ -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 'mastodon/actions/alerts'; import type { - Alert, + Alert as AlertType, TranslatableString, TranslatableValues, } from 'mastodon/models/alert'; import { useAppSelector, useAppDispatch } from 'mastodon/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 ( -
-
- {title && ( - - {formatIfNeeded(intl, title, values)} - - )} - - - {formatIfNeeded(intl, message, values)} - - - {action && ( - - )} -
-
+ ); }; @@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => { return (
{alerts.map((alert, idx) => ( - + ))}
); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index bf810fe6e2b..f5cb8744bf8 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -10294,7 +10294,7 @@ noscript { .notification-list { position: fixed; bottom: 2rem; - inset-inline-start: 0; + inset-inline-start: 1rem; z-index: 9999; display: flex; flex-direction: column; @@ -10302,9 +10302,11 @@ noscript { } .notification-bar { + --alert-edge-spacing: 1rem; + + display: flex; + gap: 10px; flex: 0 0 auto; - position: relative; - inset-inline-start: -100%; width: auto; padding: 15px; margin: 0; @@ -10320,33 +10322,43 @@ noscript { font-size: 15px; line-height: 21px; - &.notification-bar-active { - inset-inline-start: 1rem; + &.from-side { + translate: calc( + -1 * (100% + var(--alert-edge-spacing)) * var(--text-x-direction) + ); + } + + &.from-below { + translate: 0 calc(100% + var(--alert-edge-spacing)); + } + + &.notification-bar--active { + translate: none; } .no-reduce-motion & { transition: 0.5s cubic-bezier(0.89, 0.01, 0.5, 1.1); - transform: translateZ(0); + will-change: translate; } } -.notification-bar-title { - margin-inline-end: 5px; +.notification-bar__content { + margin-inline-end: auto; } -.notification-bar-title, -.notification-bar-action { +.notification-bar__title { + margin-inline-end: 5px; font-weight: 700; } -.notification-bar-action { +.notification-bar__action { display: inline-block; border: 0; background: transparent; text-transform: uppercase; - margin-inline-start: 10px; cursor: pointer; color: $blurple-300; + font-weight: 700; border-radius: 4px; padding: 0 4px; @@ -10357,6 +10369,17 @@ noscript { } } +.notification-bar__dismiss-button { + margin-top: -2px; + color: rgb(from currentColor r g b / 85%); + + &:hover, + &:focus, + &:active { + color: currentColor; + } +} + .hashtag-header { border-bottom: 1px solid var(--background-border-color); padding: 15px;