Merge branch 'main' into mute-prefill

This commit is contained in:
Sebastian Hädrich 2025-06-24 17:36:08 +02:00 committed by GitHub
commit 1b0122631e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 202 additions and 87 deletions

View File

@ -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,
};

View File

@ -3,12 +3,15 @@ import { useCallback } from 'react';
import classNames from 'classnames';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean;
secondary?: boolean;
compact?: boolean;
dangerous?: boolean;
loading?: boolean;
}
interface PropsChildren extends PropsWithChildren<BaseProps> {
@ -34,6 +37,7 @@ export const Button: React.FC<Props> = ({
secondary,
compact,
dangerous,
loading,
className,
title,
text,
@ -42,13 +46,18 @@ export const Button: React.FC<Props> = ({
}) => {
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(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 (
<button
className={classNames('button', className, {
@ -56,14 +65,27 @@ export const Button: React.FC<Props> = ({
'button--compact': compact,
'button--block': block,
'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}
title={title}
type={type}
{...props}
>
{text ?? children}
{loading ? (
<>
<span className='button__label-wrapper'>{label}</span>
<LoadingIndicator role='none' />
</>
) : (
label
)}
</button>
);
};

View File

@ -13,14 +13,13 @@ interface Props extends React.SVGProps<SVGSVGElement> {
children?: never;
id: string;
icon: IconProp;
title?: string;
}
export const Icon: React.FC<Props> = ({
id,
icon: IconComponent,
className,
title: titleProp,
'aria-label': ariaLabel,
...other
}) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -34,18 +33,19 @@ export const Icon: React.FC<Props> = ({
IconComponent = CheckBoxOutlineBlankIcon;
}
const ariaHidden = titleProp ? undefined : true;
const ariaHidden = ariaLabel ? undefined : true;
const role = !ariaHidden ? 'img' : undefined;
// Set the title to an empty string to remove the built-in SVG one if any
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const title = titleProp || '';
const title = ariaLabel || '';
return (
<IconComponent
className={classNames('icon', `icon-${id}`, className)}
title={title}
aria-hidden={ariaHidden}
aria-label={ariaLabel}
role={role}
{...other}
/>

View File

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

View File

@ -318,7 +318,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
id='check'
icon={CheckIcon}
className='poll__voted__mark'
title={intl.formatMessage(messages.voted)}
aria-label={intl.formatMessage(messages.voted)}
/>
</span>
)}

View File

@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
<Icon
id={visibilityIcon.icon}
icon={visibilityIcon.iconComponent}
title={visibilityIcon.text}
aria-label={visibilityIcon.text}
/>
);
};

View File

@ -768,7 +768,7 @@ export const AccountHeader: React.FC<{
<Icon
id='lock'
icon={LockIcon}
title={intl.formatMessage(messages.account_locked)}
aria-label={intl.formatMessage(messages.account_locked)}
/>
);
}

View File

@ -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 (
<form className='compose-form' onSubmit={this.handleSubmit}>
@ -246,7 +246,7 @@ class ComposeForm extends ImmutablePureComponent {
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
disabled={disabled}
disabled={isSubmitting}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
ref={this.setSpoilerText}
@ -268,7 +268,7 @@ class ComposeForm extends ImmutablePureComponent {
<AutosuggestTextarea
ref={this.textareaRef}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
disabled={isSubmitting}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
@ -305,9 +305,15 @@ class ComposeForm extends ImmutablePureComponent {
<Button
type='submit'
compact
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
disabled={!this.canSubmit()}
/>
loading={isSubmitting}
>
{intl.formatMessage(
this.props.isEditing ?
messages.saveChanges :
(this.props.isInReply ? messages.reply : messages.publish)
)}
</Button>
</div>
</div>
</div>

View File

@ -29,6 +29,7 @@ import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
clearSearch: { id: 'search.clear', defaultMessage: 'Clear search' },
placeholderSignedIn: {
id: 'search.search_or_paste',
defaultMessage: 'Search or paste URL',
@ -50,6 +51,34 @@ const unfocus = () => {
document.querySelector('.ui')?.parentElement?.focus();
};
const ClearButton: React.FC<{
onClick: () => void;
hasValue: boolean;
}> = ({ onClick, hasValue }) => {
const intl = useIntl();
return (
<div
className={classNames('search__icon-wrapper', { 'has-value': hasValue })}
>
<Icon id='search' icon={SearchIcon} className='search__icon' />
<button
type='button'
onClick={onClick}
className='search__icon search__icon--clear-button'
tabIndex={hasValue ? undefined : -1}
aria-hidden={!hasValue}
>
<Icon
id='times-circle'
icon={CancelIcon}
aria-label={intl.formatMessage(messages.clearSearch)}
/>
</button>
</div>
);
};
interface SearchOption {
key: string;
label: React.ReactNode;
@ -380,6 +409,7 @@ export const Search: React.FC<{
setValue('');
setQuickActions([]);
setSelectedOption(-1);
unfocus();
}, [setValue, setQuickActions, setSelectedOption]);
const handleKeyDown = useCallback(
@ -474,19 +504,7 @@ export const Search: React.FC<{
onBlur={handleBlur}
/>
<button type='button' className='search__icon' onClick={handleClear}>
<Icon
id='search'
icon={SearchIcon}
className={hasValue ? '' : 'active'}
/>
<Icon
id='times-circle'
icon={CancelIcon}
className={hasValue ? 'active' : ''}
aria-label={intl.formatMessage(messages.placeholder)}
/>
</button>
<ClearButton hasValue={hasValue} onClick={handleClear} />
<div className='search__popout'>
{!hasValue && (

View File

@ -804,6 +804,7 @@
"report_notification.categories.violation": "Rule violation",
"report_notification.categories.violation_sentence": "rule violation",
"report_notification.open": "Open report",
"search.clear": "Clear search",
"search.no_recent_searches": "No recent searches",
"search.placeholder": "Search",
"search.quick_action.account_search": "Profiles matching {x}",

View File

@ -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;
@ -5649,18 +5670,47 @@ a.status-card {
}
}
.search__icon {
background: transparent;
border: 0;
padding: 0;
.search__icon-wrapper {
position: absolute;
top: 12px + 2px;
cursor: default;
pointer-events: none;
top: 14px;
display: grid;
margin-inline-start: 16px - 2px;
width: 20px;
height: 20px;
.icon {
width: 100%;
height: 100%;
}
&:not(.has-value) {
pointer-events: none;
}
}
.search__icon {
grid-area: 1 / 1;
transition: all 100ms linear;
transition-property: transform, opacity;
color: $darker-text-color;
}
.search__icon.icon-search {
.has-value & {
pointer-events: none;
opacity: 0;
transform: rotate(90deg);
}
}
.search__icon--clear-button {
background: transparent;
border: 0;
padding: 0;
width: 20px;
height: 20px;
border-radius: 100%;
&::-moz-focus-inner {
border: 0;
}
@ -5670,39 +5720,14 @@ a.status-card {
outline: 0 !important;
}
.icon {
position: absolute;
top: 0;
inset-inline-start: 0;
opacity: 0;
transition: all 100ms linear;
transition-property: transform, opacity;
width: 20px;
height: 20px;
color: $darker-text-color;
&.active {
pointer-events: auto;
opacity: 1;
}
&:focus-visible {
box-shadow: 0 0 0 2px $ui-button-focus-outline-color;
}
.icon-search {
transform: rotate(90deg);
&.active {
&[aria-hidden='true'] {
pointer-events: none;
transform: rotate(0deg);
}
}
.icon-times-circle {
transform: rotate(0deg);
cursor: pointer;
&.active {
transform: rotate(90deg);
}
opacity: 0;
transform: rotate(-90deg);
}
}