From 729ed3dd7d476884f76eee4ff324cc875204f9c8 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 8 Oct 2025 12:33:17 +0200 Subject: [PATCH] Message Input: Fix focus delay in attachment modal (#6273) --- src/components/common/pickers/ItemPicker.tsx | 16 ++++--------- src/components/common/pickers/PeerPicker.tsx | 16 ++++--------- src/components/middle/HeaderActions.tsx | 15 +++++------- .../middle/composer/MessageInput.tsx | 12 ++++------ src/components/middle/search/MiddleSearch.tsx | 3 ++- src/components/payment/CardInput.tsx | 17 +++++++++++--- src/components/payment/PaymentInfo.tsx | 3 +++ src/components/payment/PaymentModal.tsx | 11 +++++---- src/components/payment/ShippingInfo.tsx | 15 ++++++++++-- src/hooks/useInputFocusOnOpen.ts | 23 +++++++------------ src/util/focusNoScroll.ts | 5 ++++ 11 files changed, 70 insertions(+), 66 deletions(-) create mode 100644 src/util/focusNoScroll.ts diff --git a/src/components/common/pickers/ItemPicker.tsx b/src/components/common/pickers/ItemPicker.tsx index 68fb6227b..3a0d3473b 100644 --- a/src/components/common/pickers/ItemPicker.tsx +++ b/src/components/common/pickers/ItemPicker.tsx @@ -8,6 +8,7 @@ import { import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; import buildClassName from '../../../util/buildClassName'; +import focusNoScroll from '../../../util/focusNoScroll'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; @@ -69,9 +70,6 @@ type OwnProps = { onLoadMore?: () => void; } & (SingleModeProps | MultipleModeProps); -// Focus slows down animation, also it breaks transition layout in Chrome -const FOCUS_DELAY_MS = 500; - const ITEM_CLASS_NAME = 'ItemPickerItem'; const ItemPicker = ({ @@ -103,15 +101,9 @@ const ItemPicker = ({ useEffect(() => { if (!isSearchable) return undefined; - const timeoutId = window.setTimeout(() => { - requestMeasure(() => { - inputRef.current?.focus(); - }); - }, FOCUS_DELAY_MS); - - return () => { - window.clearTimeout(timeoutId); - }; + requestMeasure(() => { + focusNoScroll(inputRef.current); + }); }, [isSearchable]); const selectedValues = useMemo(() => { diff --git a/src/components/common/pickers/PeerPicker.tsx b/src/components/common/pickers/PeerPicker.tsx index efe0a1085..f9d7ef986 100644 --- a/src/components/common/pickers/PeerPicker.tsx +++ b/src/components/common/pickers/PeerPicker.tsx @@ -12,6 +12,7 @@ import { getGroupStatus, getMainUsername, getUserStatus, isUserOnline } from '.. import { getPeerTypeKey, isApiPeerChat } from '../../../global/helpers/peers'; import { selectPeer, selectUserStatus } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; +import focusNoScroll from '../../../util/focusNoScroll'; import { buildCollectionByKey } from '../../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; @@ -81,9 +82,6 @@ type OwnProps = { onLoadMore?: () => void; } & (SingleModeProps | MultipleModeProps); -// Focus slows down animation, also it breaks transition layout in Chrome -const FOCUS_DELAY_MS = 500; - const MAX_FULL_ITEMS = 10; const ALWAYS_FULL_ITEMS_COUNT = 5; @@ -141,15 +139,9 @@ const PeerPicker = ({ useEffect(() => { if (!isSearchable) return undefined; - const timeoutId = window.setTimeout(() => { - requestMeasure(() => { - inputRef.current?.focus(); - }); - }, FOCUS_DELAY_MS); - - return () => { - window.clearTimeout(timeoutId); - }; + requestMeasure(() => { + focusNoScroll(inputRef.current); + }); }, [isSearchable]); const lockedSelectedIdsSet = useMemo(() => new Set(lockedSelectedIds), [lockedSelectedIds]); diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index ef9c882a2..6926c13e0 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -35,6 +35,7 @@ import { } from '../../global/selectors'; import { ARE_CALLS_SUPPORTED, IS_APP } from '../../util/browser/windowEnvironment'; import { isUserId } from '../../util/entities/ids'; +import focusNoScroll from '../../util/focusNoScroll'; import { useHotkeys } from '../../hooks/useHotkeys'; import useLastCallback from '../../hooks/useLastCallback'; @@ -89,9 +90,6 @@ interface StateProps { isAccountFrozen?: boolean; } -// Chrome breaks layout when focusing input during transition -const SEARCH_FOCUS_DELAY_MS = 320; - const HeaderActions: FC = ({ chatId, threadId, @@ -207,16 +205,13 @@ const HeaderActions: FC = ({ openMiddleSearch(); - if (isMobile) { - // iOS requires synchronous focus on user event. - setFocusInSearchInput(); - } else if (noAnimation) { + if (noAnimation) { // The second RAF is necessary because Teact must update the state and render the async component requestMeasure(() => { requestNextMutation(setFocusInSearchInput); }); } else { - setTimeout(setFocusInSearchInput, SEARCH_FOCUS_DELAY_MS); + setFocusInSearchInput(); } }); @@ -566,5 +561,7 @@ export default memo(withGlobal( function setFocusInSearchInput() { const searchInput = document.querySelector('#MiddleSearch input'); - searchInput?.focus(); + if (searchInput) { + focusNoScroll(searchInput); + } } diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index cae60762a..f542d5875 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -2,7 +2,6 @@ import type { ChangeEvent } from 'react'; import type { ElementRef, FC, TeactNode } from '../../../lib/teact/teact'; import type React from '../../../lib/teact/teact'; import { - getIsHeavyAnimating, memo, useEffect, useLayoutEffect, useRef, useState, } from '../../../lib/teact/teact'; @@ -45,8 +44,6 @@ import TextTimer from '../../ui/TextTimer'; import TextFormatter from './TextFormatter.async'; 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 SCROLLER_CLASS = 'input-scroller'; @@ -248,6 +245,10 @@ const MessageInput: FC = ({ useLayoutEffect(() => { const html = isActive ? getHtml() : ''; + if (!isActive && inputRef.current) { + inputRef.current.blur(); + } + if (html !== inputRef.current!.innerHTML) { inputRef.current!.innerHTML = html; } @@ -270,11 +271,6 @@ const MessageInput: FC = ({ return; } - if (getIsHeavyAnimating()) { - setTimeout(focusInput, FOCUS_DELAY_MS); - return; - } - focusEditableElement(inputRef.current); }); diff --git a/src/components/middle/search/MiddleSearch.tsx b/src/components/middle/search/MiddleSearch.tsx index ded50117f..aa9927ebc 100644 --- a/src/components/middle/search/MiddleSearch.tsx +++ b/src/components/middle/search/MiddleSearch.tsx @@ -35,6 +35,7 @@ import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { getDayStartAt } from '../../../util/dates/dateFormat'; import focusEditableElement from '../../../util/focusEditableElement'; +import focusNoScroll from '../../../util/focusNoScroll'; import { getSearchResultKey, parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { debounce, fastRaf } from '../../../util/schedulers'; @@ -174,7 +175,7 @@ const MiddleSearch: FC = ({ const focusInput = useLastCallback(() => { requestMeasure(() => { - inputRef.current?.focus(); + focusNoScroll(inputRef.current); }); }); diff --git a/src/components/payment/CardInput.tsx b/src/components/payment/CardInput.tsx index 1e61e0b11..36aaecd36 100644 --- a/src/components/payment/CardInput.tsx +++ b/src/components/payment/CardInput.tsx @@ -4,10 +4,12 @@ import { useRef, useState, } 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 { formatCardNumber } from '../middle/helpers/inputFormatters'; -import useFocusAfterAnimation from '../../hooks/useFocusAfterAnimation'; import useOldLang from '../../hooks/useOldLang'; import InputText from '../ui/InputText'; @@ -24,13 +26,22 @@ export type OwnProps = { value: string; error?: string; onChange: (value: string) => void; + isActive?: boolean; }; -const CardInput: FC = ({ value, error, onChange }) => { +const CardInput: FC = ({ value, error, onChange, isActive }) => { const lang = useOldLang(); const cardNumberRef = useRef(); - useFocusAfterAnimation(cardNumberRef); + useEffect(() => { + if (!isActive || IS_TOUCH_ENV) { + return; + } + + requestMeasure(() => { + focusNoScroll(cardNumberRef.current); + }); + }, [isActive]); const [cardType, setCardType] = useState(CardType.Default); useEffect(() => { diff --git a/src/components/payment/PaymentInfo.tsx b/src/components/payment/PaymentInfo.tsx index 9dd1d1d61..c96111d3e 100644 --- a/src/components/payment/PaymentInfo.tsx +++ b/src/components/payment/PaymentInfo.tsx @@ -26,6 +26,7 @@ export type OwnProps = { needZip?: boolean; countryList: ApiCountry[]; dispatch: FormEditDispatch; + isActive?: boolean; }; const PaymentInfo: FC = ({ @@ -36,6 +37,7 @@ const PaymentInfo: FC = ({ needZip, countryList, dispatch, + isActive, }) => { const selectCountryRef = useRef(); @@ -88,6 +90,7 @@ const PaymentInfo: FC = ({ onChange={handleCardNumberChange} value={state.cardNumber} error={formErrors.cardNumber && lang.withRegular(formErrors.cardNumber)} + isActive={isActive} /> {needCardholderName && ( = ({ sendForm(); }, [sendForm]); - function renderModalContent(currentStep: PaymentStep) { + function renderModalContent(currentStep: PaymentStep, isActive?: boolean) { switch (currentStep) { case PaymentStep.Checkout: return ( @@ -328,6 +328,7 @@ const PaymentModal: FC = ({ needCountry={needCountry} needZip={needZip} countryList={countryList} + isActive={isActive} /> ); case PaymentStep.ShippingInfo: @@ -580,9 +581,11 @@ const PaymentModal: FC = ({ shouldCleanup cleanupOnlyKey={PaymentStep.ConfirmPayment} > -
- {renderModalContent(step)} -
+ {(isActive) => ( +
+ {renderModalContent(step, isActive)} +
+ )} ) : (
diff --git a/src/components/payment/ShippingInfo.tsx b/src/components/payment/ShippingInfo.tsx index 9ccff5973..8f5bd4083 100644 --- a/src/components/payment/ShippingInfo.tsx +++ b/src/components/payment/ShippingInfo.tsx @@ -7,7 +7,10 @@ import { import type { ApiCountry } from '../../api/types'; 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 useOldLang from '../../hooks/useOldLang'; @@ -50,7 +53,15 @@ const ShippingInfo: FC = ({ const oldLang = useOldLang(); const lang = useLang(); - useFocusAfterAnimation(inputRef); + useEffect(() => { + if (IS_TOUCH_ENV) { + return; + } + + requestMeasure(() => { + focusNoScroll(inputRef.current); + }); + }, [inputRef]); const handleAddress1Change = useCallback((e) => { dispatch({ type: 'changeAddress1', payload: e.target.value }); diff --git a/src/hooks/useInputFocusOnOpen.ts b/src/hooks/useInputFocusOnOpen.ts index 70f199c49..6a066d38b 100644 --- a/src/hooks/useInputFocusOnOpen.ts +++ b/src/hooks/useInputFocusOnOpen.ts @@ -1,11 +1,10 @@ import type { ElementRef } from '../lib/teact/teact'; import { useEffect } from '../lib/teact/teact'; -import { requestMutation } from '../lib/fasterdom/fasterdom'; -import useAppLayout from './useAppLayout'; +import { requestMeasure } from '../lib/fasterdom/fasterdom'; +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; export default function useInputFocusOnOpen( @@ -13,18 +12,12 @@ export default function useInputFocusOnOpen( isOpen?: boolean, onClose?: NoneToVoidFunction, ) { - const { isMobile } = useAppLayout(); - useEffect(() => { if (isOpen) { - if (!isMobile) { - setTimeout(() => { - requestMutation(() => { - if (inputRef.current?.isConnected) { - inputRef.current.focus(); - } - }); - }, FOCUS_DELAY_MS); + if (!IS_TOUCH_ENV && inputRef.current?.isConnected) { + requestMeasure(() => { + focusNoScroll(inputRef.current); + }); } } else { if (inputRef.current?.isConnected) { @@ -35,5 +28,5 @@ export default function useInputFocusOnOpen( setTimeout(onClose, MODAL_HIDE_DELAY_MS); } } - }, [inputRef, isMobile, isOpen, onClose]); + }, [inputRef, isOpen, onClose]); } diff --git a/src/util/focusNoScroll.ts b/src/util/focusNoScroll.ts new file mode 100644 index 000000000..5fe0578ef --- /dev/null +++ b/src/util/focusNoScroll.ts @@ -0,0 +1,5 @@ +export default function focusNoScroll(element?: HTMLElement) { + if (!element) return; + + element.focus({ preventScroll: true }); +}