Composer redesign: useScrollSensor (#40287)

This commit is contained in:
Echo
2026-08-26 14:53:20 +00:00
committed by GitHub
parent 98d171b8d1
commit 47ac677a9b
9 changed files with 56 additions and 84 deletions

View File

@@ -46,7 +46,7 @@ interface MenuTriggerContextProps {
}
interface MenuListContextProps {
ref: (button: HTMLDivElement | null) => void;
ref: (list: HTMLDivElement | null) => void;
role?: 'menu'; // only for menus of type === 'actions'
tabIndex: -1;
id: string;
@@ -75,7 +75,7 @@ export function useMenuContext(): MenuState {
return context;
}
function getAllMenuItems(menuListElement: HTMLDivElement) {
export function getAllMenuItems(menuListElement: HTMLDivElement) {
return Array.from(
menuListElement.querySelectorAll<HTMLElement>(
':scope [data-menu-item]:not([disabled])',
@@ -91,9 +91,14 @@ interface MenuProps {
*/
type?: MenuType;
children: React.ReactNode;
noFocus?: boolean;
}
export const Menu: React.FC<MenuProps> = ({ type = 'actions', children }) => {
export const Menu: React.FC<MenuProps> = ({
type = 'actions',
children,
noFocus,
}) => {
const id = useId();
const triggerId = `${id}-trigger`;
const listId = `${id}-list`;
@@ -105,13 +110,13 @@ export const Menu: React.FC<MenuProps> = ({ type = 'actions', children }) => {
(element: HTMLDivElement | null) => {
setListElement(element);
if (element && type === 'actions') {
if (element && type === 'actions' && !noFocus) {
const menuItems = getAllMenuItems(element);
const elementToFocus = menuItems[0] ?? element;
elementToFocus.focus();
}
},
[type],
[noFocus, type],
);
const [isMenuOpen, setIsMenuOpen] = useState(false);

View File

@@ -10,7 +10,9 @@ import {
} from './selectors';
import { ComposeUpload } from './upload';
export const ComposeAttachments: React.FC = () => {
export const ComposeAttachments: React.FC<{ className?: string }> = ({
className,
}) => {
const { hasPoll, hasAttachments, quotedStatusId } = useAppSelector(
selectComposeHasAttachments,
);
@@ -20,11 +22,11 @@ export const ComposeAttachments: React.FC = () => {
}
return (
<>
<div className={className}>
{hasPoll && <ComposePoll />}
{hasAttachments && <ComposeMediaAttachments />}
{quotedStatusId && <ComposeQuotedStatus id={quotedStatusId} />}
</>
</div>
);
};

View File

@@ -64,7 +64,8 @@ export const ComposeHints = () => {
);
}
if (isDifferentLanguage) {
const { wasDismissed } = useDismissible('compose_language_hint');
if (isDifferentLanguage && !wasDismissed) {
messages.push(<LanguageHint guess={guess} key='different-language' />);
}

View File

@@ -11,7 +11,9 @@ export function useLanguages() {
}
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(() => {

View File

@@ -6,7 +6,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
import { useDebouncedCallback } from 'use-debounce';
import {
changeComposeSpoilerness,
@@ -18,7 +17,7 @@ import {
TextInputField,
} from '@/mastodon/components/form_fields/redesign';
import { Icon } from '@/mastodon/components/icon';
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
import { useScrollSensor } from '@/mastodon/hooks/useScrollSensor';
import {
focusComposerTextarea,
getComposerTextarea,
@@ -69,18 +68,17 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
const type = useAppSelector(selectComposeType);
const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive);
const {
onSensitiveChange,
onSensitiveTextChange,
onEmojiPick,
onSubmit,
onWrapperMount,
onWrapperScroll,
} = useComposeHandlers(redirectOnSuccess);
const { onSensitiveChange, onSensitiveTextChange, onEmojiPick, onSubmit } =
useComposeHandlers(redirectOnSuccess);
const intl = useIntl();
const titleId = useId();
const { sensor, isInViewport } = useScrollSensor({
placement: 'bottom',
tolerance: 10,
});
return (
<form
role='dialog'
@@ -127,18 +125,16 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
/>
)}
<div
ref={onWrapperMount}
onScroll={onWrapperScroll}
className={classes.editorWrapper}
>
<div data-scroll-down={!isInViewport} className={classes.editorWrapper}>
<ComposeTextarea
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
onSubmit={onSubmit}
/>
<ComposeAttachments />
<ComposeAttachments className={classes.attachments} />
{sensor}
</div>
<ComposeHints />
@@ -209,49 +205,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<HTMLElement> = 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<HTMLElement>) {
if (event.target instanceof HTMLElement) {
wrapperUpdate(event.target);
}
}

View File

@@ -86,7 +86,6 @@
flex-grow: 1;
display: flex;
flex-direction: column;
gap: var(--space-md);
scrollbar-width: thin;
overscroll-behavior-y: contain;
position: relative;
@@ -102,13 +101,13 @@
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;
}
}
@@ -138,6 +137,10 @@
}
}
.attachments {
margin-top: var(--space-md);
}
.footer {
display: flex;
align-items: center;

View File

@@ -119,21 +119,15 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
},
[onSubmit],
);
const onPaste: React.ClipboardEventHandler = useCallback(
(event) => {
if (event.clipboardData.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.clipboardData));
},
[dispatch],
);
const onDrop: React.DragEventHandler = useCallback(
(event) => {
if (event.dataTransfer.files.length === 1) {
event.preventDefault();
}
dispatch(processPasteOrDrop(event.dataTransfer));
dispatch(processPasteOrDrop(data));
},
[dispatch],
);
@@ -178,8 +172,8 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onKeyDown={onKeyDown}
onDrop={onDrop}
onPaste={onPaste}
onDrop={onPasteOrDrop}
onPaste={onPasteOrDrop}
onChange={onChange}
/>
</div>

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react';
export function useMergedRefs<T>(...refs: React.Ref<T>[]) {
export function useMergedRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
const setRef: React.RefCallback<T> = useCallback(
(node: T | null) => {
const cleanups: (() => void)[] = [];

View File

@@ -0,0 +1,8 @@
/**
* Returns the input if it is a string, otherwise returns undefined.
* @param input Any input.
* @returns The string, or undefined.
*/
export function stringOrUndefined(input: unknown) {
return typeof input === 'string' ? input : undefined;
}