Composer redesign: Update language picker and toolbar button design (#40294)

This commit is contained in:
Echo
2026-08-27 15:36:08 +00:00
committed by GitHub
parent 2798c0894f
commit bafbdd4838
16 changed files with 304 additions and 128 deletions

View File

@@ -200,3 +200,21 @@ a.base {
.loading {
color: inherit;
}
// Toggle tonal buttons by default show the neutral color, but when pressed show the brand color.
.toggle {
&.tonal {
@include active {
--bg: rgb(from var(--color-bg-brand-base) r g b / 10%);
--fg: var(--color-text-brand);
}
}
&.solid,
&.ghost {
@include active {
--bg: var(--color-bg-brand-base);
--fg: var(--color-text-on-brand-base);
}
}
}

View File

@@ -1,11 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { useToggle } from '@/mastodon/hooks/useToggle';
import ChatIcon from '@/material-icons/400-24px/chat.svg?react';
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
import HeadphonesIcon from '@/material-icons/400-24px/headphones.svg?react';
import { Button, IconButton } from './redesign';
import { Button, IconButton, ToggleButton } from './redesign';
const iconArgType = {
control: 'select',
@@ -98,3 +99,10 @@ export const Link: Story = {
as: 'link',
},
};
export const Toggle: Story = {
render(args) {
const [active, { onToggle }] = useToggle();
return <ToggleButton {...args} active={active} onClick={onToggle} />;
},
};

View File

@@ -32,9 +32,9 @@ type ButtonAnchorProps = { as: 'a' } & ButtonPropsBase<'a'> &
type ButtonLinkProps = { as: 'link' } & ButtonPropsBase<'a'> &
Omit<LinkProps, 'children'>;
type ButtonProps = ButtonButtonProps | ButtonAnchorProps | ButtonLinkProps;
type BaseButtonProps = ButtonButtonProps | ButtonAnchorProps | ButtonLinkProps;
const BaseButton: React.FC<ButtonProps> = ({
const BaseButton: React.FC<BaseButtonProps> = ({
size = 'md',
variant = 'tonal',
color = 'neutral',
@@ -91,12 +91,17 @@ const BaseButton: React.FC<ButtonProps> = ({
);
};
export const Button: React.FC<
ButtonProps & {
leadingIcon?: IconProp;
trailingIcon?: IconProp;
}
> = ({ children, leadingIcon, trailingIcon, ...props }) => (
type ButtonProps = BaseButtonProps & {
leadingIcon?: IconProp;
trailingIcon?: IconProp;
};
export const Button: React.FC<ButtonProps> = ({
children,
leadingIcon,
trailingIcon,
...props
}) => (
<BaseButton {...props}>
{leadingIcon && !props.loading && (
<Icon id='leading' icon={leadingIcon} className={classes.icon} />
@@ -109,7 +114,7 @@ export const Button: React.FC<
</BaseButton>
);
export const IconButton: React.FC<ButtonProps & { icon: IconProp }> = ({
export const IconButton: React.FC<BaseButtonProps & { icon: IconProp }> = ({
icon,
className,
children,
@@ -133,3 +138,17 @@ const LoadingIcon: React.FC = () => (
role='none'
/>
);
export const ToggleButton: React.FC<ButtonProps & { active?: boolean }> = ({
active,
className,
...props
}) => (
<Button
aria-pressed={active}
{...props}
// Toggle buttons always have neutral until pressed.
color='neutral'
className={classNames(className, classes.toggle)}
/>
);

View File

@@ -73,6 +73,17 @@ input.input {
}
}
.inputIcon {
svg {
width: 20px;
height: 20px;
inset-inline-start: var(--space-xs);
inset-block-start: var(--space-xs);
}
}
// Toggles
.toggle > span {
background: var(--color-text-tertiary);

View File

@@ -65,7 +65,11 @@ export const TextInput: RedesignComponent<typeof OldTextInput> = ({
className,
...props
}) => (
<OldTextInput {...props} className={classNames(className, classes.input)} />
<OldTextInput
{...props}
className={classNames(className, classes.input)}
iconClassName={classes.inputIcon}
/>
);
// Toggles

View File

@@ -12,6 +12,7 @@ import classes from './text_input.module.scss';
export interface TextInputProps extends ComponentPropsWithoutRef<'input'> {
icon?: IconProp;
iconClassName?: string;
}
interface Props extends TextInputProps, CommonFieldWrapperProps {}
@@ -44,8 +45,18 @@ export const TextInputField = forwardRef<HTMLInputElement, Props>(
TextInputField.displayName = 'TextInputField';
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ type = 'text', icon, className, autoComplete, ...otherProps }, ref) => (
<WrapFieldWithIcon icon={icon}>
(
{
type = 'text',
icon,
iconClassName,
className,
autoComplete,
...otherProps
},
ref,
) => (
<WrapFieldWithIcon icon={icon} iconClassName={iconClassName}>
<input
type={type}
{...otherProps}
@@ -69,11 +80,12 @@ TextInput.displayName = 'TextInput';
const WrapFieldWithIcon: React.FC<{
icon?: IconProp;
iconClassName?: string;
children: React.ReactElement;
}> = ({ icon, children }) => {
}> = ({ icon, iconClassName, children }) => {
if (icon) {
return (
<div className={classes.iconWrapper}>
<div className={classNames(classes.iconWrapper, iconClassName)}>
<Icon icon={icon} id='input-icon' className={classes.icon} />
{children}
</div>

View File

@@ -18,7 +18,7 @@ export type MenuCardProps<As extends React.ElementType> = PolymorphicProps<
As
>;
export const MenuCard = <As extends React.ElementType>({
export const MenuCard = <As extends React.ElementType = 'div'>({
as: asComp,
children,
className,

View File

@@ -247,7 +247,7 @@ export const Menu: React.FC<MenuProps> = ({
return <MenuContext value={contextValue}>{children}</MenuContext>;
};
export const MenuTrigger = <As extends React.ElementType>({
export const MenuTrigger = <As extends React.ElementType = typeof Button>({
as: asComp,
children,
...props

View File

@@ -15,7 +15,7 @@ import {
useAppSelector,
} from '@/mastodon/store';
import { useLanguageGuess, useLanguages } from './hooks';
import { languageName, useLanguageGuess } from './hooks';
import { selectComposeAttachments } from './selectors';
const selectComposeAttachmentsWithoutAlt = createAppSelector(
@@ -105,8 +105,7 @@ const defaultWrapper = (children: React.ReactNode, key: string) => (
);
const LanguageHint: React.FC<{ guess: string }> = ({ guess }) => {
const languages = useLanguages();
const language = languages.find(([lang]) => lang === guess);
const language = languageName(guess);
const { wasDismissed, dismiss } = useDismissible('compose_language_hint');
@@ -142,9 +141,7 @@ const LanguageHint: React.FC<{ guess: string }> = ({ guess }) => {
<FormattedMessage
id='compose.hints.language'
defaultMessage='Change this posts language to {language}?'
values={{
language: language[1],
}}
values={{ language }}
/>
</Callout>
);

View File

@@ -1,13 +1,19 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Map as ImmutableMap } from 'immutable';
import { useDebouncedCallback } from 'use-debounce';
import type { InitialStateLanguage } from '@/mastodon/initial_state';
import { languages } from '@/mastodon/initial_state';
import { useAppSelector } from '@/mastodon/store';
const emptyArray: InitialStateLanguage[] = [];
import { createAppSelector, useAppSelector } from '@/mastodon/store';
export function useLanguages() {
return languages ?? emptyArray;
return languages;
}
export function languageName(code: string) {
return languages?.find(([lang]) => lang === code)?.[1];
}
export function useLanguageGuess() {
@@ -41,3 +47,96 @@ export function useLanguageGuess() {
return guess;
}
const selectFrequentlyUsedLanguages = createAppSelector(
[
(state) =>
state.settings.get('frequentlyUsedLanguages') as
| ImmutableMap<string, number>
| undefined,
],
(languageCounters) =>
!languageCounters
? []
: languageCounters
.keySeq()
.sort(
(a, b) =>
(languageCounters.get(a) ?? 0) - (languageCounters.get(b) ?? 0),
)
.reverse()
.toArray(),
);
export function useLanguageList() {
const frequentlyUsed = useAppSelector(selectFrequentlyUsedLanguages);
const currentLang = useAppSelector(
(state) => state.compose.get('language') as string,
);
const guess = useLanguageGuess();
const sortedLanguages = useMemo(() => {
if (!languages) {
return [];
}
return [...languages].sort((a, b) => {
if (guess && a[0] === guess) {
// Push guessed language higher than current selection
return -1;
} else if (guess && b[0] === guess) {
return 1;
} else if (a[0] === currentLang) {
// Push current selection to the top of the list
return -1;
} else if (b[0] === currentLang) {
return 1;
} else {
// Sort according to frequently used languages
const indexOfA = frequentlyUsed.indexOf(a[0]);
const indexOfB = frequentlyUsed.indexOf(b[0]);
return (
(indexOfA > -1 ? indexOfA : Infinity) -
(indexOfB > -1 ? indexOfB : Infinity)
);
}
});
}, [currentLang, frequentlyUsed, guess]);
const fuzzySortRef = useRef<Fuzzysort.Fuzzysort>(null);
useEffect(() => {
void import('fuzzysort').then((fuzzySort) => {
fuzzySortRef.current = fuzzySort;
});
}, []);
const [searchResults, setSearchResults] = useState<
InitialStateLanguage[] | null
>(null);
const onSearch = useDebouncedCallback((search: string) => {
if (!search || !fuzzySortRef.current) {
setSearchResults(null);
return;
}
const results = fuzzySortRef.current
.go(search, languages ?? [], {
keys: ['0', '1', '2'],
limit: 5,
threshold: -10000,
})
.map((result) => result.obj);
setSearchResults(results);
}, 10);
const onClear = useCallback(() => {
setSearchResults(null);
}, []);
return {
onSearch,
onClear,
languages: searchResults ?? sortedLanguages,
};
}

View File

@@ -5,17 +5,15 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
import { LockSimpleOpenIcon, PepperIcon } from '@phosphor-icons/react';
import {
changeComposeSpoilerness,
changeComposeSpoilerText,
insertEmojiCompose,
} from '@/mastodon/actions/compose';
import {
ToggleField,
TextInputField,
} from '@/mastodon/components/form_fields/redesign';
import { ToggleButton } from '@/mastodon/components/button/redesign';
import { TextInputField } from '@/mastodon/components/form_fields/redesign';
import { Icon } from '@/mastodon/components/icon';
import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
import {
@@ -42,10 +40,6 @@ import { ComposeTextarea } from './textarea';
import { ComposeVisibility } from './visibility';
const messages = defineMessages({
sensitive: {
id: 'compose.sensitive',
defaultMessage: 'Sensitive',
},
sensitiveText: {
id: 'compose.sensitive.text',
defaultMessage: 'Sensitive content description',
@@ -95,14 +89,16 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
<div className={classes.toolbar}>
<ComposeVisibility className={classes.flexGrowWrap} />
<ToggleField
label={intl.formatMessage(messages.sensitive)}
checked={sensitive}
onChange={onSensitiveChange}
size='sm'
/>
<LanguageButton />
<ToggleButton
size='sm'
active={sensitive}
onClick={onSensitiveChange}
leadingIcon={PepperIcon}
>
<FormattedMessage id='compose.sensitive' defaultMessage='Sensitive' />
</ToggleButton>
</div>
{type === 'message' && (

View File

@@ -1,99 +1,101 @@
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { TranslateIcon } from '@phosphor-icons/react';
import { CaretDownIcon, MagnifyingGlassIcon } from '@phosphor-icons/react';
import { changeComposeLanguage } from '@/mastodon/actions/compose';
import { IconButton } from '@/mastodon/components/button/redesign';
import { PopoverMenuCard } from '@/mastodon/components/menu/card';
import { TextInput } from '@/mastodon/components/form_fields/redesign';
import {
Menu,
MenuItem,
MenuList,
MenuTrigger,
} from '@/mastodon/components/menu';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { LanguageDropdownMenu } from '../components/language_dropdown';
import { useLanguageGuess } from './hooks';
import { useLanguageList } from './hooks';
import classes from './styles.module.scss';
const messages = defineMessages({
searchPlaceholder: {
id: 'compose.language.search',
defaultMessage: 'Search languages...',
},
});
export const LanguageButton: React.FC = () => {
const [open, setOpen] = useState(false);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const activeElementRef = useRef<HTMLElement | null>(null);
const handleMouseDown = useCallback(() => {
if (!open && document.activeElement instanceof HTMLElement) {
activeElementRef.current = document.activeElement;
}
}, [open]);
const handleToggle = useCallback(() => {
if (open && activeElementRef.current)
activeElementRef.current.focus({ preventScroll: true });
setOpen(!open);
}, [open]);
const handleClose = useCallback(() => {
if (open && activeElementRef.current)
activeElementRef.current.focus({ preventScroll: true });
setOpen(false);
}, [open]);
const langCode = useAppSelector(
(state) => state.compose.get('language') as string,
);
return (
<>
<IconButton
icon={TranslateIcon}
size='sm'
ref={setTrigger}
aria-expanded={open}
onClick={handleToggle}
onMouseDown={handleMouseDown}
>
<FormattedMessage
id='compose.language.change'
defaultMessage='Change language'
/>
</IconButton>
<Menu>
<MenuTrigger size='sm' trailingIcon={CaretDownIcon}>
{langCode.toLocaleUpperCase()}
</MenuTrigger>
<PopoverMenuCard
isOpen={open}
onClose={handleClose}
offset={4}
<MenuList
placement='bottom-end'
reference={trigger}
className={classes.languageMenu}
maxWidth={280}
>
<LanguageDropdown onClose={handleClose} />
</PopoverMenuCard>
</>
<LanguageDropdown />
</MenuList>
</Menu>
);
};
export const LanguageDropdown: React.FC<{ onClose: () => void }> = ({
onClose,
}) => {
const language = useAppSelector(
(state) => state.compose.get('language') as string,
);
const guess = useLanguageGuess();
export const LanguageDropdown = () => {
const { languages, onSearch } = useLanguageList();
const dispatch = useAppDispatch();
const handleChange = useCallback(
(newLanguage: string) => {
dispatch(changeComposeLanguage(newLanguage));
onClose();
const handleChange: React.MouseEventHandler<HTMLButtonElement> = useCallback(
(event) => {
const newLanguage = event.currentTarget.dataset.language;
if (newLanguage) {
dispatch(changeComposeLanguage(newLanguage));
}
},
[dispatch, onClose],
[dispatch],
);
const intl = useIntl();
const handleSearch: React.ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
onSearch(event.target.value);
},
[onSearch],
);
return (
<LanguageDropdownMenu
value={language}
guess={guess}
onChange={handleChange}
onClose={onClose}
/>
<>
<TextInput
type='search'
onChange={handleSearch}
placeholder={intl.formatMessage(messages.searchPlaceholder)}
icon={MagnifyingGlassIcon}
/>
<div className={classes.languageList}>
{languages.map((lang) => (
<MenuItem
key={lang[0]}
onClick={handleChange}
data-language={lang[0]}
className={classes.languageItem}
>
<strong>{lang[2]}</strong>&nbsp;<span>({lang[1]})</span>
</MenuItem>
))}
{languages.length === 0 && (
<FormattedMessage
id='compose.language.not-found'
defaultMessage='No language found'
/>
)}
</div>
</>
);
};

View File

@@ -184,22 +184,30 @@ textarea.textarea {
color: var(--color-text-error);
}
// Language selector
.languageMenu {
padding: var(--space-xs) var(--space-sm);
padding: var(--space-xs);
:global(.emoji-mart-search) {
padding: 0;
padding-inline-end: 0;
input {
margin-bottom: var(--space-xs);
&:focus::placeholder {
color: transparent;
}
}
}
:global(.emoji-mart-search-icon) {
top: 0;
inset-inline-end: 0;
}
.languageList {
max-height: 350px;
overflow-y: auto;
}
:global(.emoji-mart-scroll) {
padding: 0;
margin-top: var(--space-xs);
.languageItem {
display: block;
span {
color: var(--color-text-secondary);
}
}

View File

@@ -4,6 +4,7 @@ import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import {
CaretDownIcon,
ChatCircleIcon,
MagnifyingGlassIcon,
NewspaperIcon,
@@ -46,7 +47,7 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
/>
<Menu>
<MenuTrigger size='sm'>
<MenuTrigger size='sm' trailingIcon={CaretDownIcon}>
<ComposeVisibilityButtonText privacy={privacy} />
</MenuTrigger>

View File

@@ -502,6 +502,7 @@
"compose.hints.missing-alt": "Your attachment is missing alt text.",
"compose.hints.missing-alt-many": "One or more of your attachments are missing alt text.",
"compose.language.change": "Change language",
"compose.language.not-found": "No language found",
"compose.language.search": "Search languages...",
"compose.message.direct.followers": "{name} {count, plural, =0 {} one {+ # other} other {+ # others}}",
"compose.message.notice": "Messages are not end-to-end encrypted",

View File

@@ -83,5 +83,5 @@ export type PolymorphicProps<
As extends React.ElementType,
> = {
as?: As;
} & Omit<React.ComponentPropsWithoutRef<As>, keyof AdditionalProps | 'as'> &
} & Omit<React.ComponentPropsWithRef<As>, keyof AdditionalProps | 'as'> &
AdditionalProps;