+
{children}
diff --git a/app/javascript/mastodon/components/menu/card.tsx b/app/javascript/mastodon/components/menu/card.tsx
index 475650b4a0ef70..90e90beb3f3c95 100644
--- a/app/javascript/mastodon/components/menu/card.tsx
+++ b/app/javascript/mastodon/components/menu/card.tsx
@@ -1,25 +1,24 @@
import classNames from 'classnames';
-import type { Merge } from 'type-fest';
+import type { PolymorphicProps } from '@/types/polymorphic';
import { Popover } from '../popover';
import type { PopoverProps } from '../popover';
import classes from './styles.module.scss';
-export type MenuCardProps
= Merge<
+export type MenuCardProps = PolymorphicProps<
{
- as?: As;
children: React.ReactNode;
className?: string;
elevation?: 1 | 2;
maxWidth?: number | string;
style?: React.CSSProperties;
},
- React.ComponentProps
+ As
>;
-export const MenuCard = ({
+export const MenuCard = ({
as: asComp,
children,
className,
@@ -34,10 +33,13 @@ export const MenuCard = ({
{...props}
className={classNames(className, classes.card)}
data-elevation={elevation}
- style={{
- maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
- ...style,
- }}
+ style={
+ {
+ '--_max-card-width':
+ typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
+ ...style,
+ } as React.CSSProperties
+ }
>
{children}
@@ -80,7 +82,7 @@ export const PopoverMenuCard = ({
{({ props: popoverChildProps }) => (
)}
className={classNames(
className,
props.maxWidth && classes.popoverCard,
diff --git a/app/javascript/mastodon/components/menu/index.tsx b/app/javascript/mastodon/components/menu/index.tsx
index de38d4f6107762..e83df85b59816d 100644
--- a/app/javascript/mastodon/components/menu/index.tsx
+++ b/app/javascript/mastodon/components/menu/index.tsx
@@ -1,4 +1,3 @@
-import type React from 'react';
import {
createContext,
use,
@@ -8,7 +7,7 @@ import {
useState,
} from 'react';
-import type { Merge } from 'type-fest';
+import type { PolymorphicProps } from '@/types/polymorphic';
import { Button } from '../button/redesign';
@@ -21,8 +20,8 @@ export const menuItemClass = classes.item;
export {
MenuItemDivider,
MenuItemGroup,
- MenuItemBase,
MenuItem,
+ MenuItemLink,
MenuItemRadio,
MenuItemCheckbox,
} from './items';
@@ -36,10 +35,10 @@ interface PopoverState {
reference: HTMLButtonElement | null;
}
-interface MenuButtonContextProps {
+interface MenuTriggerContextProps {
ref: (button: HTMLButtonElement | null) => void;
id: string;
- 'aria-haspopup': 'menu';
+ 'aria-haspopup'?: 'menu';
'aria-expanded': boolean;
'aria-controls'?: string;
onKeyDown: React.KeyboardEventHandler;
@@ -47,17 +46,20 @@ interface MenuButtonContextProps {
}
interface MenuListContextProps {
- ref: (button: HTMLDivElement | null) => void;
- role: 'menu';
+ ref: (list: HTMLDivElement | null) => void;
+ role?: 'menu'; // only for menus of type === 'actions'
tabIndex: -1;
id: string;
'aria-labelledby': string;
onKeyDown: React.KeyboardEventHandler;
}
+type MenuType = 'actions' | 'navigation';
+
interface MenuState {
+ type: MenuType;
popover: PopoverState;
- menuButtonProps: MenuButtonContextProps;
+ menuTriggerProps: MenuTriggerContextProps;
menuListProps: MenuListContextProps;
}
@@ -67,13 +69,13 @@ export function useMenuContext(): MenuState {
const context = use(MenuContext);
if (!context) {
- throw new Error('useMenu must be used within a component');
+ throw new Error('useMenuContext must be used within a component');
}
return context;
}
-function getAllMenuItems(menuListElement: HTMLDivElement) {
+export function getAllMenuItems(menuListElement: HTMLDivElement) {
return Array.from(
menuListElement.querySelectorAll(
':scope [data-menu-item]:not([disabled])',
@@ -81,23 +83,41 @@ function getAllMenuItems(menuListElement: HTMLDivElement) {
);
}
-export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+interface MenuProps {
+ /**
+ * Set the type according the the menu's use case for accessible markup.
+ * Use 'navigation' for menus that are primarily used for site navigation.
+ * Note that navigation menus don't support `MenuItemRadio` and `MenuItemCheckbox`.
+ */
+ type?: MenuType;
+ children: React.ReactNode;
+ noFocus?: boolean;
+}
+
+export const Menu: React.FC = ({
+ type = 'actions',
+ children,
+ noFocus,
+}) => {
const id = useId();
- const buttonId = `${id}-button`;
+ const triggerId = `${id}-trigger`;
const listId = `${id}-list`;
- const [buttonElement, setButtonElement] = useState(
- null,
- );
+ const [triggerElement, setTriggerElement] =
+ useState(null);
const [listElement, setListElement] = useState(null);
- const mountListElement = useCallback((element: HTMLDivElement | null) => {
- setListElement(element);
- if (element) {
- const menuItems = getAllMenuItems(element);
- const elementToFocus = menuItems[0] ?? element;
- elementToFocus.focus();
- }
- }, []);
+ const mountListElement = useCallback(
+ (element: HTMLDivElement | null) => {
+ setListElement(element);
+
+ if (element && type === 'actions' && !noFocus) {
+ const menuItems = getAllMenuItems(element);
+ const elementToFocus = menuItems[0] ?? element;
+ elementToFocus.focus();
+ }
+ },
+ [noFocus, type],
+ );
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -107,14 +127,20 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const closeMenu = useCallback(() => {
setIsMenuOpen(false);
- buttonElement?.focus();
- }, [buttonElement]);
+ triggerElement?.focus();
+ }, [triggerElement]);
const toggleMenu = isMenuOpen ? closeMenu : openMenu;
const handleMenuNavigation = useCallback(
(event: React.KeyboardEvent) => {
- if (!listElement) return;
+ if (!listElement) {
+ if (event.code === 'ArrowDown') {
+ openMenu();
+ event.preventDefault();
+ }
+ return;
+ }
const menuItems = getAllMenuItems(listElement);
if (menuItems.length === 0) return;
@@ -173,14 +199,16 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
openMenu,
closeMenu,
toggleMenu,
- reference: buttonElement,
+ reference: triggerElement,
popover: listElement,
};
- const menuButtonProps: MenuButtonContextProps = {
- id: buttonId,
- ref: setButtonElement,
- 'aria-haspopup': 'menu',
+ const role = type === 'actions' ? 'menu' : undefined;
+
+ const menuTriggerProps: MenuTriggerContextProps = {
+ id: triggerId,
+ ref: setTriggerElement,
+ 'aria-haspopup': role,
'aria-expanded': isMenuOpen,
'aria-controls': listElement ? listId : undefined,
onClick: toggleMenu,
@@ -190,26 +218,28 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const menuListProps: MenuListContextProps = {
id: listId,
ref: mountListElement,
- 'aria-labelledby': buttonId,
- role: 'menu',
+ 'aria-labelledby': triggerId,
+ role,
tabIndex: -1,
onKeyDown: handleMenuNavigation,
};
return {
+ type,
popover,
- menuButtonProps,
+ menuTriggerProps,
menuListProps,
};
}, [
+ type,
isMenuOpen,
openMenu,
closeMenu,
toggleMenu,
- buttonElement,
+ triggerElement,
listElement,
mountListElement,
- buttonId,
+ triggerId,
listId,
handleMenuNavigation,
]);
@@ -217,22 +247,15 @@ export const Menu: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return {children} ;
};
-export type MenuButtonProps = Merge<
- React.ComponentProps,
- {
- as?: As;
- }
->;
-
-export const MenuButton = ({
+export const MenuTrigger = ({
as: asComp,
children,
...props
-}: MenuButtonProps) => {
+}: PolymorphicProps) => {
const Component = asComp ?? Button;
- const { menuButtonProps } = useMenuContext();
+ const { menuTriggerProps } = useMenuContext();
return (
-
+
{children}
);
@@ -247,7 +270,7 @@ export const MenuList = ({
children,
...props
}: MenuListProps) => {
- const { popover, menuListProps } = useMenuContext();
+ const { popover, menuListProps, type } = useMenuContext();
return (
({
reference={popover.reference}
popoverElement={popover.popover}
container={null}
- {...props}
+ {...(props as React.ComponentPropsWithoutRef)}
{...menuListProps}
>
- {children}
+ {type === 'navigation' ? : children}
);
};
diff --git a/app/javascript/mastodon/components/menu/items.tsx b/app/javascript/mastodon/components/menu/items.tsx
index 760ad1c708242a..0cc164647c163e 100644
--- a/app/javascript/mastodon/components/menu/items.tsx
+++ b/app/javascript/mastodon/components/menu/items.tsx
@@ -1,6 +1,8 @@
-import { useCallback, useId } from 'react';
+import { Fragment, useCallback, useId } from 'react';
import classNames from 'classnames';
+import type { NavLinkProps } from 'react-router-dom';
+import { NavLink } from 'react-router-dom';
import { CheckIcon } from '@phosphor-icons/react';
@@ -8,6 +10,7 @@ import { Toggle } from '../form_fields/redesign';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
+import { useMenuContext } from '.';
import classes from './styles.module.scss';
interface MenuItemGroupProps extends React.ComponentProps<'div'> {
@@ -18,15 +21,19 @@ export const MenuItemGroup: React.FC = ({
label,
children,
}) => {
+ const { type } = useMenuContext();
const uniqueId = useId();
+ // Use list elements if we're in a navigation menu
+ const Wrapper = type === 'navigation' ? 'li' : 'div';
+
return (
-
+ {type === 'navigation' ? : children}
+
);
};
@@ -44,9 +51,11 @@ type MenuItemProps =
icon?: IconProp | 'reserve-space';
trailingContent?: React.ReactNode;
iconClassName?: string;
+ keepMenuOpenOnClick?: boolean;
+ onClick?: React.MouseEventHandler;
};
-export const MenuItemBase = ({
+const MenuItemBase = ({
active,
disabled,
as: AsComp,
@@ -55,11 +64,31 @@ export const MenuItemBase = ({
icon,
trailingContent,
iconClassName,
+ keepMenuOpenOnClick,
+ onClick,
...props
}: MenuItemProps) => {
const Component = AsComp ?? 'div';
+ const { popover } = useMenuContext();
+
+ const closeMenuOnClick = useCallback(
+ (e) => {
+ if (!keepMenuOpenOnClick) {
+ // Closing with a short delay feels nicer than an instant close
+ setTimeout(() => {
+ popover.closeMenu();
+ }, 100);
+ }
+
+ onClick?.(e);
+ },
+ [keepMenuOpenOnClick, onClick, popover],
+ );
+
return (
({
active && classes.itemActive,
)}
aria-disabled={disabled}
+ onClick={closeMenuOnClick}
>
{icon && icon !== 'reserve-space' && (
({
{children}
- {trailingContent}
+ {trailingContent && (
+ {trailingContent}
+ )}
);
};
@@ -89,13 +121,85 @@ export const MenuItem: React.FC, 'as'>> = ({
children,
...props
}) => {
+ const { type } = useMenuContext();
+
+ const Wrapper = type === 'actions' ? Fragment : 'li';
+
return (
-
- {children}
-
+
+
+ {children}
+
+
+ );
+};
+
+type MenuItemLinkProps = Omit, 'as'> &
+ (
+ | ({ as: 'a' } & React.ComponentProps<'a'>)
+ | ({ as?: 'link' } & NavLinkProps)
+ );
+
+export const MenuItemLink: React.FC = ({
+ as,
+ children,
+ onKeyDown,
+ ...props
+}) => {
+ const { type } = useMenuContext();
+
+ const handleSpacebarPress = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (type === 'actions' && e.code === 'Space') {
+ (e.target as HTMLElement).click();
+ e.preventDefault();
+ }
+ onKeyDown?.(e);
+ },
+ [onKeyDown, type],
+ );
+
+ const Wrapper = type === 'actions' ? Fragment : 'li';
+ const asElement = (as ?? 'link') === 'link' ? NavLink : 'a';
+ const externalLinkProps =
+ as === 'a'
+ ? {
+ target: '_blank',
+ rel: 'noopener',
+ }
+ : undefined;
+
+ return (
+
+
+ {children}
+
+
);
};
+// Helper to prevent item components from being used with incompatible menu types
+function useAssertMenuType(componentName: string) {
+ const { type } = useMenuContext();
+
+ if (type === 'navigation') {
+ throw new Error(
+ `\`${componentName}\` can not be used inside of \`\`. Use \`type='actions'\` instead.`,
+ );
+ }
+}
+
interface MenuItemRadioProps extends Omit<
MenuItemProps<'button'>,
'as' | 'onChange' | 'icon'
@@ -112,6 +216,8 @@ export const MenuItemRadio: React.FC = ({
onChange,
...props
}) => {
+ useAssertMenuType('MenuItemRadio');
+
const handleChange = useCallback(() => {
onChange?.({ value });
}, [value, onChange]);
@@ -142,6 +248,8 @@ export const MenuItemCheckbox: React.FC = ({
onChange,
...props
}) => {
+ useAssertMenuType('MenuItemCheckbox');
+
const handleChange = useCallback(() => {
onChange?.({ value, checked: !checked });
}, [onChange, value, checked]);
@@ -154,7 +262,13 @@ export const MenuItemCheckbox: React.FC = ({
aria-checked={checked}
onClick={handleChange}
trailingContent={
-
+
}
>
{children}
diff --git a/app/javascript/mastodon/components/menu/menu.stories.tsx b/app/javascript/mastodon/components/menu/menu.stories.tsx
index 4e18c24712b542..963fed89d322ce 100644
--- a/app/javascript/mastodon/components/menu/menu.stories.tsx
+++ b/app/javascript/mastodon/components/menu/menu.stories.tsx
@@ -13,16 +13,16 @@ import { useToggle } from '@/mastodon/hooks/useToggle';
import {
Menu,
- MenuButton,
+ MenuTrigger,
MenuList,
MenuItem,
MenuItemDivider,
MenuItemCheckbox,
MenuItemGroup,
MenuItemRadio,
+ MenuItemLink,
} from '.';
import type { MenuCardProps } from './card';
-import { MenuCard } from './card';
const meta = {
title: 'Redesign/Menu',
@@ -43,27 +43,12 @@ type Story = StoryObj;
const handleMenuItemClick = action('menu item click');
-export const Simple: Story = {
- render(args) {
- return (
-
-
- First item
-
-
- Second item
-
-
- );
- },
-};
-
-export const Popover: Story = {
+export const Default: Story = {
render(args) {
return (
- Click to show dropdown
+ Show actions
@@ -89,44 +74,76 @@ export const Complex: Story = {
}, []);
return (
-
- First item
-
-
-
-
- Daytime toggle
-
-
-
-
- None
-
-
- Rain
-
-
- Snow
-
-
-
+
+
+ World settings
+
+
+
+ First item
+
+
+
+
+
+ Daytime toggle
+
+
+
+
+ None
+
+
+ Rain
+
+
+ Snow
+
+
+
+
+
+ );
+ },
+};
+
+export const Navigation: Story = {
+ render(args) {
+ return (
+
+
+ More links
+
+
+ About
+ Terms & Conditions
+
+ Privacy
+
+
+
+
);
},
};
diff --git a/app/javascript/mastodon/components/menu/styles.module.scss b/app/javascript/mastodon/components/menu/styles.module.scss
index 62408f05896e12..4261cdb7caef78 100644
--- a/app/javascript/mastodon/components/menu/styles.module.scss
+++ b/app/javascript/mastodon/components/menu/styles.module.scss
@@ -10,6 +10,7 @@
background: var(--color-bg-primary);
overflow: hidden;
z-index: calc(infinity);
+ max-width: var(--_max-card-width, 100vw);
&[data-elevation='2'] {
@include mixins.elevation-2;
@@ -44,6 +45,8 @@
border-radius: var(--radius-sm);
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
+ color: var(--color-text-primary);
+ text-decoration: none;
cursor: var(--cursor);
transition:
background 200ms,
@@ -57,10 +60,19 @@
text-align: start;
}
+ &:hover {
+ text-decoration: none;
+ }
+
&:hover:not([aria-disabled='true']) {
background-color: var(--color-bg-highlight);
}
+ &:focus {
+ // Override overeager global styles
+ border-radius: var(--radius-sm);
+ }
+
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: -2px;
diff --git a/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx b/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx
index ee17187fb1c96d..ee278c393c3ee2 100644
--- a/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx
+++ b/app/javascript/mastodon/components/modal_shell/redesign.stories.tsx
@@ -38,7 +38,7 @@ const meta = {
Cancel
- Save
+ Save
);
diff --git a/app/javascript/mastodon/components/modal_shell/redesign.tsx b/app/javascript/mastodon/components/modal_shell/redesign.tsx
index 4702269346e00d..059969cd39bd2b 100644
--- a/app/javascript/mastodon/components/modal_shell/redesign.tsx
+++ b/app/javascript/mastodon/components/modal_shell/redesign.tsx
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import type { PolymorphicProps } from '@/types/polymorphic';
+import type { NamedFocusTarget } from '../navigation_focus_target';
import { NavigationFocusTarget } from '../navigation_focus_target';
import classes from './redesign.module.scss';
@@ -48,7 +49,7 @@ type HeadingLevels = 1 | 2 | 3 | 4 | 5 | 6;
type ModalTitleProps = { children: React.ReactNode; level?: HeadingLevels } & (
| { noFocus: true }
- | { noFocus?: false; focusTargetName?: string }
+ | { noFocus?: false; focusTargetName?: NamedFocusTarget }
);
export const ModalTitle: React.FC<
diff --git a/app/javascript/mastodon/components/navigation_focus_target/index.tsx b/app/javascript/mastodon/components/navigation_focus_target/index.tsx
index 5a971685f295e4..56a5070fae81cf 100644
--- a/app/javascript/mastodon/components/navigation_focus_target/index.tsx
+++ b/app/javascript/mastodon/components/navigation_focus_target/index.tsx
@@ -14,11 +14,12 @@ import type { MastodonLocation } from '../router';
export const FOCUS_TARGET = {
POST: 'detailed-status',
+ SEARCH: 'search',
} as const;
-export type FocusTarget =
- | boolean
- | (typeof FOCUS_TARGET)[keyof typeof FOCUS_TARGET];
+export type NamedFocusTarget = (typeof FOCUS_TARGET)[keyof typeof FOCUS_TARGET];
+
+export type FocusTarget = boolean | NamedFocusTarget;
const FocusTargetContext = createContext | null>(
null,
@@ -98,7 +99,7 @@ export const FocusTargetProvider: React.FC<{
);
};
-export function useFocusOnNavigation(targetName?: string) {
+export function useFocusOnNavigation(targetName?: NamedFocusTarget) {
const focusTargetRef = useContext(FocusTargetContext);
return useCallback(
@@ -110,7 +111,11 @@ export function useFocusOnNavigation(targetName?: string) {
return;
}
- if (focusTarget === true || focusTarget === targetName) {
+ const shouldSetFocus = targetName
+ ? focusTarget === targetName
+ : focusTarget === true;
+
+ if (shouldSetFocus) {
setTimeout(() => {
element.focus({ preventScroll: true });
}, 0);
@@ -121,7 +126,7 @@ export function useFocusOnNavigation(targetName?: string) {
}
interface FocusTargetElementProps extends React.ComponentPropsWithoutRef<'h1'> {
- focusTargetName?: string;
+ focusTargetName?: NamedFocusTarget;
}
export const NavigationFocusTarget = polymorphicForwardRef<
diff --git a/app/javascript/mastodon/components/status/hooks.ts b/app/javascript/mastodon/components/status/hooks.ts
index 893f22b98c23b1..4e6fa32e52f16d 100644
--- a/app/javascript/mastodon/components/status/hooks.ts
+++ b/app/javascript/mastodon/components/status/hooks.ts
@@ -282,9 +282,7 @@ export function useHandlersForStatus(
);
return useElementHandledLink({
hashtagAccountId:
- typeof status?.account === 'string'
- ? status.account
- : status?.account.acct,
+ typeof status?.account === 'string' ? status.account : status?.account.id,
hrefToCollectionId,
hrefToMention,
});
diff --git a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx
index 8baa16dec429e0..4a81f5eff932b1 100644
--- a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx
+++ b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx
@@ -143,7 +143,12 @@ export const CollectionAccountsList: React.FC<{
({ relationship, accountId }: RenderButtonOptions) => {
if (!me || !relationship) {
// Show follow button when logged out (it will trigger the remote interaction modal)
- return ;
+ return (
+
+ );
}
// When viewing your own collection, only show the Follow button
@@ -165,7 +170,12 @@ export const CollectionAccountsList: React.FC<{
);
}
- return ;
+ return (
+
+ );
},
[collectionOwnerId, confirmRevoke],
);
diff --git a/app/javascript/mastodon/features/compose/components/search.tsx b/app/javascript/mastodon/features/compose/components/search.tsx
index 23e034be44ed54..91a61fd7bf7ec9 100644
--- a/app/javascript/mastodon/features/compose/components/search.tsx
+++ b/app/javascript/mastodon/features/compose/components/search.tsx
@@ -19,7 +19,13 @@ import { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
+import {
+ FOCUS_TARGET,
+ useFocusOnNavigation,
+} from '@/mastodon/components/navigation_focus_target';
import { getCollectionPath } from '@/mastodon/features/collections/utils';
+import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs';
+import { isRedesignEnabled } from '@/mastodon/utils/environment';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@@ -105,6 +111,7 @@ export const Search: React.FC<{
const [expanded, setExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(-1);
const [quickActions, setQuickActions] = useState([]);
+ const focusOnNavigation = useFocusOnNavigation(FOCUS_TARGET.SEARCH);
const unfocus = useCallback(() => {
document.querySelector('.ui')?.parentElement?.focus();
@@ -600,7 +607,10 @@ export const Search: React.FC<{
className={classNames('search', { active: expanded })}
>
{
+export const ComposeAttachments: React.FC<{ className?: string }> = ({
+ className,
+}) => {
const { hasPoll, hasAttachments, quotedStatusId } = useAppSelector(
selectComposeHasAttachments,
);
@@ -20,11 +23,11 @@ export const ComposeAttachments: React.FC = () => {
}
return (
- <>
+
{hasPoll && }
{hasAttachments && }
- {quotedStatusId && }
- >
+ {quotedStatusId && }
+
);
};
@@ -54,7 +57,3 @@ const ComposeMediaAttachments: React.FC = () => {
);
};
-
-const ComposeQuotedStatus: React.FC<{ id: string }> = ({ id }) => {
- return Quoting status {id}
;
-};
diff --git a/app/javascript/mastodon/features/compose/redesign/footer.tsx b/app/javascript/mastodon/features/compose/redesign/footer.tsx
index 5037ae3ee5085f..035bec8ce56024 100644
--- a/app/javascript/mastodon/features/compose/redesign/footer.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/footer.tsx
@@ -83,7 +83,7 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({
{
);
}
- if (isDifferentLanguage) {
+ const { wasDismissed } = useDismissible('compose_language_hint');
+ if (isDifferentLanguage && !wasDismissed) {
messages.push( );
}
@@ -104,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');
@@ -141,9 +141,7 @@ const LanguageHint: React.FC<{ guess: string }> = ({ guess }) => {
);
diff --git a/app/javascript/mastodon/features/compose/redesign/hooks.ts b/app/javascript/mastodon/features/compose/redesign/hooks.ts
index d3c1ce14f06410..bf8bc7b35869c7 100644
--- a/app/javascript/mastodon/features/compose/redesign/hooks.ts
+++ b/app/javascript/mastodon/features/compose/redesign/hooks.ts
@@ -1,17 +1,25 @@
-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() {
- const text = useAppSelector((state) => state.compose.get('text') as string);
+ const text = useAppSelector((state) =>
+ (state.compose.get('text') as string).trim(),
+ );
const [guess, setGuess] = useState('');
useEffect(() => {
@@ -39,3 +47,96 @@ export function useLanguageGuess() {
return guess;
}
+
+const selectFrequentlyUsedLanguages = createAppSelector(
+ [
+ (state) =>
+ state.settings.get('frequentlyUsedLanguages') as
+ | ImmutableMap
+ | 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(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,
+ };
+}
diff --git a/app/javascript/mastodon/features/compose/redesign/index.tsx b/app/javascript/mastodon/features/compose/redesign/index.tsx
index d76ea6bc835193..c505069eb3ca7e 100644
--- a/app/javascript/mastodon/features/compose/redesign/index.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/index.tsx
@@ -5,21 +5,16 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
-import { LockSimpleOpenIcon } from '@phosphor-icons/react';
-import { useDebouncedCallback } from 'use-debounce';
+import { LockSimpleOpenIcon, PepperIcon } from '@phosphor-icons/react';
-import messageBackground from '@/images/composer_message.svg?url';
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 { useResizeObserver } from '@/mastodon/hooks/useObserver';
import {
focusComposerTextarea,
getComposerTextarea,
@@ -44,10 +39,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',
@@ -70,19 +61,8 @@ export const RedesignComposeForm: React.FC = ({
const type = useAppSelector(selectComposeType);
const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive);
- let background: string | null = null;
- if (type === 'message') {
- background = messageBackground;
- }
-
- const {
- onSensitiveChange,
- onSensitiveTextChange,
- onEmojiPick,
- onSubmit,
- onWrapperMount,
- onWrapperScroll,
- } = useComposeHandlers(redirectOnSuccess);
+ const { onSensitiveChange, onSensitiveTextChange, onEmojiPick, onSubmit } =
+ useComposeHandlers(redirectOnSuccess);
const intl = useIntl();
const titleId = useId();
@@ -94,12 +74,7 @@ export const RedesignComposeForm: React.FC = ({
aria-labelledby={titleId}
className={classNames(className, classes.root)}
>
- {background && (
-
- )}
+ {type === 'message' &&
}
@@ -108,14 +83,16 @@ export const RedesignComposeForm: React.FC = ({
-
-
+
+
+
+
{type === 'message' && (
@@ -138,19 +115,13 @@ export const RedesignComposeForm: React.FC = ({
/>
)}
-
-
-
-
-
+
+
@@ -220,49 +191,10 @@ function useComposeHandlers(redirectOnSuccess?: boolean) {
[canSubmit, dispatch, redirectOnSuccess],
);
- // Handle wrapper fade to indicate scroll.
- const onWrapperScroll = useDebouncedCallback(wrapperScroll, 20, {
- leading: true,
- });
- const observer = useResizeObserver(wrapperResize);
- const onWrapperMount: React.RefCallback = useCallback(
- (ele) => {
- if (ele) {
- observer.observe(ele);
- }
- },
- [observer],
- );
-
return {
onSubmit,
onEmojiPick,
onSensitiveChange,
onSensitiveTextChange,
- onWrapperScroll,
- onWrapperMount,
};
}
-
-function wrapperUpdate(ele: HTMLElement) {
- const scrollMax = ele.scrollHeight - ele.offsetHeight - 5; // 5px padding to account for sub-pixel issues
- if (scrollMax > 0 && ele.scrollTop < scrollMax) {
- ele.dataset.scrollDown = 'true';
- } else {
- delete ele.dataset.scrollDown;
- }
-}
-
-function wrapperResize(entries: ResizeObserverEntry[]) {
- for (const entry of entries) {
- if (entry.target instanceof HTMLElement) {
- wrapperUpdate(entry.target);
- }
- }
-}
-
-function wrapperScroll(event: React.UIEvent) {
- if (event.target instanceof HTMLElement) {
- wrapperUpdate(event.target);
- }
-}
diff --git a/app/javascript/mastodon/features/compose/redesign/language.tsx b/app/javascript/mastodon/features/compose/redesign/language.tsx
index ab57a404572a6a..0db27911d75fd7 100644
--- a/app/javascript/mastodon/features/compose/redesign/language.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/language.tsx
@@ -1,99 +1,102 @@
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 { 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 { CaretIcon } from '@/mastodon/components/button/redesign';
+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';
-export const LanguageButton: React.FC = () => {
- const [open, setOpen] = useState(false);
- const [trigger, setTrigger] = useState(null);
- const activeElementRef = useRef(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 messages = defineMessages({
+ searchPlaceholder: {
+ id: 'compose.language.search',
+ defaultMessage: 'Search languages...',
+ },
+});
- const handleClose = useCallback(() => {
- if (open && activeElementRef.current)
- activeElementRef.current.focus({ preventScroll: true });
-
- setOpen(false);
- }, [open]);
+export const LanguageButton: React.FC = () => {
+ const langCode = useAppSelector(
+ (state) => state.compose.get('language') as string,
+ );
return (
- <>
-
-
-
+
+
+ {langCode.toLocaleUpperCase()}
+
-
-
-
- >
+
+
+
);
};
-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 = useCallback(
+ (event) => {
+ const newLanguage = event.currentTarget.dataset.language;
+ if (newLanguage) {
+ dispatch(changeComposeLanguage(newLanguage));
+ }
},
- [dispatch, onClose],
+ [dispatch],
+ );
+
+ const intl = useIntl();
+ const handleSearch: React.ChangeEventHandler = useCallback(
+ (event) => {
+ onSearch(event.target.value);
+ },
+ [onSearch],
);
return (
-
+ <>
+
+
+ {languages.map((lang) => (
+
+ {lang[2]} ({lang[1]})
+
+ ))}
+
+ {languages.length === 0 && (
+
+ )}
+
+ >
);
};
diff --git a/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx b/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx
index 8d65f75223f643..ee5d18f2079859 100644
--- a/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/modal_cancel.tsx
@@ -52,13 +52,13 @@ const ComposerModalCancelConfirm: React.FC<{ openNew?: boolean }> = ({
/>
-
+
-
+
void }> = ({
/>
-
+
{
/>
-
+
{
className={classNames(
classes.pollDurationSelect,
buttonClasses.base,
- buttonClasses.solid,
buttonClasses.tonal,
+ buttonClasses.neutral,
buttonClasses.xs,
)}
>
diff --git a/app/javascript/mastodon/features/compose/redesign/quote.tsx b/app/javascript/mastodon/features/compose/redesign/quote.tsx
new file mode 100644
index 00000000000000..bc4f6065e96740
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/redesign/quote.tsx
@@ -0,0 +1,106 @@
+import type React from 'react';
+import { useCallback } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import { quoteComposeCancel } from '@/mastodon/actions/compose_typed';
+import { Avatar } from '@/mastodon/components/avatar';
+import { Blurhash } from '@/mastodon/components/blurhash';
+import { Card, CardBody, CardTitle } from '@/mastodon/components/card';
+import { LinkedDisplayName } from '@/mastodon/components/display_name';
+import { EmojiHTML } from '@/mastodon/components/emoji/html';
+import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
+import type { AccountStatusShape } from '@/mastodon/models/status';
+import { selectAccountStatus } from '@/mastodon/selectors/statuses';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+import type { OnElementHandler } from '@/mastodon/utils/html';
+
+import classes from './attachments.module.scss';
+
+export const ComposeQuote: React.FC<{ id: string }> = ({ id }) => {
+ const status = useAppSelector((state) => selectAccountStatus(state, id));
+
+ const dispatch = useAppDispatch();
+ const handleDelete = useCallback(() => {
+ dispatch(quoteComposeCancel());
+ }, [dispatch]);
+
+ if (!status) {
+ return null;
+ }
+
+ let imageEle: React.ReactNode = null;
+ const image = status.media_attachments.find(({ type }) => type !== 'unknown');
+ if (image) {
+ imageEle = !status.sensitive ? (
+
+ ) : (
+
+ );
+ }
+
+ const statusTo = `/@${status.account.acct}/${status.id}`;
+
+ return (
+
+
+ }
+ afterContent={
+
+
+
+ }
+ >
+
+
+
+
+ {!status.spoiler_text ? (
+
+ ) : (
+
+
+
+ {status.spoiler_text}
+
+ )}
+
+
+ );
+};
+
+const ComposeQuoteBody: React.FC<{
+ status: AccountStatusShape;
+}> = ({ status }) => {
+ return (
+
+ );
+};
+
+const onStatusLinks: OnElementHandler = (element, { key }, children) => {
+ if (element instanceof HTMLAnchorElement) {
+ return {children} ;
+ }
+ return undefined;
+};
diff --git a/app/javascript/mastodon/features/compose/redesign/selectors.ts b/app/javascript/mastodon/features/compose/redesign/selectors.ts
index 8fe1f23e4d37cd..0b9f6da112fc8a 100644
--- a/app/javascript/mastodon/features/compose/redesign/selectors.ts
+++ b/app/javascript/mastodon/features/compose/redesign/selectors.ts
@@ -1,6 +1,7 @@
import { length } from 'stringz';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
+import { immutableListToSuggestions } from '@/mastodon/components/autosuggest/utils';
import type { StatusVisibility } from '@/mastodon/models/status';
import type { ComposeType } from '@/mastodon/reducers/slices/composer';
import { createAppSelector } from '@/mastodon/store';
@@ -239,3 +240,11 @@ export const selectComposePoll = createAppSelector(
};
},
);
+
+export const selectSuggestions = createAppSelector(
+ [
+ (state) =>
+ state.compose.get('suggestions') as unknown as Immutable.List,
+ ],
+ (list) => immutableListToSuggestions(list),
+);
diff --git a/app/javascript/mastodon/features/compose/redesign/styles.module.scss b/app/javascript/mastodon/features/compose/redesign/styles.module.scss
index b1af1042951f28..08aa18eb9723a1 100644
--- a/app/javascript/mastodon/features/compose/redesign/styles.module.scss
+++ b/app/javascript/mastodon/features/compose/redesign/styles.module.scss
@@ -13,17 +13,20 @@
min-width: 300px;
position: relative;
overflow: hidden;
+ z-index: 1; // To cover the sidebar
}
.background {
mask-repeat: repeat;
- background-color: var(--color-bg-brand-base);
+ mask-image: url('@/images/composer_message.svg');
+ mask-size: 311.36px auto;
+ background-color: var(--color-border-brand);
opacity: 0.15;
position: absolute;
bottom: 0;
right: 0;
- width: 800px;
- height: 800px;
+ top: 0;
+ left: 0;
pointer-events: none;
}
@@ -76,14 +79,13 @@
}
}
-.editorWrapper {
+.textareaWrapper {
--fade-size: var(--space-xl);
overflow-y: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
- gap: var(--space-md);
scrollbar-width: thin;
overscroll-behavior-y: contain;
position: relative;
@@ -99,40 +101,64 @@
position: sticky;
bottom: 0;
flex-shrink: 0;
- margin-top: calc((-1 * var(--fade-size)) - var(--space-md));
+ margin-top: calc(-1 * var(--fade-size));
opacity: 0;
pointer-events: none;
transition: opacity 200ms;
}
- &[data-scroll-down]::after {
+ &[data-scroll-down='true']::after {
opacity: 1;
}
}
-.textareaWrapper {
- flex-grow: 1;
+.textarea,
+.textareaMirror {
+ @include mixins.type-body-lg;
+
padding: var(--space-xs);
+}
+
+.textarea {
+ flex-grow: 1;
+ flex-shrink: 0;
border-radius: var(--radius-xs);
transition: border 200ms;
cursor: text;
+ border: none;
+ width: 100%;
+ outline: none;
+ background: none;
- &:focus-within {
+ &:focus {
outline: 2px solid var(--color-border-brand);
outline-offset: -2px;
- textarea::placeholder {
+ &::placeholder {
color: transparent;
}
}
+}
- textarea {
- @include mixins.type-body-lg;
+textarea.textarea {
+ resize: none;
+}
- border: none;
- width: 100%;
- outline: none;
- }
+.textareaMirror {
+ visibility: hidden;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ white-space: pre-wrap; // This makes the formatting match the text area.
+}
+
+.attachments {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-top: var(--space-md);
}
.footer {
@@ -155,22 +181,30 @@
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);
- :global(.emoji-mart-search-icon) {
- top: 0;
- inset-inline-end: 0;
+ &:focus::placeholder {
+ color: transparent;
+ }
}
+}
+
+.languageList {
+ max-height: 350px;
+ overflow-y: auto;
+}
+
+.languageItem {
+ display: block;
- :global(.emoji-mart-scroll) {
- padding: 0;
- margin-top: var(--space-xs);
+ span {
+ color: var(--color-text-secondary);
}
}
diff --git a/app/javascript/mastodon/features/compose/redesign/textarea.tsx b/app/javascript/mastodon/features/compose/redesign/textarea.tsx
index 3e59ebe57a26e0..214fa204c6e0d5 100644
--- a/app/javascript/mastodon/features/compose/redesign/textarea.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/textarea.tsx
@@ -14,15 +14,23 @@ import {
selectComposeSuggestion,
} from '@/mastodon/actions/compose';
import { processPasteOrDrop } from '@/mastodon/actions/compose_typed';
-import AutosuggestTextareaOriginal from '@/mastodon/components/autosuggest_textarea';
-import { COMPOSER_TEXTAREA_ID } from '@/mastodon/reducers/slices/composer';
+import type { OnSuggestionSelect } from '@/mastodon/components/autosuggest/hooks';
+import { useAutosuggestFloatingMenu } from '@/mastodon/components/autosuggest/hooks';
+import { AutosuggestMenu } from '@/mastodon/components/autosuggest/list';
+import { TextArea } from '@/mastodon/components/form_fields';
+import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
+import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
+import {
+ COMPOSER_TEXTAREA_ID,
+ focusComposerTextarea,
+} from '@/mastodon/reducers/slices/composer';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
-import { selectComposeType } from './selectors';
+import { selectComposeType, selectSuggestions } from './selectors';
import classes from './styles.module.scss';
const messages = defineMessages({
@@ -38,23 +46,6 @@ const messages = defineMessages({
},
});
-type SuggestSelectedHandler = (
- position: number,
- token: string,
- suggestion: unknown,
-) => void;
-
-const AutosuggestTextarea =
- AutosuggestTextareaOriginal as React.ForwardRefExoticComponent<
- {
- suggestions: Immutable.List;
- onSuggestionSelected: SuggestSelectedHandler;
- onSuggestionsClearRequested: () => void;
- onSuggestionsFetchRequested: (token: string) => void;
- } & TextareaAutosizeProps &
- React.RefAttributes
- >;
-
type ComposeTextareaProps = Omit<
TextareaAutosizeProps,
| 'placeholder'
@@ -71,9 +62,6 @@ const selectComposeTextState = createAppSelector(
(compose) => ({
text: compose.get('text') as string,
lang: compose.get('language') as string,
- suggestions: compose.get(
- 'suggestions',
- ) as unknown as Immutable.List,
isSubmitting: !!compose.get('is_submitting'),
}),
);
@@ -82,89 +70,122 @@ export const ComposeTextarea: React.FC = ({
onSubmit,
className,
disabled,
+ children,
...props
}) => {
const intl = useIntl();
+ // Selectors
const type = useAppSelector(selectComposeType);
- const { suggestions, text, lang, isSubmitting } = useAppSelector(
- selectComposeTextState,
+ const { text, lang, isSubmitting } = useAppSelector(selectComposeTextState);
+ const dispatch = useAppDispatch();
+
+ // Suggestion logic
+ const onSuggestionFetch = useCallback(
+ (token: string) => {
+ dispatch(fetchComposeSuggestions(token));
+ },
+ [dispatch],
);
- const dispatch = useAppDispatch();
- const onClickWrapper: React.MouseEventHandler = useCallback(
- (event) => {
- if (event.target instanceof HTMLDivElement) {
- event.target.querySelector('textarea')?.focus();
- }
+ const onSuggestion: OnSuggestionSelect = useCallback(
+ (tokenStart, token, suggestion) => {
+ dispatch(
+ selectComposeSuggestion(tokenStart, token, suggestion, ['text']),
+ );
+ focusComposerTextarea(true);
},
- [],
+ [dispatch],
);
+
+ const onSuggestionClear = useCallback(() => {
+ dispatch(clearComposeSuggestions());
+ }, [dispatch]);
+
+ const suggestions = useAppSelector(selectSuggestions);
+ const textAreaRef = useRef(null);
+
+ const {
+ onTextChange,
+ focus,
+ mirror,
+ sourceProps: fullSourceProps,
+ suggestProps,
+ } = useAutosuggestFloatingMenu({
+ suggestions,
+ text,
+ className: classes.textareaMirror,
+ sourceRef: textAreaRef,
+ onSelect: onSuggestion,
+ onFetch: onSuggestionFetch,
+ onClear: onSuggestionClear,
+ });
+
+ const { onScroll, ...sourceProps } = fullSourceProps;
+
+ // Update the composer text and trigger suggestions.
const onChange: React.ChangeEventHandler = useCallback(
(event) => {
dispatch(changeCompose(event.target.value));
+ onTextChange(event);
},
- [dispatch],
+ [dispatch, onTextChange],
);
+
const onKeyDown: React.KeyboardEventHandler =
useCallback(
(event) => {
- const key = event.key.toLowerCase();
+ const key = normalizeKey(event.key);
+
if (key === 'enter' && (event.ctrlKey || event.metaKey)) {
onSubmit();
event.preventDefault();
- } else if (['esc', 'escape'].includes(key)) {
- event.currentTarget.blur();
+ onSuggestionClear();
+ } else if (key === 'escape') {
+ // Dismiss the suggestions if we're displaying any.
+ if (suggestions.length > 0) {
+ onSuggestionClear();
+ } else {
+ // Otherwise lose focus on the textarea.
+ event.currentTarget.blur();
+ }
+ } else if (key === 'down') {
+ focus(event);
}
},
- [onSubmit],
+ [onSubmit, onSuggestionClear, suggestions.length, focus],
);
- const onPaste: React.ClipboardEventHandler = useCallback(
- (event) => {
- if (event.clipboardData.files.length === 1) {
- event.preventDefault();
- }
- dispatch(processPasteOrDrop(event.clipboardData));
- },
- [dispatch],
- );
- const onDrop: React.DragEventHandler = useCallback(
- (event) => {
- if (event.dataTransfer.files.length === 1) {
+
+ const onPasteOrDrop = useCallback(
+ (event: React.ClipboardEvent | React.DragEvent) => {
+ const data =
+ 'clipboardData' in event ? event.clipboardData : event.dataTransfer;
+ if (data.files.length === 1) {
event.preventDefault();
}
- dispatch(processPasteOrDrop(event.dataTransfer));
- },
- [dispatch],
- );
- const onSuggestionsFetchRequested = useCallback(
- (token: string) => {
- dispatch(fetchComposeSuggestions(token));
- },
- [dispatch],
- );
- const onSuggestionsClearRequested = useCallback(() => {
- dispatch(clearComposeSuggestions());
- }, [dispatch]);
- const onSuggestionSelected: SuggestSelectedHandler = useCallback(
- (position, token, suggestion) => {
- dispatch(selectComposeSuggestion(position, token, suggestion, ['text']));
+ dispatch(processPasteOrDrop(data));
},
[dispatch],
);
- const textareaRef = useRef(null);
+ const { sensor, isInViewport } = useScrollSensor({
+ placement: 'bottom',
+ tolerance: 10,
+ });
return (
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- This just moves focus to the textarea.
-
= ({
: messages.placeholder,
)}
disabled={disabled || isSubmitting}
- suggestions={suggestions}
- onSuggestionsFetchRequested={onSuggestionsFetchRequested}
- onSuggestionsClearRequested={onSuggestionsClearRequested}
- onSuggestionSelected={onSuggestionSelected}
onKeyDown={onKeyDown}
- onDrop={onDrop}
- onPaste={onPaste}
+ onDrop={onPasteOrDrop}
+ onPaste={onPasteOrDrop}
onChange={onChange}
+ {...sourceProps}
/>
+
+ {mirror}
+
+
+
+ {children}
+
+ {sensor}
);
};
diff --git a/app/javascript/mastodon/features/compose/redesign/trigger.module.scss b/app/javascript/mastodon/features/compose/redesign/trigger.module.scss
index 51cad33e5cd115..7cccba24a64bef 100644
--- a/app/javascript/mastodon/features/compose/redesign/trigger.module.scss
+++ b/app/javascript/mastodon/features/compose/redesign/trigger.module.scss
@@ -1,9 +1,12 @@
@use '@/styles/mastodon/variables';
.button {
- position: fixed;
- bottom: var(--space-md);
- right: var(--space-md);
+ &:not(.buttonInline) {
+ position: fixed;
+ bottom: var(--space-md);
+ right: var(--space-md);
+ z-index: 2;
+ }
}
.composer,
@@ -17,7 +20,8 @@
}
.composer {
- width: min(560px, calc(100vw - var(--space-md) * 2));
+ width: calc(100vw - var(--space-md) * 2);
+ max-width: 500px;
min-height: 520px;
max-height: 80vh;
}
diff --git a/app/javascript/mastodon/features/compose/redesign/trigger.tsx b/app/javascript/mastodon/features/compose/redesign/trigger.tsx
index 7a51db52934da6..f53c98038fabb1 100644
--- a/app/javascript/mastodon/features/compose/redesign/trigger.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/trigger.tsx
@@ -4,6 +4,8 @@ import { lazy, Suspense, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+
import {
ChatCircleIcon,
NewspaperIcon,
@@ -14,7 +16,7 @@ import { IconButton } from '@/mastodon/components/button/redesign';
import { CircularProgress } from '@/mastodon/components/circular_progress';
import {
Menu,
- MenuButton,
+ MenuTrigger,
MenuList,
MenuItem,
} from '@/mastodon/components/menu';
@@ -32,7 +34,12 @@ const ComposeLazyForm = lazy(() =>
})),
);
-export const ComposeRedesignButton: React.FC = () => {
+export const ComposeRedesignButton: React.FC<{
+ /**
+ * Render the button in regular document flow instead of fixed positioning for mobile layout
+ */
+ inline?: boolean;
+}> = ({ inline }) => {
const displayState = useAppSelector((state) => state.composer.displayState);
const dispatch = useAppDispatch();
@@ -71,18 +78,18 @@ export const ComposeRedesignButton: React.FC = () => {
return (
-
-
+
diff --git a/app/javascript/mastodon/features/compose/redesign/upload.tsx b/app/javascript/mastodon/features/compose/redesign/upload.tsx
index 309700134aa05b..77d8c4e90327af 100644
--- a/app/javascript/mastodon/features/compose/redesign/upload.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/upload.tsx
@@ -14,11 +14,10 @@ import { Blurhash } from '@/mastodon/components/blurhash';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
Menu,
- MenuButton,
+ MenuTrigger,
MenuItem,
MenuItemDivider,
MenuList,
- useMenuContext,
} from '@/mastodon/components/menu';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -73,18 +72,18 @@ export const ComposeUpload: React.FC<{
)}
-
-
+
@@ -105,22 +104,17 @@ const ComposeUploadMenu: React.FC<{
const dispatch = useAppDispatch();
const id = attachment.id;
- const { popover } = useMenuContext();
-
const handleEdit = useCallback(() => {
- popover.closeMenu();
dispatch(
openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
);
- }, [dispatch, id, popover]);
+ }, [dispatch, id]);
const handleRearrange = useCallback(() => {
- popover.closeMenu();
dispatch(openModal({ modalType: 'COMPOSER_REARRANGE', modalProps: {} }));
- }, [dispatch, popover]);
+ }, [dispatch]);
const handleDelete = useCallback(() => {
- popover.closeMenu();
dispatch(undoUploadCompose(id));
- }, [dispatch, id, popover]);
+ }, [dispatch, id]);
return (
diff --git a/app/javascript/mastodon/features/compose/redesign/visibility.tsx b/app/javascript/mastodon/features/compose/redesign/visibility.tsx
index 6884acf85d8440..6217ede6a13535 100644
--- a/app/javascript/mastodon/features/compose/redesign/visibility.tsx
+++ b/app/javascript/mastodon/features/compose/redesign/visibility.tsx
@@ -17,17 +17,17 @@ import {
import { openModal } from '@/mastodon/actions/modal';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
+import { CaretIcon } from '@/mastodon/components/button/redesign';
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
import {
Menu,
MenuList,
- MenuButton,
+ MenuTrigger,
MenuItemDivider,
MenuItemGroup,
MenuItem,
MenuItemRadio,
MenuItemCheckbox,
- useMenuContext,
} from '@/mastodon/components/menu';
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
@@ -47,9 +47,9 @@ export const ComposeVisibility: React.FC<{ className?: string }> = ({
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
/>
-
+
-
+
{privacy !== 'direct' ? (
@@ -155,12 +155,10 @@ const ComposeVisibilityMenu: React.FC = () => {
[defaultQuotePolicy, dispatch],
);
- const { popover } = useMenuContext();
const handleSwitchToMessage: React.MouseEventHandler =
useCallback(() => {
- popover.closeMenu();
dispatch(changeComposeVisibility('direct'));
- }, [dispatch, popover]);
+ }, [dispatch]);
return (
@@ -177,6 +175,7 @@ const ComposeVisibilityMenu: React.FC = () => {
value='public'
checked={privacy === 'public' || privacy === 'unlisted'}
onChange={handlePrivacyChange}
+ keepMenuOpenOnClick
>
@@ -186,6 +185,7 @@ const ComposeVisibilityMenu: React.FC = () => {
value='private'
checked={privacy === 'private'}
onChange={handlePrivacyChange}
+ keepMenuOpenOnClick
>
{
checked={privacy === 'public'}
onChange={handlePrivacyChange}
icon={MagnifyingGlassIcon}
+ keepMenuOpenOnClick
>
{
checked={quotePolicy !== 'nobody' && privacy !== 'private'}
onChange={handleQuotePolicyChange}
icon={QuotesIcon}
+ keepMenuOpenOnClick
>
{
value='public'
checked={quotePolicy === 'public'}
onChange={handleQuotePolicyChange}
+ keepMenuOpenOnClick
>
{
value='followers'
checked={quotePolicy === 'followers'}
onChange={handleQuotePolicyChange}
+ keepMenuOpenOnClick
>
{
const ComposeDirectMenu: React.FC = () => {
const dispatch = useAppDispatch();
- const { popover } = useMenuContext();
const handleSwitchToPost: React.MouseEventHandler =
useCallback(() => {
dispatch(
openModal({ modalType: 'COMPOSER_SWITCH_TO_POST', modalProps: {} }),
);
- popover.closeMenu();
- }, [dispatch, popover]);
+ }, [dispatch]);
return (
diff --git a/app/javascript/mastodon/features/custom_homepage/styles.module.scss b/app/javascript/mastodon/features/custom_homepage/styles.module.scss
index 856f44b0e87aa5..3ca7cc3c1bb29b 100644
--- a/app/javascript/mastodon/features/custom_homepage/styles.module.scss
+++ b/app/javascript/mastodon/features/custom_homepage/styles.module.scss
@@ -1,3 +1,5 @@
+@use '@/styles/mastodon/mixins';
+
.page {
border-radius: 16px;
border: 1px solid var(--color-border-primary);
@@ -42,12 +44,10 @@
}
p {
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
+ @include mixins.line-clamp(2);
+
font-size: 16px;
line-height: 24px;
- text-overflow: ellipsis;
}
}
diff --git a/app/javascript/mastodon/features/explore/components/card.tsx b/app/javascript/mastodon/features/explore/components/card.tsx
index 9cf128100ec5cd..a0e28869aee786 100644
--- a/app/javascript/mastodon/features/explore/components/card.tsx
+++ b/app/javascript/mastodon/features/explore/components/card.tsx
@@ -116,7 +116,7 @@ export const Card: React.FC<{ id: string; source: SuggestionSource }> = ({
title={intl.formatMessage(messages.dismiss)}
className='explore-suggestions-card__dismiss-button'
/>
-
+
diff --git a/app/javascript/mastodon/features/followed_tags/index.tsx b/app/javascript/mastodon/features/followed_tags/index.tsx
index b2a4338c1e5c76..045e05c5ff7624 100644
--- a/app/javascript/mastodon/features/followed_tags/index.tsx
+++ b/app/javascript/mastodon/features/followed_tags/index.tsx
@@ -20,7 +20,7 @@ import ScrollableList from 'mastodon/components/scrollable_list';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
- heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+ heading: { id: 'followed_tags', defaultMessage: 'Followed Hashtags' },
});
const FollowedTag: React.FC<{
diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx
index 2d849b46ffb6cb..86634dc533455c 100644
--- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx
+++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx
@@ -158,7 +158,7 @@ const Card: React.FC<{
)}
-