From 644da36336845adbc8673ae49c670776e3cf5792 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 24 Jun 2025 16:08:48 +0200 Subject: [PATCH] feat: More obvious loading state when submitting a post (#35153) --- .../components/button/button.stories.tsx | 44 ++++++++++++++----- .../mastodon/components/button/index.tsx | 30 +++++++++++-- .../mastodon/components/loading_indicator.tsx | 27 ++++++++++-- .../compose/components/compose_form.jsx | 24 ++++++---- .../styles/mastodon/components.scss | 23 +++++++++- 5 files changed, 120 insertions(+), 28 deletions(-) diff --git a/app/javascript/mastodon/components/button/button.stories.tsx b/app/javascript/mastodon/components/button/button.stories.tsx index dc3277992..4bcb9edbb 100644 --- a/app/javascript/mastodon/components/button/button.stories.tsx +++ b/app/javascript/mastodon/components/button/button.stories.tsx @@ -11,6 +11,7 @@ const meta = { compact: false, dangerous: false, disabled: false, + loading: false, onClick: fn(), }, argTypes: { @@ -41,16 +42,6 @@ const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => { await expect(args.onClick).toHaveBeenCalled(); }; -const disabledButtonTest: Story['play'] = async ({ - args, - canvas, - userEvent, -}) => { - const button = await canvas.findByRole('button'); - await userEvent.click(button); - await expect(args.onClick).not.toHaveBeenCalled(); -}; - export const Primary: Story = { args: { children: 'Primary button', @@ -82,6 +73,18 @@ export const Dangerous: Story = { play: buttonTest, }; +const disabledButtonTest: Story['play'] = async ({ + args, + canvas, + userEvent, +}) => { + const button = await canvas.findByRole('button'); + await userEvent.click(button); + // Disabled controls can't be focused + await expect(button).not.toHaveFocus(); + await expect(args.onClick).not.toHaveBeenCalled(); +}; + export const PrimaryDisabled: Story = { args: { ...Primary.args, @@ -97,3 +100,24 @@ export const SecondaryDisabled: Story = { }, play: disabledButtonTest, }; + +const loadingButtonTest: Story['play'] = async ({ + args, + canvas, + userEvent, +}) => { + const button = await canvas.findByRole('button', { + name: 'Primary button Loading…', + }); + await userEvent.click(button); + await expect(button).toHaveFocus(); + await expect(args.onClick).not.toHaveBeenCalled(); +}; + +export const Loading: Story = { + args: { + ...Primary.args, + loading: true, + }, + play: loadingButtonTest, +}; diff --git a/app/javascript/mastodon/components/button/index.tsx b/app/javascript/mastodon/components/button/index.tsx index 43f5901c7..6d2a37cf8 100644 --- a/app/javascript/mastodon/components/button/index.tsx +++ b/app/javascript/mastodon/components/button/index.tsx @@ -3,12 +3,15 @@ import { useCallback } from 'react'; import classNames from 'classnames'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; + interface BaseProps extends Omit, 'children'> { block?: boolean; secondary?: boolean; compact?: boolean; dangerous?: boolean; + loading?: boolean; } interface PropsChildren extends PropsWithChildren { @@ -34,6 +37,7 @@ export const Button: React.FC = ({ secondary, compact, dangerous, + loading, className, title, text, @@ -42,13 +46,18 @@ export const Button: React.FC = ({ }) => { const handleClick = useCallback>( (e) => { - if (!disabled && onClick) { + if (disabled || loading) { + e.stopPropagation(); + e.preventDefault(); + } else if (onClick) { onClick(e); } }, - [disabled, onClick], + [disabled, loading, onClick], ); + const label = text ?? children; + return ( ); }; diff --git a/app/javascript/mastodon/components/loading_indicator.tsx b/app/javascript/mastodon/components/loading_indicator.tsx index fcdbe80d8..53d216a99 100644 --- a/app/javascript/mastodon/components/loading_indicator.tsx +++ b/app/javascript/mastodon/components/loading_indicator.tsx @@ -6,15 +6,34 @@ const messages = defineMessages({ loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' }, }); -export const LoadingIndicator: React.FC = () => { +interface LoadingIndicatorProps { + /** + * Use role='none' to opt out of the current default role 'progressbar' + * and aria attributes which we should re-visit to check if they're appropriate. + * In Firefox the aria-label is not applied, instead an implied value of `50` is + * used as the label. + */ + role?: string; +} + +export const LoadingIndicator: React.FC = ({ + role = 'progressbar', +}) => { const intl = useIntl(); + const a11yProps = + role === 'progressbar' + ? ({ + role, + 'aria-busy': true, + 'aria-live': 'polite', + } as const) + : undefined; + return (
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 3611a74b4..6dd3dbd05 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -12,9 +12,10 @@ import { length } from 'stringz'; import { missingAltTextModal } from 'mastodon/initial_state'; -import AutosuggestInput from '../../../components/autosuggest_input'; -import AutosuggestTextarea from '../../../components/autosuggest_textarea'; -import { Button } from '../../../components/button'; +import AutosuggestInput from 'mastodon/components/autosuggest_input'; +import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea'; +import { Button } from 'mastodon/components/button'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollButtonContainer from '../containers/poll_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; @@ -225,9 +226,8 @@ class ComposeForm extends ImmutablePureComponent { }; render () { - const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props; + const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props; const { highlighted } = this.state; - const disabled = this.props.isSubmitting; return (
@@ -246,7 +246,7 @@ class ComposeForm extends ImmutablePureComponent { + loading={isSubmitting} + > + {intl.formatMessage( + this.props.isEditing ? + messages.saveChanges : + (this.props.isInReply ? messages.reply : messages.publish) + )} +
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ab86d5334..c17a4f158 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -249,6 +249,21 @@ width: 100%; } + &.loading { + cursor: wait; + + .button__label-wrapper { + // Hide the label only visually, so that + // it keeps its layout and accessibility + opacity: 0; + } + + .loading-indicator { + position: absolute; + inset: 0; + } + } + .icon { width: 18px; height: 18px; @@ -4645,14 +4660,20 @@ a.status-card { .icon-button .loading-indicator { position: static; transform: none; + color: inherit; .circular-progress { - color: $primary-text-color; + color: inherit; width: 22px; height: 22px; } } +.button--compact .loading-indicator .circular-progress { + width: 17px; + height: 17px; +} + .icon-button .loading-indicator .circular-progress { color: lighten($ui-base-color, 26%); width: 12px;