Message Input: Fix focus delay in attachment modal (#6273)

This commit is contained in:
Alexander Zinchuk 2025-10-08 12:33:17 +02:00
parent d62cd024a9
commit 729ed3dd7d
11 changed files with 70 additions and 66 deletions

View File

@ -8,6 +8,7 @@ import {
import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; import { requestMeasure } from '../../../lib/fasterdom/fasterdom';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import focusNoScroll from '../../../util/focusNoScroll';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
@ -69,9 +70,6 @@ type OwnProps = {
onLoadMore?: () => void; onLoadMore?: () => void;
} & (SingleModeProps | MultipleModeProps); } & (SingleModeProps | MultipleModeProps);
// Focus slows down animation, also it breaks transition layout in Chrome
const FOCUS_DELAY_MS = 500;
const ITEM_CLASS_NAME = 'ItemPickerItem'; const ITEM_CLASS_NAME = 'ItemPickerItem';
const ItemPicker = ({ const ItemPicker = ({
@ -103,15 +101,9 @@ const ItemPicker = ({
useEffect(() => { useEffect(() => {
if (!isSearchable) return undefined; if (!isSearchable) return undefined;
const timeoutId = window.setTimeout(() => { requestMeasure(() => {
requestMeasure(() => { focusNoScroll(inputRef.current);
inputRef.current?.focus(); });
});
}, FOCUS_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isSearchable]); }, [isSearchable]);
const selectedValues = useMemo(() => { const selectedValues = useMemo(() => {

View File

@ -12,6 +12,7 @@ import { getGroupStatus, getMainUsername, getUserStatus, isUserOnline } from '..
import { getPeerTypeKey, isApiPeerChat } from '../../../global/helpers/peers'; import { getPeerTypeKey, isApiPeerChat } from '../../../global/helpers/peers';
import { selectPeer, selectUserStatus } from '../../../global/selectors'; import { selectPeer, selectUserStatus } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import focusNoScroll from '../../../util/focusNoScroll';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -81,9 +82,6 @@ type OwnProps<CategoryType extends string> = {
onLoadMore?: () => void; onLoadMore?: () => void;
} & (SingleModeProps<CategoryType> | MultipleModeProps<CategoryType>); } & (SingleModeProps<CategoryType> | MultipleModeProps<CategoryType>);
// Focus slows down animation, also it breaks transition layout in Chrome
const FOCUS_DELAY_MS = 500;
const MAX_FULL_ITEMS = 10; const MAX_FULL_ITEMS = 10;
const ALWAYS_FULL_ITEMS_COUNT = 5; const ALWAYS_FULL_ITEMS_COUNT = 5;
@ -141,15 +139,9 @@ const PeerPicker = <CategoryType extends string = CustomPeerType>({
useEffect(() => { useEffect(() => {
if (!isSearchable) return undefined; if (!isSearchable) return undefined;
const timeoutId = window.setTimeout(() => { requestMeasure(() => {
requestMeasure(() => { focusNoScroll(inputRef.current);
inputRef.current?.focus(); });
});
}, FOCUS_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isSearchable]); }, [isSearchable]);
const lockedSelectedIdsSet = useMemo(() => new Set(lockedSelectedIds), [lockedSelectedIds]); const lockedSelectedIdsSet = useMemo(() => new Set(lockedSelectedIds), [lockedSelectedIds]);

View File

@ -35,6 +35,7 @@ import {
} from '../../global/selectors'; } from '../../global/selectors';
import { ARE_CALLS_SUPPORTED, IS_APP } from '../../util/browser/windowEnvironment'; import { ARE_CALLS_SUPPORTED, IS_APP } from '../../util/browser/windowEnvironment';
import { isUserId } from '../../util/entities/ids'; import { isUserId } from '../../util/entities/ids';
import focusNoScroll from '../../util/focusNoScroll';
import { useHotkeys } from '../../hooks/useHotkeys'; import { useHotkeys } from '../../hooks/useHotkeys';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
@ -89,9 +90,6 @@ interface StateProps {
isAccountFrozen?: boolean; isAccountFrozen?: boolean;
} }
// Chrome breaks layout when focusing input during transition
const SEARCH_FOCUS_DELAY_MS = 320;
const HeaderActions: FC<OwnProps & StateProps> = ({ const HeaderActions: FC<OwnProps & StateProps> = ({
chatId, chatId,
threadId, threadId,
@ -207,16 +205,13 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
openMiddleSearch(); openMiddleSearch();
if (isMobile) { if (noAnimation) {
// iOS requires synchronous focus on user event.
setFocusInSearchInput();
} else if (noAnimation) {
// The second RAF is necessary because Teact must update the state and render the async component // The second RAF is necessary because Teact must update the state and render the async component
requestMeasure(() => { requestMeasure(() => {
requestNextMutation(setFocusInSearchInput); requestNextMutation(setFocusInSearchInput);
}); });
} else { } else {
setTimeout(setFocusInSearchInput, SEARCH_FOCUS_DELAY_MS); setFocusInSearchInput();
} }
}); });
@ -566,5 +561,7 @@ export default memo(withGlobal<OwnProps>(
function setFocusInSearchInput() { function setFocusInSearchInput() {
const searchInput = document.querySelector<HTMLInputElement>('#MiddleSearch input'); const searchInput = document.querySelector<HTMLInputElement>('#MiddleSearch input');
searchInput?.focus(); if (searchInput) {
focusNoScroll(searchInput);
}
} }

View File

@ -2,7 +2,6 @@ import type { ChangeEvent } from 'react';
import type { ElementRef, FC, TeactNode } from '../../../lib/teact/teact'; import type { ElementRef, FC, TeactNode } from '../../../lib/teact/teact';
import type React from '../../../lib/teact/teact'; import type React from '../../../lib/teact/teact';
import { import {
getIsHeavyAnimating,
memo, useEffect, useLayoutEffect, memo, useEffect, useLayoutEffect,
useRef, useState, useRef, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
@ -45,8 +44,6 @@ import TextTimer from '../../ui/TextTimer';
import TextFormatter from './TextFormatter.async'; import TextFormatter from './TextFormatter.async';
const CONTEXT_MENU_CLOSE_DELAY_MS = 100; const CONTEXT_MENU_CLOSE_DELAY_MS = 100;
// Focus slows down animation, also it breaks transition layout in Chrome
const FOCUS_DELAY_MS = 350;
const TRANSITION_DURATION_FACTOR = 50; const TRANSITION_DURATION_FACTOR = 50;
const SCROLLER_CLASS = 'input-scroller'; const SCROLLER_CLASS = 'input-scroller';
@ -248,6 +245,10 @@ const MessageInput: FC<OwnProps & StateProps> = ({
useLayoutEffect(() => { useLayoutEffect(() => {
const html = isActive ? getHtml() : ''; const html = isActive ? getHtml() : '';
if (!isActive && inputRef.current) {
inputRef.current.blur();
}
if (html !== inputRef.current!.innerHTML) { if (html !== inputRef.current!.innerHTML) {
inputRef.current!.innerHTML = html; inputRef.current!.innerHTML = html;
} }
@ -270,11 +271,6 @@ const MessageInput: FC<OwnProps & StateProps> = ({
return; return;
} }
if (getIsHeavyAnimating()) {
setTimeout(focusInput, FOCUS_DELAY_MS);
return;
}
focusEditableElement(inputRef.current); focusEditableElement(inputRef.current);
}); });

View File

@ -35,6 +35,7 @@ import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { getDayStartAt } from '../../../util/dates/dateFormat'; import { getDayStartAt } from '../../../util/dates/dateFormat';
import focusEditableElement from '../../../util/focusEditableElement'; import focusEditableElement from '../../../util/focusEditableElement';
import focusNoScroll from '../../../util/focusNoScroll';
import { getSearchResultKey, parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; import { getSearchResultKey, parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { debounce, fastRaf } from '../../../util/schedulers'; import { debounce, fastRaf } from '../../../util/schedulers';
@ -174,7 +175,7 @@ const MiddleSearch: FC<OwnProps & StateProps> = ({
const focusInput = useLastCallback(() => { const focusInput = useLastCallback(() => {
requestMeasure(() => { requestMeasure(() => {
inputRef.current?.focus(); focusNoScroll(inputRef.current);
}); });
}); });

View File

@ -4,10 +4,12 @@ import {
useRef, useState, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import focusNoScroll from '../../util/focusNoScroll';
import { CardType, detectCardType } from '../common/helpers/detectCardType'; import { CardType, detectCardType } from '../common/helpers/detectCardType';
import { formatCardNumber } from '../middle/helpers/inputFormatters'; import { formatCardNumber } from '../middle/helpers/inputFormatters';
import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import InputText from '../ui/InputText'; import InputText from '../ui/InputText';
@ -24,13 +26,22 @@ export type OwnProps = {
value: string; value: string;
error?: string; error?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
isActive?: boolean;
}; };
const CardInput: FC<OwnProps> = ({ value, error, onChange }) => { const CardInput: FC<OwnProps> = ({ value, error, onChange, isActive }) => {
const lang = useOldLang(); const lang = useOldLang();
const cardNumberRef = useRef<HTMLInputElement>(); const cardNumberRef = useRef<HTMLInputElement>();
useFocusAfterAnimation(cardNumberRef); useEffect(() => {
if (!isActive || IS_TOUCH_ENV) {
return;
}
requestMeasure(() => {
focusNoScroll(cardNumberRef.current);
});
}, [isActive]);
const [cardType, setCardType] = useState<number>(CardType.Default); const [cardType, setCardType] = useState<number>(CardType.Default);
useEffect(() => { useEffect(() => {

View File

@ -26,6 +26,7 @@ export type OwnProps = {
needZip?: boolean; needZip?: boolean;
countryList: ApiCountry[]; countryList: ApiCountry[];
dispatch: FormEditDispatch; dispatch: FormEditDispatch;
isActive?: boolean;
}; };
const PaymentInfo: FC<OwnProps> = ({ const PaymentInfo: FC<OwnProps> = ({
@ -36,6 +37,7 @@ const PaymentInfo: FC<OwnProps> = ({
needZip, needZip,
countryList, countryList,
dispatch, dispatch,
isActive,
}) => { }) => {
const selectCountryRef = useRef<HTMLSelectElement>(); const selectCountryRef = useRef<HTMLSelectElement>();
@ -88,6 +90,7 @@ const PaymentInfo: FC<OwnProps> = ({
onChange={handleCardNumberChange} onChange={handleCardNumberChange}
value={state.cardNumber} value={state.cardNumber}
error={formErrors.cardNumber && lang.withRegular(formErrors.cardNumber)} error={formErrors.cardNumber && lang.withRegular(formErrors.cardNumber)}
isActive={isActive}
/> />
{needCardholderName && ( {needCardholderName && (
<InputText <InputText

View File

@ -275,7 +275,7 @@ const PaymentModal: FC<OwnProps & StateProps> = ({
sendForm(); sendForm();
}, [sendForm]); }, [sendForm]);
function renderModalContent(currentStep: PaymentStep) { function renderModalContent(currentStep: PaymentStep, isActive?: boolean) {
switch (currentStep) { switch (currentStep) {
case PaymentStep.Checkout: case PaymentStep.Checkout:
return ( return (
@ -328,6 +328,7 @@ const PaymentModal: FC<OwnProps & StateProps> = ({
needCountry={needCountry} needCountry={needCountry}
needZip={needZip} needZip={needZip}
countryList={countryList} countryList={countryList}
isActive={isActive}
/> />
); );
case PaymentStep.ShippingInfo: case PaymentStep.ShippingInfo:
@ -580,9 +581,11 @@ const PaymentModal: FC<OwnProps & StateProps> = ({
shouldCleanup shouldCleanup
cleanupOnlyKey={PaymentStep.ConfirmPayment} cleanupOnlyKey={PaymentStep.ConfirmPayment}
> >
<div className="content custom-scroll"> {(isActive) => (
{renderModalContent(step)} <div className="content custom-scroll">
</div> {renderModalContent(step, isActive)}
</div>
)}
</Transition> </Transition>
) : ( ) : (
<div className="empty-content"> <div className="empty-content">

View File

@ -7,7 +7,10 @@ import {
import type { ApiCountry } from '../../api/types'; import type { ApiCountry } from '../../api/types';
import type { FormEditDispatch, FormState } from '../../hooks/reducers/usePaymentReducer'; import type { FormEditDispatch, FormState } from '../../hooks/reducers/usePaymentReducer';
import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import { IS_TOUCH_ENV } from '../../util/browser/windowEnvironment';
import focusNoScroll from '../../util/focusNoScroll';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
@ -50,7 +53,15 @@ const ShippingInfo: FC<OwnProps> = ({
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
useFocusAfterAnimation(inputRef); useEffect(() => {
if (IS_TOUCH_ENV) {
return;
}
requestMeasure(() => {
focusNoScroll(inputRef.current);
});
}, [inputRef]);
const handleAddress1Change = useCallback((e) => { const handleAddress1Change = useCallback((e) => {
dispatch({ type: 'changeAddress1', payload: e.target.value }); dispatch({ type: 'changeAddress1', payload: e.target.value });

View File

@ -1,11 +1,10 @@
import type { ElementRef } from '../lib/teact/teact'; import type { ElementRef } from '../lib/teact/teact';
import { useEffect } from '../lib/teact/teact'; import { useEffect } from '../lib/teact/teact';
import { requestMutation } from '../lib/fasterdom/fasterdom'; import { requestMeasure } from '../lib/fasterdom/fasterdom';
import useAppLayout from './useAppLayout'; import { IS_TOUCH_ENV } from '../util/browser/windowEnvironment';
import focusNoScroll from '../util/focusNoScroll';
// Focus slows down animation, also it breaks transition layout in Chrome
const FOCUS_DELAY_MS = 500;
const MODAL_HIDE_DELAY_MS = 300; const MODAL_HIDE_DELAY_MS = 300;
export default function useInputFocusOnOpen( export default function useInputFocusOnOpen(
@ -13,18 +12,12 @@ export default function useInputFocusOnOpen(
isOpen?: boolean, isOpen?: boolean,
onClose?: NoneToVoidFunction, onClose?: NoneToVoidFunction,
) { ) {
const { isMobile } = useAppLayout();
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (!isMobile) { if (!IS_TOUCH_ENV && inputRef.current?.isConnected) {
setTimeout(() => { requestMeasure(() => {
requestMutation(() => { focusNoScroll(inputRef.current);
if (inputRef.current?.isConnected) { });
inputRef.current.focus();
}
});
}, FOCUS_DELAY_MS);
} }
} else { } else {
if (inputRef.current?.isConnected) { if (inputRef.current?.isConnected) {
@ -35,5 +28,5 @@ export default function useInputFocusOnOpen(
setTimeout(onClose, MODAL_HIDE_DELAY_MS); setTimeout(onClose, MODAL_HIDE_DELAY_MS);
} }
} }
}, [inputRef, isMobile, isOpen, onClose]); }, [inputRef, isOpen, onClose]);
} }

View File

@ -0,0 +1,5 @@
export default function focusNoScroll(element?: HTMLElement) {
if (!element) return;
element.focus({ preventScroll: true });
}