feat: More obvious loading state when submitting a post (#35153)

This commit is contained in:
diondiondion 2025-06-24 16:08:48 +02:00 committed by GitHub
commit 644da36336
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 120 additions and 28 deletions

View file

@ -11,6 +11,7 @@ const meta = {
compact: false, compact: false,
dangerous: false, dangerous: false,
disabled: false, disabled: false,
loading: false,
onClick: fn(), onClick: fn(),
}, },
argTypes: { argTypes: {
@ -41,16 +42,6 @@ const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
await expect(args.onClick).toHaveBeenCalled(); 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 = { export const Primary: Story = {
args: { args: {
children: 'Primary button', children: 'Primary button',
@ -82,6 +73,18 @@ export const Dangerous: Story = {
play: buttonTest, 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 = { export const PrimaryDisabled: Story = {
args: { args: {
...Primary.args, ...Primary.args,
@ -97,3 +100,24 @@ export const SecondaryDisabled: Story = {
}, },
play: disabledButtonTest, 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,
};

View file

@ -3,12 +3,15 @@ import { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
interface BaseProps interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> { extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean; block?: boolean;
secondary?: boolean; secondary?: boolean;
compact?: boolean; compact?: boolean;
dangerous?: boolean; dangerous?: boolean;
loading?: boolean;
} }
interface PropsChildren extends PropsWithChildren<BaseProps> { interface PropsChildren extends PropsWithChildren<BaseProps> {
@ -34,6 +37,7 @@ export const Button: React.FC<Props> = ({
secondary, secondary,
compact, compact,
dangerous, dangerous,
loading,
className, className,
title, title,
text, text,
@ -42,13 +46,18 @@ export const Button: React.FC<Props> = ({
}) => { }) => {
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>( const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(e) => { (e) => {
if (!disabled && onClick) { if (disabled || loading) {
e.stopPropagation();
e.preventDefault();
} else if (onClick) {
onClick(e); onClick(e);
} }
}, },
[disabled, onClick], [disabled, loading, onClick],
); );
const label = text ?? children;
return ( return (
<button <button
className={classNames('button', className, { className={classNames('button', className, {
@ -56,14 +65,27 @@ export const Button: React.FC<Props> = ({
'button--compact': compact, 'button--compact': compact,
'button--block': block, 'button--block': block,
'button--dangerous': dangerous, 'button--dangerous': dangerous,
loading,
})} })}
disabled={disabled} // Disabled buttons can't have focus, so we don't really
// disable the button during loading
disabled={disabled && !loading}
aria-disabled={loading}
// If the loading prop is used, announce label changes
aria-live={loading !== undefined ? 'polite' : undefined}
onClick={handleClick} onClick={handleClick}
title={title} title={title}
type={type} type={type}
{...props} {...props}
> >
{text ?? children} {loading ? (
<>
<span className='button__label-wrapper'>{label}</span>
<LoadingIndicator role='none' />
</>
) : (
label
)}
</button> </button>
); );
}; };

View file

@ -6,15 +6,34 @@ const messages = defineMessages({
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' }, 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<LoadingIndicatorProps> = ({
role = 'progressbar',
}) => {
const intl = useIntl(); const intl = useIntl();
const a11yProps =
role === 'progressbar'
? ({
role,
'aria-busy': true,
'aria-live': 'polite',
} as const)
: undefined;
return ( return (
<div <div
className='loading-indicator' className='loading-indicator'
role='progressbar' {...a11yProps}
aria-busy
aria-live='polite'
aria-label={intl.formatMessage(messages.loading)} aria-label={intl.formatMessage(messages.loading)}
> >
<CircularProgress size={50} strokeWidth={6} /> <CircularProgress size={50} strokeWidth={6} />

View file

@ -12,9 +12,10 @@ import { length } from 'stringz';
import { missingAltTextModal } from 'mastodon/initial_state'; import { missingAltTextModal } from 'mastodon/initial_state';
import AutosuggestInput from '../../../components/autosuggest_input'; import AutosuggestInput from 'mastodon/components/autosuggest_input';
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
import { Button } from '../../../components/button'; import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollButtonContainer from '../containers/poll_button_container'; import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
@ -225,9 +226,8 @@ class ComposeForm extends ImmutablePureComponent {
}; };
render () { render () {
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props; const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
const { highlighted } = this.state; const { highlighted } = this.state;
const disabled = this.props.isSubmitting;
return ( return (
<form className='compose-form' onSubmit={this.handleSubmit}> <form className='compose-form' onSubmit={this.handleSubmit}>
@ -246,7 +246,7 @@ class ComposeForm extends ImmutablePureComponent {
<AutosuggestInput <AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)} placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText} value={this.props.spoilerText}
disabled={disabled} disabled={isSubmitting}
onChange={this.handleChangeSpoilerText} onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
ref={this.setSpoilerText} ref={this.setSpoilerText}
@ -268,7 +268,7 @@ class ComposeForm extends ImmutablePureComponent {
<AutosuggestTextarea <AutosuggestTextarea
ref={this.textareaRef} ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)} placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled} disabled={isSubmitting}
value={this.props.text} value={this.props.text}
onChange={this.handleChange} onChange={this.handleChange}
suggestions={this.props.suggestions} suggestions={this.props.suggestions}
@ -305,9 +305,15 @@ class ComposeForm extends ImmutablePureComponent {
<Button <Button
type='submit' type='submit'
compact compact
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()} disabled={!this.canSubmit()}
/> loading={isSubmitting}
>
{intl.formatMessage(
this.props.isEditing ?
messages.saveChanges :
(this.props.isInReply ? messages.reply : messages.publish)
)}
</Button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -249,6 +249,21 @@
width: 100%; 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 { .icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@ -4645,14 +4660,20 @@ a.status-card {
.icon-button .loading-indicator { .icon-button .loading-indicator {
position: static; position: static;
transform: none; transform: none;
color: inherit;
.circular-progress { .circular-progress {
color: $primary-text-color; color: inherit;
width: 22px; width: 22px;
height: 22px; height: 22px;
} }
} }
.button--compact .loading-indicator .circular-progress {
width: 17px;
height: 17px;
}
.icon-button .loading-indicator .circular-progress { .icon-button .loading-indicator .circular-progress {
color: lighten($ui-base-color, 26%); color: lighten($ui-base-color, 26%);
width: 12px; width: 12px;