diff --git a/app/javascript/mastodon/components/button/button.stories.tsx b/app/javascript/mastodon/components/button/button.stories.tsx index dc32779928..4bcb9edbb8 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 43f5901c74..6d2a37cf85 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/icon.tsx b/app/javascript/mastodon/components/icon.tsx index f388380c44..165c214c10 100644 --- a/app/javascript/mastodon/components/icon.tsx +++ b/app/javascript/mastodon/components/icon.tsx @@ -13,14 +13,13 @@ interface Props extends React.SVGProps { children?: never; id: string; icon: IconProp; - title?: string; } export const Icon: React.FC = ({ 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 = ({ 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 ( diff --git a/app/javascript/mastodon/components/loading_indicator.tsx b/app/javascript/mastodon/components/loading_indicator.tsx index fcdbe80d8a..53d216a994 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/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index e9b3b2b672..d9e76617d0 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -318,7 +318,7 @@ const PollOption: React.FC = (props) => { id='check' icon={CheckIcon} className='poll__voted__mark' - title={intl.formatMessage(messages.voted)} + aria-label={intl.formatMessage(messages.voted)} /> )} diff --git a/app/javascript/mastodon/components/visibility_icon.tsx b/app/javascript/mastodon/components/visibility_icon.tsx index 3a310cbae2..293b5253cb 100644 --- a/app/javascript/mastodon/components/visibility_icon.tsx +++ b/app/javascript/mastodon/components/visibility_icon.tsx @@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({ ); }; diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 18807ecf85..30433151c6 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -768,7 +768,7 @@ export const AccountHeader: React.FC<{ ); } diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 3611a74b4f..6dd3dbd054 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/mastodon/features/compose/components/search.tsx b/app/javascript/mastodon/features/compose/components/search.tsx index 30a7a84db6..ae242190e8 100644 --- a/app/javascript/mastodon/features/compose/components/search.tsx +++ b/app/javascript/mastodon/features/compose/components/search.tsx @@ -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 ( +
+ + +
+ ); +}; + 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} /> - +
{!hasValue && ( diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0f53dbe576..6a2a7a885b 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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}", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ab86d53345..c32110be8c 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; @@ -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; + &:focus-visible { + box-shadow: 0 0 0 2px $ui-button-focus-outline-color; + } + + &[aria-hidden='true'] { + pointer-events: none; opacity: 0; - transition: all 100ms linear; - transition-property: transform, opacity; - width: 20px; - height: 20px; - color: $darker-text-color; - - &.active { - pointer-events: auto; - opacity: 1; - } - } - - .icon-search { - transform: rotate(90deg); - - &.active { - pointer-events: none; - transform: rotate(0deg); - } - } - - .icon-times-circle { - transform: rotate(0deg); - cursor: pointer; - - &.active { - transform: rotate(90deg); - } + transform: rotate(-90deg); } }