From 6c438e67a47922fbd0b3ee82a1e05e8c175d8e67 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Wed, 1 Oct 2025 11:11:55 +0300 Subject: [PATCH 1/9] =?UTF-8?q?feat(code-input):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=BE=D0=BA=D1=83=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=BF=D1=83=D1=82=20=D0=BF=D1=80=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B5=20=D0=BD=D0=B0=20=D0=BB=D1=8E=D0=B1?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/true-feet-argue.md | 6 ++ packages/code-input/src/component.test.tsx | 87 +++++++++++++++++++ .../components/base-code-input/component.tsx | 23 +++++ packages/confirmation/src/Component.test.tsx | 29 +++++++ 4 files changed, 145 insertions(+) create mode 100644 .changeset/true-feet-argue.md diff --git a/.changeset/true-feet-argue.md b/.changeset/true-feet-argue.md new file mode 100644 index 0000000000..661b2e717c --- /dev/null +++ b/.changeset/true-feet-argue.md @@ -0,0 +1,6 @@ +--- +'@alfalab/core-components': minor +'@alfalab/core-components-code-input': minor +--- + +- Добавлено поведение автоматического фокуса на первый инпут при клике на любое пустое поле diff --git a/packages/code-input/src/component.test.tsx b/packages/code-input/src/component.test.tsx index 73edad4517..691c603650 100644 --- a/packages/code-input/src/component.test.tsx +++ b/packages/code-input/src/component.test.tsx @@ -212,5 +212,92 @@ describe('CodeInput', () => { expect(queryByDisplayValue('2')).not.toBeInTheDocument(); expect(queryByDisplayValue('1')).not.toBeInTheDocument(); }); + + describe('focus first input on empty functionality', () => { + it('should focus first input when clicking on any empty input', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + const thirdInput = inputs[2]; + + await userEvent.click(thirdInput); + await waitFor(() => { + expect(firstInput).toHaveFocus(); + }); + }); + + it('should focus target input when inputs are not all empty', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + const secondInput = inputs[1]; + + await userEvent.type(firstInput, '1'); + await userEvent.click(secondInput); + + expect(secondInput).toHaveFocus(); + }); + + it('should focus first input when clicking on first input', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + + await userEvent.click(firstInput); + + expect(firstInput).toHaveFocus(); + }); + + it('should focus first input after all inputs are cleared', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + const secondInput = inputs[1]; + const thirdInput = inputs[2]; + + await userEvent.type(firstInput, '12'); + + await userEvent.type(secondInput, '{backspace}'); + await userEvent.type(firstInput, '{backspace}'); + + // Ждем достаточно времени чтобы прошло пользовательское взаимодействие + await new Promise((resolve) => setTimeout(resolve, 150)); + await userEvent.click(thirdInput); + + await waitFor(() => { + expect(firstInput).toHaveFocus(); + }); + }); + + it('should not redirect focus during SMS autofill', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const thirdInput = inputs[2]; + const fourthInput = inputs[3]; + + // Симулируем автозаполнение SMS + await userEvent.type(thirdInput, '1234'); + + expect(fourthInput).toHaveFocus(); + expect(inputs[0]).not.toHaveFocus(); + }); + + it('should redirect focus to first input when clicking empty field without autofill', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const thirdInput = inputs[2]; + + await userEvent.click(thirdInput); + await waitFor(() => { + expect(inputs[0]).toHaveFocus(); + }); + }); + }); }); }); diff --git a/packages/code-input/src/components/base-code-input/component.tsx b/packages/code-input/src/components/base-code-input/component.tsx index 206176dda0..56c9b0cd91 100644 --- a/packages/code-input/src/components/base-code-input/component.tsx +++ b/packages/code-input/src/components/base-code-input/component.tsx @@ -53,12 +53,14 @@ export const BaseCodeInput = forwardRef( const [values, setValues] = useState(initialValues.split('')); const clearErrorTimerId = useRef>(); + const programmaticFocusRef = useRef(false); const focusOnInput = (inputRef: RefObject) => { inputRef?.current?.focus(); }; const focus = (index = 0) => { + programmaticFocusRef.current = true; focusOnInput(inputRefs[index]); }; @@ -132,6 +134,7 @@ export const BaseCodeInput = forwardRef( setValues(newValues); if (nextRef?.current) { + programmaticFocusRef.current = true; nextRef.current.focus(); nextRef.current.select(); @@ -225,6 +228,26 @@ export const BaseCodeInput = forwardRef( event.persist(); const target = event.target as HTMLInputElement; + if (programmaticFocusRef.current) { + programmaticFocusRef.current = false; + + requestAnimationFrame(() => { + target?.select(); + }); + + return; + } + + const targetIndex = inputRefs.findIndex((inputRef) => inputRef.current === target); + const allEmpty = values.every((value) => !value); + + if (allEmpty && targetIndex > 0) { + programmaticFocusRef.current = true; + focusOnInput(inputRefs[0]); + + return; + } + /** * В сафари выделение корректно работает только с асинхронным вызовом */ diff --git a/packages/confirmation/src/Component.test.tsx b/packages/confirmation/src/Component.test.tsx index c11399db36..afc12db958 100644 --- a/packages/confirmation/src/Component.test.tsx +++ b/packages/confirmation/src/Component.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConfirmationDesktop, DesktopConfirmationProps } from './desktop'; /** @@ -358,6 +359,34 @@ describe('Confirmation', () => { expect(onChangeState).toHaveBeenCalledWith('INITIAL'); }); }); + + it('should focus first input when clicking on any empty input', async () => { + const { container } = render(); + + const inputs = container.querySelectorAll('input'); + const firstInput = inputs[0] as HTMLInputElement; + const thirdInput = inputs[2] as HTMLInputElement; + + fireEvent.click(thirdInput); + + await waitFor(() => { + expect(firstInput).toHaveFocus(); + }); + }); + + it('should focus target input when inputs are not all empty', async () => { + const { container } = render(); + + const inputs = container.querySelectorAll('input'); + const firstInput = inputs[0] as HTMLInputElement; + const secondInput = inputs[1] as HTMLInputElement; + + await userEvent.type(firstInput, '1'); + + await userEvent.click(secondInput); + + expect(secondInput).toHaveFocus(); + }); }); describe('Classes tests', () => { From 3ef993bd1cc5b9fefda4360399e34f70d7c123f2 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Tue, 25 Nov 2025 00:56:04 +0300 Subject: [PATCH 2/9] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BF=D1=80=D0=BE=D0=BF=20`restrictFocus`=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/true-feet-argue.md | 13 +- packages/code-input/src/component.test.tsx | 159 ++++++++++++++++++ .../components/base-code-input/component.tsx | 52 ++++-- .../code-input/src/docs/Component.stories.tsx | 3 + packages/code-input/src/typings.ts | 10 +- packages/code-input/src/utils.ts | 87 ++++++++++ .../base-confirmation/component.tsx | 2 + .../components/screens/initial/component.tsx | 2 + packages/confirmation/src/context.ts | 1 + packages/confirmation/src/types.ts | 7 + 10 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 packages/code-input/src/utils.ts diff --git a/.changeset/true-feet-argue.md b/.changeset/true-feet-argue.md index 661b2e717c..900786dcb0 100644 --- a/.changeset/true-feet-argue.md +++ b/.changeset/true-feet-argue.md @@ -1,6 +1,17 @@ --- '@alfalab/core-components': minor '@alfalab/core-components-code-input': minor +'@alfalab/core-components-confirmation': minor --- -- Добавлено поведение автоматического фокуса на первый инпут при клике на любое пустое поле +##### BaseCodeInput + +- Добавлен проп `restrictFocus` для включения последовательного ввода: + - при клике на ячейку правее первой пустой — фокус остается на первой + - фокус разрешается только на уже заполненные ячейки и первую пустую ячейку + +- Добавлено поведение автоматического фокуса на первый инпут при клике на любое пустое поле + +##### Confirmation + +- Добавлена поддержка пропа `restrictFocus` для использования в `CodeInput` diff --git a/packages/code-input/src/component.test.tsx b/packages/code-input/src/component.test.tsx index 691c603650..ad86145810 100644 --- a/packages/code-input/src/component.test.tsx +++ b/packages/code-input/src/component.test.tsx @@ -9,6 +9,18 @@ const getInputs = (container: HTMLElement) => container.querySelectorAll('input' const getInput = (container: HTMLElement, index: number) => getInputs(container)[index]; +const fillByArray = async (container: HTMLElement, values: Array) => { + const inputs = getInputs(container); + + for (let i = 0; i < values.length; i += 1) { + const v = values[i]; + + if (typeof v === 'string' && v.length > 0) { + await userEvent.type(inputs[i], v); + } + } +}; + describe('CodeInput', () => { describe('Display tests', () => { it('should display correctly', () => { @@ -299,5 +311,152 @@ describe('CodeInput', () => { }); }); }); + + describe('restrictFocus', () => { + it('redirects focus to next empty when clicking on a later input (state: [1] [] [] [])', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + const secondInput = inputs[1]; + const thirdInput = inputs[2]; + + await userEvent.type(firstInput, '1'); + await userEvent.click(thirdInput); + + expect(secondInput).toHaveFocus(); + }); + + it('redirects focus to first empty when clicking far ahead (state: [1] [2] [] [])', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + const secondInput = inputs[1]; + const thirdInput = inputs[2]; + const fourthInput = inputs[3]; + + await userEvent.type(firstInput, '1'); + await userEvent.type(secondInput, '2'); + + await userEvent.click(fourthInput); + + expect(thirdInput).toHaveFocus(); + }); + + it('keeps focus constrained after deletion (state: [1] [] [] []), clicking 3rd focuses 2nd', async () => { + const { container } = render(); + + const inputs = getInputs(container); + const firstInput = inputs[0]; + const secondInput = inputs[1]; + const thirdInput = inputs[2]; + + await userEvent.type(firstInput, '12'); + await userEvent.type(secondInput, '{backspace}'); + + await userEvent.click(thirdInput); + + expect(secondInput).toHaveFocus(); + }); + + describe('click navigation cases', () => { + it.each([ + { + name: 'state [1] [] [] [], click idx 2 -> focuses idx 1', + fields: 4, + preset: ['1', undefined, undefined, undefined], + clickIndex: 2, + expectedFocus: 1, + }, + { + name: 'state [1] [2] [] [], click idx 3 -> focuses idx 2', + fields: 4, + preset: ['1', '2', undefined, undefined], + clickIndex: 3, + expectedFocus: 2, + }, + { + name: 'state [1] [2] [] [], click idx 2 -> focuses idx 2 (allowed)', + fields: 4, + preset: ['1', '2', undefined, undefined], + clickIndex: 2, + expectedFocus: 2, + }, + { + name: 'state [1] [2] [3] [], click idx 1 -> focuses idx 2 (last filled)', + fields: 4, + preset: ['1', '2', '3', undefined], + clickIndex: 1, + expectedFocus: 2, + }, + { + name: 'all empty, click idx 2 -> focuses idx 0', + fields: 4, + preset: [undefined, undefined, undefined, undefined], + clickIndex: 2, + expectedFocus: 0, + }, + ])('$name', async ({ fields, preset, clickIndex, expectedFocus }) => { + const { container } = render( + , + ); + + await fillByArray(container, preset); + + const inputs = getInputs(container); + await userEvent.click(inputs[clickIndex]); + + expect(inputs[expectedFocus]).toHaveFocus(); + }); + }); + + it('ArrowRight focuses first empty when restricted', async () => { + const { container } = render(); + + await fillByArray(container, ['1', undefined, undefined, undefined]); + + const inputs = getInputs(container); + inputs[0].focus(); + await userEvent.type(inputs[0], '{arrowright}'); + + expect(inputs[1]).toHaveFocus(); + }); + + describe('deletion restrictions', () => { + it('click on any cell when fully filled focuses last', async () => { + const { container } = render(); + + await fillByArray(container, ['1', '2', '3', '4']); + const inputs = getInputs(container); + + await userEvent.click(inputs[1]); + expect(inputs[3]).toHaveFocus(); + }); + + it('after deleting last, clicks on earlier cells focus last filled only', async () => { + const { container } = render(); + + await fillByArray(container, ['1', '2', '3', '4', '5']); + const inputs = getInputs(container); + + // удаляем последнюю (5) + inputs[4].focus(); + await userEvent.type(inputs[4], '{backspace}'); + + // теперь lastFilled = 3 (индекс 3) + await userEvent.click(inputs[0]); + expect(inputs[3]).toHaveFocus(); + await userEvent.click(inputs[1]); + expect(inputs[3]).toHaveFocus(); + await userEvent.click(inputs[2]); + expect(inputs[3]).toHaveFocus(); + + // разрешаем фокус только на последнюю заполненную (4-ю позицию) + await userEvent.click(inputs[3]); + expect(inputs[3]).toHaveFocus(); + }); + }); + }); }); }); diff --git a/packages/code-input/src/components/base-code-input/component.tsx b/packages/code-input/src/components/base-code-input/component.tsx index 56c9b0cd91..0b6be1f580 100644 --- a/packages/code-input/src/components/base-code-input/component.tsx +++ b/packages/code-input/src/components/base-code-input/component.tsx @@ -17,6 +17,7 @@ import { type CredentialRequestOtpOptions, type CustomInputRef, } from '../../typings'; +import { getFocusRestrictionMeta, resolveRestrictedIndex } from '../../utils'; import { Input, type InputProps } from '..'; import styles from './index.module.css'; @@ -39,6 +40,7 @@ export const BaseCodeInput = forwardRef( onChange, onComplete, stylesInput = {}, + restrictFocus = false, }, ref, ) => { @@ -60,7 +62,6 @@ export const BaseCodeInput = forwardRef( }; const focus = (index = 0) => { - programmaticFocusRef.current = true; focusOnInput(inputRefs[index]); }; @@ -135,7 +136,7 @@ export const BaseCodeInput = forwardRef( if (nextRef?.current) { programmaticFocusRef.current = true; - nextRef.current.focus(); + focusOnInput(nextRef); nextRef.current.select(); } @@ -170,6 +171,7 @@ export const BaseCodeInput = forwardRef( if (values[index]) { newValues[index] = ''; + focusOnInput(curtRef); } else if (prevRef) { newValues[prevIndex] = ''; @@ -185,14 +187,7 @@ export const BaseCodeInput = forwardRef( event.preventDefault(); newValues[index] = ''; - - if (!values[nextIndex]) { - focusOnInput(curtRef); - } - - if (nextRef) { - focusOnInput(nextRef); - } + focusOnInput(curtRef); setValues(newValues); @@ -210,6 +205,22 @@ export const BaseCodeInput = forwardRef( case 'ArrowRight': event.preventDefault(); + if (restrictFocus) { + const meta = getFocusRestrictionMeta({ values, fields }); + const restrictedIdx = resolveRestrictedIndex({ + requestedIndex: nextIndex, + meta, + }); + + const restrictedRef = inputRefs[restrictedIdx]; + + if (restrictedRef) { + focusOnInput(restrictedRef); + } + + break; + } + if (nextRef) { focusOnInput(nextRef); } @@ -239,15 +250,32 @@ export const BaseCodeInput = forwardRef( } const targetIndex = inputRefs.findIndex((inputRef) => inputRef.current === target); - const allEmpty = values.every((value) => !value); + const allEmpty = values.every((value) => !value) || values.length === 0; if (allEmpty && targetIndex > 0) { - programmaticFocusRef.current = true; focusOnInput(inputRefs[0]); return; } + if (restrictFocus) { + const meta = getFocusRestrictionMeta({ values, fields }); + const restrictedIdx = resolveRestrictedIndex({ + requestedIndex: targetIndex, + meta, + }); + + if (restrictedIdx !== targetIndex) { + const restrictedRef = inputRefs[restrictedIdx]; + + if (restrictedRef) { + focusOnInput(restrictedRef); + } + + return; + } + } + /** * В сафари выделение корректно работает только с асинхронным вызовом */ diff --git a/packages/code-input/src/docs/Component.stories.tsx b/packages/code-input/src/docs/Component.stories.tsx index dd12ccb929..df870a31d3 100644 --- a/packages/code-input/src/docs/Component.stories.tsx +++ b/packages/code-input/src/docs/Component.stories.tsx @@ -22,6 +22,7 @@ export const code_input: Story = { disabled={boolean('disabled', false)} error={text('error', '')} initialValues='1234' + restrictFocus={boolean('restrictFocus', false)} /> ); }, @@ -36,6 +37,7 @@ export const code_input_mobile: Story = { disabled={boolean('disabled', false)} error={text('error', '')} initialValues='1234' + restrictFocus={boolean('restrictFocus', false)} /> ); }, @@ -50,6 +52,7 @@ export const code_input_desktop: Story = { disabled={boolean('disabled', false)} error={text('error', '')} initialValues='1234' + restrictFocus={boolean('restrictFocus', false)} /> ); }, diff --git a/packages/code-input/src/typings.ts b/packages/code-input/src/typings.ts index f1eed5fec2..feffc9507a 100644 --- a/packages/code-input/src/typings.ts +++ b/packages/code-input/src/typings.ts @@ -1,6 +1,6 @@ import { type ReactNode } from 'react'; -export type BaseCodeInputProps = { +export interface BaseCodeInputProps { /** * Количество полей */ @@ -11,6 +11,12 @@ export type BaseCodeInputProps = { */ initialValues?: string; + /** + * Ограничение навигации фокусом между ячейками + * @default false + */ + restrictFocus?: boolean; + /** * Заблокированное состояние */ @@ -62,7 +68,7 @@ export type BaseCodeInputProps = { * Основные стили компонента. */ stylesInput?: { [key: string]: string }; -}; +} export type CustomInputRef = { focus: (index?: number) => void; diff --git a/packages/code-input/src/utils.ts b/packages/code-input/src/utils.ts new file mode 100644 index 0000000000..d8b907e6a9 --- /dev/null +++ b/packages/code-input/src/utils.ts @@ -0,0 +1,87 @@ +type NextEmptyIdxPayload = { + values: string[]; + fields: number; +}; + +/** + * Находит индекс следующей пустой ячейки + * @returns индекс первой пустой ячейки, индекс следующей пустой (если ячейки заполнены частично), или -1 если все ячейки заполнены + */ +const getNextEmptyIdx = ({ values, fields }: NextEmptyIdxPayload): number => { + const firstEmptyIdx = values.indexOf(''); + + if (firstEmptyIdx !== -1) { + return firstEmptyIdx; + } + + if (values.length < fields) { + return values.length; + } + + return -1; +}; + +type FocusRestrictionPayload = { + values: string[]; + fields: number; +}; + +type FocusRestrictionMeta = { + /** Индекс ячейки, на которую можно установить фокус */ + focusIdx: number; + /** Индекс последней заполненной ячейки */ + lastFilledIdx: number; + /** Флаг, указывающий что все ячейки заполнены */ + isComplete: boolean; +}; + +/** Получает метаданные для ограничения фокуса на основе текущих значений */ +export const getFocusRestrictionMeta = ({ + values, + fields, +}: FocusRestrictionPayload): FocusRestrictionMeta => { + const nextEmptyIdx = getNextEmptyIdx({ values, fields }); + + if (nextEmptyIdx === -1) { + const lastIndex = fields - 1; + + return { + focusIdx: lastIndex, + lastFilledIdx: lastIndex, + isComplete: true, + }; + } + + return { + focusIdx: nextEmptyIdx, + lastFilledIdx: Math.max(nextEmptyIdx - 1, 0), + isComplete: false, + }; +}; + +type ResolveFocusIndexPayload = { + requestedIndex: number; + meta: FocusRestrictionMeta; +}; + +/** Разрешает допустимый индекс для установки фокуса с учетом ограничений */ +export const resolveRestrictedIndex = ({ + requestedIndex, + meta, +}: ResolveFocusIndexPayload): number => { + const { isComplete, focusIdx, lastFilledIdx } = meta; + + if (isComplete) { + return focusIdx; + } + + if (requestedIndex > focusIdx) { + return focusIdx; + } + + if (requestedIndex < lastFilledIdx) { + return lastFilledIdx; + } + + return requestedIndex; +}; diff --git a/packages/confirmation/src/components/base-confirmation/component.tsx b/packages/confirmation/src/components/base-confirmation/component.tsx index cfd6e6b07a..52b73d3f6a 100644 --- a/packages/confirmation/src/components/base-confirmation/component.tsx +++ b/packages/confirmation/src/components/base-confirmation/component.tsx @@ -45,6 +45,7 @@ export const BaseConfirmation: FC = ({ client, initialScreenHintSlot, errorVisibleDuration, + restrictFocus = false, ...restProps }) => { const [timeLeft, startTimer, stopTimer] = useCountdown(countdownDuration); @@ -110,6 +111,7 @@ export const BaseConfirmation: FC = ({ blockSmsRetry, breakpoint, client, + restrictFocus, onTempBlockFinished, onChangeState, onChangeScreen, diff --git a/packages/confirmation/src/components/screens/initial/component.tsx b/packages/confirmation/src/components/screens/initial/component.tsx index 8fb9f8dba7..568951347c 100644 --- a/packages/confirmation/src/components/screens/initial/component.tsx +++ b/packages/confirmation/src/components/screens/initial/component.tsx @@ -41,6 +41,7 @@ export const Initial: FC = ({ mobile }) => { hideCountdownSection, initialScreenHintSlot, errorVisibleDuration, + restrictFocus, onChangeState, onInputFinished, onChangeScreen, @@ -194,6 +195,7 @@ export const Initial: FC = ({ mobile }) => { error={getCodeInputError()} ref={inputRef} fields={requiredCharAmount} + restrictFocus={restrictFocus} className={cn(styles.containerInput, styles.codeInput)} onComplete={handleInputComplete} onChange={handleInputChange} diff --git a/packages/confirmation/src/context.ts b/packages/confirmation/src/context.ts index ba1f3765c5..fc5b27e145 100644 --- a/packages/confirmation/src/context.ts +++ b/packages/confirmation/src/context.ts @@ -20,6 +20,7 @@ export const ConfirmationContext = createContext({ breakpoint: 1024, client: 'desktop', initialScreenHintSlot: null, + restrictFocus: false, onTempBlockFinished: mockFn, onInputFinished: mockFn, onChangeState: mockFn, diff --git a/packages/confirmation/src/types.ts b/packages/confirmation/src/types.ts index 2f88f6bcd8..c5a98e5d1a 100644 --- a/packages/confirmation/src/types.ts +++ b/packages/confirmation/src/types.ts @@ -139,6 +139,12 @@ export interface ConfirmationProps { * @default 1300 */ errorVisibleDuration?: number; + + /** + * Ограничение навигации фокусом между ячейками + * @default false + */ + restrictFocus?: boolean; } export type TConfirmationContext = Required< @@ -158,6 +164,7 @@ export type TConfirmationContext = Required< | 'onFatalErrorOkButtonClick' | 'tempBlockDuration' | 'hideCountdownSection' + | 'restrictFocus' > > & Pick< From 4c33e0c0769769eaf5c6bf4315756d3156b77739 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Tue, 25 Nov 2025 18:53:23 +0300 Subject: [PATCH 3/9] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=20UX,=20=D0=BF=D1=80=D0=B8=20restrictFocus=20=3D=3D=3D=20true?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B2=D1=81=D0=B5=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code-input/src/component.test.tsx | 23 +++++++++++----------- packages/code-input/src/utils.ts | 19 ++++-------------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/packages/code-input/src/component.test.tsx b/packages/code-input/src/component.test.tsx index ad86145810..2039547b82 100644 --- a/packages/code-input/src/component.test.tsx +++ b/packages/code-input/src/component.test.tsx @@ -384,11 +384,11 @@ describe('CodeInput', () => { expectedFocus: 2, }, { - name: 'state [1] [2] [3] [], click idx 1 -> focuses idx 2 (last filled)', + name: 'state [1] [2] [3] [], click idx 1 -> focuses idx 1 (allowed)', fields: 4, preset: ['1', '2', '3', undefined], clickIndex: 1, - expectedFocus: 2, + expectedFocus: 1, }, { name: 'all empty, click idx 2 -> focuses idx 0', @@ -424,17 +424,19 @@ describe('CodeInput', () => { }); describe('deletion restrictions', () => { - it('click on any cell when fully filled focuses last', async () => { + it('click on any filled cell when fully filled keeps that cell focused', async () => { const { container } = render(); await fillByArray(container, ['1', '2', '3', '4']); const inputs = getInputs(container); await userEvent.click(inputs[1]); + expect(inputs[1]).toHaveFocus(); + await userEvent.click(inputs[3]); expect(inputs[3]).toHaveFocus(); }); - it('after deleting last, clicks on earlier cells focus last filled only', async () => { + it('after deleting last, clicks on filled cells keep their focus and empty fields redirect', async () => { const { container } = render(); await fillByArray(container, ['1', '2', '3', '4', '5']); @@ -444,17 +446,14 @@ describe('CodeInput', () => { inputs[4].focus(); await userEvent.type(inputs[4], '{backspace}'); - // теперь lastFilled = 3 (индекс 3) await userEvent.click(inputs[0]); - expect(inputs[3]).toHaveFocus(); - await userEvent.click(inputs[1]); - expect(inputs[3]).toHaveFocus(); + expect(inputs[0]).toHaveFocus(); await userEvent.click(inputs[2]); - expect(inputs[3]).toHaveFocus(); + expect(inputs[2]).toHaveFocus(); - // разрешаем фокус только на последнюю заполненную (4-ю позицию) - await userEvent.click(inputs[3]); - expect(inputs[3]).toHaveFocus(); + // клик по пустой ячейке (индекс 4) приводит к первой пустой + await userEvent.click(inputs[4]); + expect(inputs[4]).toHaveFocus(); }); }); }); diff --git a/packages/code-input/src/utils.ts b/packages/code-input/src/utils.ts index d8b907e6a9..6a5b1f2c0e 100644 --- a/packages/code-input/src/utils.ts +++ b/packages/code-input/src/utils.ts @@ -29,8 +29,6 @@ type FocusRestrictionPayload = { type FocusRestrictionMeta = { /** Индекс ячейки, на которую можно установить фокус */ focusIdx: number; - /** Индекс последней заполненной ячейки */ - lastFilledIdx: number; /** Флаг, указывающий что все ячейки заполнены */ isComplete: boolean; }; @@ -47,14 +45,12 @@ export const getFocusRestrictionMeta = ({ return { focusIdx: lastIndex, - lastFilledIdx: lastIndex, isComplete: true, }; } return { focusIdx: nextEmptyIdx, - lastFilledIdx: Math.max(nextEmptyIdx - 1, 0), isComplete: false, }; }; @@ -69,19 +65,12 @@ export const resolveRestrictedIndex = ({ requestedIndex, meta, }: ResolveFocusIndexPayload): number => { - const { isComplete, focusIdx, lastFilledIdx } = meta; + const { focusIdx } = meta; + const normalizedIndex = Math.max(requestedIndex, 0); - if (isComplete) { + if (normalizedIndex > focusIdx) { return focusIdx; } - if (requestedIndex > focusIdx) { - return focusIdx; - } - - if (requestedIndex < lastFilledIdx) { - return lastFilledIdx; - } - - return requestedIndex; + return normalizedIndex; }; From fad0ac29b5b75be712a4d08f25a6c3c5f16c73c8 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Thu, 4 Dec 2025 09:56:39 +0300 Subject: [PATCH 4/9] CodeReview --- packages/code-input/src/component.test.tsx | 203 +++++++++--------- .../components/base-code-input/component.tsx | 59 ++--- packages/code-input/src/hooks.ts | 86 ++++++++ 3 files changed, 206 insertions(+), 142 deletions(-) create mode 100644 packages/code-input/src/hooks.ts diff --git a/packages/code-input/src/component.test.tsx b/packages/code-input/src/component.test.tsx index 2039547b82..ae8798eddb 100644 --- a/packages/code-input/src/component.test.tsx +++ b/packages/code-input/src/component.test.tsx @@ -226,47 +226,60 @@ describe('CodeInput', () => { }); describe('focus first input on empty functionality', () => { - it('should focus first input when clicking on any empty input', async () => { - const { container } = render(); + let container: HTMLElement; + let inputs: NodeListOf; + + type TFocusCase = { + name: string; + setup: () => Promise; + clickIndex: number; + expectedFocusIndex: number; + }; + + const focusCases: Array = [ + { + name: 'should focus first input when clicking on any empty input', + setup: async () => { }, + clickIndex: 2, + expectedFocusIndex: 0, + }, + { + name: 'should focus target input when inputs are not all empty', + setup: async () => { + await userEvent.type(inputs[0], '1'); + }, + clickIndex: 1, + expectedFocusIndex: 1, + }, + { + name: 'should focus first input when clicking on first input', + setup: async () => { }, + clickIndex: 0, + expectedFocusIndex: 0, + }, + { + name: 'should redirect focus to first input when clicking empty field without autofill', + setup: async () => { }, + clickIndex: 2, + expectedFocusIndex: 0, + }, + ]; + + beforeEach(() => { + ({ container } = render()); + inputs = getInputs(container); + }); - const inputs = getInputs(container); - const firstInput = inputs[0]; - const thirdInput = inputs[2]; + it.each(focusCases)('$name', async ({ setup, clickIndex, expectedFocusIndex }) => { + await setup(); + await userEvent.click(inputs[clickIndex]); - await userEvent.click(thirdInput); await waitFor(() => { - expect(firstInput).toHaveFocus(); + expect(inputs[expectedFocusIndex]).toHaveFocus(); }); }); - it('should focus target input when inputs are not all empty', async () => { - const { container } = render(); - - const inputs = getInputs(container); - const firstInput = inputs[0]; - const secondInput = inputs[1]; - - await userEvent.type(firstInput, '1'); - await userEvent.click(secondInput); - - expect(secondInput).toHaveFocus(); - }); - - it('should focus first input when clicking on first input', async () => { - const { container } = render(); - - const inputs = getInputs(container); - const firstInput = inputs[0]; - - await userEvent.click(firstInput); - - expect(firstInput).toHaveFocus(); - }); - it('should focus first input after all inputs are cleared', async () => { - const { container } = render(); - - const inputs = getInputs(container); const firstInput = inputs[0]; const secondInput = inputs[1]; const thirdInput = inputs[2]; @@ -286,9 +299,6 @@ describe('CodeInput', () => { }); it('should not redirect focus during SMS autofill', async () => { - const { container } = render(); - - const inputs = getInputs(container); const thirdInput = inputs[2]; const fourthInput = inputs[3]; @@ -298,66 +308,59 @@ describe('CodeInput', () => { expect(fourthInput).toHaveFocus(); expect(inputs[0]).not.toHaveFocus(); }); - - it('should redirect focus to first input when clicking empty field without autofill', async () => { - const { container } = render(); - - const inputs = getInputs(container); - const thirdInput = inputs[2]; - - await userEvent.click(thirdInput); - await waitFor(() => { - expect(inputs[0]).toHaveFocus(); - }); - }); }); describe('restrictFocus', () => { - it('redirects focus to next empty when clicking on a later input (state: [1] [] [] [])', async () => { - const { container } = render(); - - const inputs = getInputs(container); - const firstInput = inputs[0]; - const secondInput = inputs[1]; - const thirdInput = inputs[2]; - - await userEvent.type(firstInput, '1'); - await userEvent.click(thirdInput); - - expect(secondInput).toHaveFocus(); - }); - - it('redirects focus to first empty when clicking far ahead (state: [1] [2] [] [])', async () => { - const { container } = render(); - - const inputs = getInputs(container); - const firstInput = inputs[0]; - const secondInput = inputs[1]; - const thirdInput = inputs[2]; - const fourthInput = inputs[3]; - - await userEvent.type(firstInput, '1'); - await userEvent.type(secondInput, '2'); - - await userEvent.click(fourthInput); - - expect(thirdInput).toHaveFocus(); + let container: HTMLElement; + let inputs: NodeListOf; + + type TRestrictClickCase = { + name: string; + setup: () => Promise; + clickIndex: number; + expectedFocusIndex: number; + }; + + const restrictClickCases: Array = [ + { + name: 'redirects focus to next empty when clicking on a later input (state: [1] [] [] [])', + setup: async () => { + await fillByArray(container, ['1', undefined, undefined, undefined]); + }, + clickIndex: 2, + expectedFocusIndex: 1, + }, + { + name: 'redirects focus to first empty when clicking far ahead (state: [1] [2] [] [])', + setup: async () => { + await fillByArray(container, ['1', '2', undefined, undefined]); + }, + clickIndex: 3, + expectedFocusIndex: 2, + }, + { + name: 'keeps focus constrained after deletion (state: [1] [] [] []), clicking 3rd focuses 2nd', + setup: async () => { + const firstInput = inputs[0]; + const secondInput = inputs[1]; + await userEvent.type(firstInput, '12'); + await userEvent.type(secondInput, '{backspace}'); + }, + clickIndex: 2, + expectedFocusIndex: 1, + }, + ]; + + beforeEach(() => { + ({ container } = render()); + inputs = getInputs(container); }); - it('keeps focus constrained after deletion (state: [1] [] [] []), clicking 3rd focuses 2nd', async () => { - const { container } = render(); - - const inputs = getInputs(container); - const firstInput = inputs[0]; - const secondInput = inputs[1]; - const thirdInput = inputs[2]; - - await userEvent.type(firstInput, '12'); - await userEvent.type(secondInput, '{backspace}'); - - await userEvent.click(thirdInput); + it.each(restrictClickCases)('$name', async ({ setup, clickIndex, expectedFocusIndex }) => { + await setup(); + await userEvent.click(inputs[clickIndex]); - expect(secondInput).toHaveFocus(); + expect(inputs[expectedFocusIndex]).toHaveFocus(); }); describe('click navigation cases', () => { @@ -398,13 +401,11 @@ describe('CodeInput', () => { expectedFocus: 0, }, ])('$name', async ({ fields, preset, clickIndex, expectedFocus }) => { - const { container } = render( - , - ); + ({ container } = render()); + inputs = getInputs(container); await fillByArray(container, preset); - const inputs = getInputs(container); await userEvent.click(inputs[clickIndex]); expect(inputs[expectedFocus]).toHaveFocus(); @@ -412,11 +413,11 @@ describe('CodeInput', () => { }); it('ArrowRight focuses first empty when restricted', async () => { - const { container } = render(); + ({ container } = render()); + inputs = getInputs(container); await fillByArray(container, ['1', undefined, undefined, undefined]); - const inputs = getInputs(container); inputs[0].focus(); await userEvent.type(inputs[0], '{arrowright}'); @@ -425,10 +426,10 @@ describe('CodeInput', () => { describe('deletion restrictions', () => { it('click on any filled cell when fully filled keeps that cell focused', async () => { - const { container } = render(); + ({ container } = render()); + inputs = getInputs(container); await fillByArray(container, ['1', '2', '3', '4']); - const inputs = getInputs(container); await userEvent.click(inputs[1]); expect(inputs[1]).toHaveFocus(); @@ -437,10 +438,10 @@ describe('CodeInput', () => { }); it('after deleting last, clicks on filled cells keep their focus and empty fields redirect', async () => { - const { container } = render(); + ({ container } = render()); + inputs = getInputs(container); await fillByArray(container, ['1', '2', '3', '4', '5']); - const inputs = getInputs(container); // удаляем последнюю (5) inputs[4].focus(); diff --git a/packages/code-input/src/components/base-code-input/component.tsx b/packages/code-input/src/components/base-code-input/component.tsx index 0b6be1f580..4c5f52cdb3 100644 --- a/packages/code-input/src/components/base-code-input/component.tsx +++ b/packages/code-input/src/components/base-code-input/component.tsx @@ -11,13 +11,13 @@ import React, { } from 'react'; import cn from 'classnames'; +import { useFocusRestriction } from '../../hooks'; import { type BaseCodeInputProps, type CredentialOtp, type CredentialRequestOtpOptions, type CustomInputRef, } from '../../typings'; -import { getFocusRestrictionMeta, resolveRestrictedIndex } from '../../utils'; import { Input, type InputProps } from '..'; import styles from './index.module.css'; @@ -61,6 +61,14 @@ export const BaseCodeInput = forwardRef( inputRef?.current?.focus(); }; + const { focusRestrictedInput } = useFocusRestriction({ + restrictFocus, + values, + fields, + inputRefs, + focusOnInput, + }); + const focus = (index = 0) => { focusOnInput(inputRefs[index]); }; @@ -202,30 +210,16 @@ export const BaseCodeInput = forwardRef( } break; - case 'ArrowRight': + case 'ArrowRight': { event.preventDefault(); + const isRestrictedHandled = focusRestrictedInput(nextIndex); - if (restrictFocus) { - const meta = getFocusRestrictionMeta({ values, fields }); - const restrictedIdx = resolveRestrictedIndex({ - requestedIndex: nextIndex, - meta, - }); - - const restrictedRef = inputRefs[restrictedIdx]; - - if (restrictedRef) { - focusOnInput(restrictedRef); - } - - break; - } - - if (nextRef) { + if (!isRestrictedHandled && nextRef) { focusOnInput(nextRef); } break; + } case 'ArrowUp': case 'ArrowDown': event.preventDefault(); @@ -258,30 +252,13 @@ export const BaseCodeInput = forwardRef( return; } - if (restrictFocus) { - const meta = getFocusRestrictionMeta({ values, fields }); - const restrictedIdx = resolveRestrictedIndex({ - requestedIndex: targetIndex, - meta, - }); + const isRestrictedHandled = focusRestrictedInput(targetIndex, { skipEqual: true }); - if (restrictedIdx !== targetIndex) { - const restrictedRef = inputRefs[restrictedIdx]; - - if (restrictedRef) { - focusOnInput(restrictedRef); - } - - return; - } + if (!isRestrictedHandled) { + requestAnimationFrame(() => { + target?.select(); + }); } - - /** - * В сафари выделение корректно работает только с асинхронным вызовом - */ - requestAnimationFrame(() => { - target?.select(); - }); }; const handleErrorAnimationEnd = () => { diff --git a/packages/code-input/src/hooks.ts b/packages/code-input/src/hooks.ts new file mode 100644 index 0000000000..a974d82b20 --- /dev/null +++ b/packages/code-input/src/hooks.ts @@ -0,0 +1,86 @@ +import { type RefObject, useCallback, useMemo } from 'react'; + +import { getFocusRestrictionMeta, resolveRestrictedIndex } from './utils'; + +interface UseFocusRestrictionPayload { + /** Флаг. который активирует ограничение фокуса */ + restrictFocus: boolean; + /** Текущее значение инпута */ + values: string[]; + /** Количество полей */ + fields: number; + /** Ссылки на инпуты */ + inputRefs: Array>; + /** Колбэк для установки фокуса */ + focusOnInput: (inputRef: RefObject) => void; +} + +interface UseFocusRestriction { + focusRestrictedInput: ( + /** Индекс, на который требуется перейти */ + requestedIndex: number, + /** Опции для управления поведением переключения фокуса */ + options?: FocusRestrictedInputOptions, + ) => boolean; +} + +type FocusRestrictedInputOptions = { + /** Не переключать фокус, если запрашиваемый индекс уже является допустимым */ + skipEqual?: boolean; +}; + +/** + * Управляет ограничением фокуса в наборе инпутов кода и предоставляет API + * для безопасного переключения на допустимый индекс. + */ +export const useFocusRestriction = ({ + restrictFocus, + values, + fields, + inputRefs, + focusOnInput, +}: UseFocusRestrictionPayload): UseFocusRestriction => { + const inputRefsLength = inputRefs.length; + + const restrictionMeta = useMemo(() => { + if (!restrictFocus || inputRefsLength === 0) { + return null; + } + + return getFocusRestrictionMeta({ values, fields }); + }, [restrictFocus, inputRefsLength, values, fields]); + + /** + * Переводит фокус на разрешённый инпут, учитывая текущее состояние. + * @returns true — переключение выполнено или запрещено логикой ограничения; false — хук не вмешался. + */ + const focusRestrictedInput = useCallback( + (requestedIndex: number, options?: FocusRestrictedInputOptions) => { + if (!restrictFocus || restrictionMeta === null) { + return false; + } + + const restrictedIdx = resolveRestrictedIndex({ + requestedIndex, + meta: restrictionMeta, + }); + + if (options?.skipEqual && restrictedIdx === requestedIndex) { + return false; + } + + const restrictedRef = inputRefs[restrictedIdx]; + + if (!restrictedRef) { + return false; + } + + focusOnInput(restrictedRef); + + return true; + }, + [restrictFocus, restrictionMeta, inputRefs, focusOnInput], + ); + + return { focusRestrictedInput }; +}; From fd700db9a09e74439ae7693436692775bf33ef2d Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Thu, 4 Dec 2025 10:01:09 +0300 Subject: [PATCH 5/9] fix esLint --- packages/code-input/src/component.test.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/code-input/src/component.test.tsx b/packages/code-input/src/component.test.tsx index ae8798eddb..20dc791f77 100644 --- a/packages/code-input/src/component.test.tsx +++ b/packages/code-input/src/component.test.tsx @@ -239,7 +239,7 @@ describe('CodeInput', () => { const focusCases: Array = [ { name: 'should focus first input when clicking on any empty input', - setup: async () => { }, + setup: async () => {}, clickIndex: 2, expectedFocusIndex: 0, }, @@ -253,13 +253,13 @@ describe('CodeInput', () => { }, { name: 'should focus first input when clicking on first input', - setup: async () => { }, + setup: async () => {}, clickIndex: 0, expectedFocusIndex: 0, }, { name: 'should redirect focus to first input when clicking empty field without autofill', - setup: async () => { }, + setup: async () => {}, clickIndex: 2, expectedFocusIndex: 0, }, @@ -356,12 +356,15 @@ describe('CodeInput', () => { inputs = getInputs(container); }); - it.each(restrictClickCases)('$name', async ({ setup, clickIndex, expectedFocusIndex }) => { - await setup(); - await userEvent.click(inputs[clickIndex]); + it.each(restrictClickCases)( + '$name', + async ({ setup, clickIndex, expectedFocusIndex }) => { + await setup(); + await userEvent.click(inputs[clickIndex]); - expect(inputs[expectedFocusIndex]).toHaveFocus(); - }); + expect(inputs[expectedFocusIndex]).toHaveFocus(); + }, + ); describe('click navigation cases', () => { it.each([ From b9f21bf2c1449ecdfeac4612b998af444742500c Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Thu, 25 Dec 2025 10:25:33 +0300 Subject: [PATCH 6/9] CodeReview --- .../src/__snapshots__/component.test.tsx.snap | 8 ++ .../components/base-code-input/component.tsx | 70 +++++++++++---- .../src/components/input/component.tsx | 1 + packages/code-input/src/hooks.ts | 86 ------------------- packages/code-input/src/utils.ts | 14 ++- .../src/__snapshots__/Component.test.tsx.snap | 25 ++++++ 6 files changed, 92 insertions(+), 112 deletions(-) delete mode 100644 packages/code-input/src/hooks.ts diff --git a/packages/code-input/src/__snapshots__/component.test.tsx.snap b/packages/code-input/src/__snapshots__/component.test.tsx.snap index 1ed14c6fef..0ea94962b6 100644 --- a/packages/code-input/src/__snapshots__/component.test.tsx.snap +++ b/packages/code-input/src/__snapshots__/component.test.tsx.snap @@ -11,6 +11,7 @@ exports[`CodeInput Display tests should display correctly 1`] = ` ( inputRef?.current?.focus(); }; - const { focusRestrictedInput } = useFocusRestriction({ - restrictFocus, - values, - fields, - inputRefs, - focusOnInput, - }); + const focusRestrictedInput = ( + requestedIndex: number, + options?: { + skipEqual?: boolean; + }, + ) => { + if (!restrictFocus) { + return false; + } + + const restrictionMeta = getFocusRestrictionMeta({ values, fields }); + const targetIndex = resolveRestrictedIndex({ requestedIndex, meta: restrictionMeta }); + + if (options?.skipEqual && targetIndex === requestedIndex) { + return false; + } + + const targetRef = inputRefs[targetIndex]; + + if (!targetRef) { + return false; + } + + focusOnInput(targetRef); + + return true; + }; const focus = (index = 0) => { focusOnInput(inputRefs[index]); @@ -231,20 +251,34 @@ export const BaseCodeInput = forwardRef( const handleFocus: FocusEventHandler = (event) => { event.persist(); - const target = event.target as HTMLInputElement; - - if (programmaticFocusRef.current) { - programmaticFocusRef.current = false; + const target = event.currentTarget; + /** + * В сафари выделение корректно работает только с асинхронным вызовом + */ + const scheduleSelect = () => { requestAnimationFrame(() => { target?.select(); }); + }; + + if (programmaticFocusRef.current) { + programmaticFocusRef.current = false; + + scheduleSelect(); + + return; + } + + const targetIndex = Number.parseInt(target?.dataset?.codeInputIndex ?? '', 10); + if (Number.isNaN(targetIndex)) { return; } - const targetIndex = inputRefs.findIndex((inputRef) => inputRef.current === target); - const allEmpty = values.every((value) => !value) || values.length === 0; + const allEmpty = Array.from({ length: fields }, (_, idx) => values[idx] ?? '').every( + (value) => !value, + ); if (allEmpty && targetIndex > 0) { focusOnInput(inputRefs[0]); @@ -254,11 +288,11 @@ export const BaseCodeInput = forwardRef( const isRestrictedHandled = focusRestrictedInput(targetIndex, { skipEqual: true }); - if (!isRestrictedHandled) { - requestAnimationFrame(() => { - target?.select(); - }); + if (isRestrictedHandled) { + return; } + + scheduleSelect(); }; const handleErrorAnimationEnd = () => { diff --git a/packages/code-input/src/components/input/component.tsx b/packages/code-input/src/components/input/component.tsx index 3c6d231c61..3c4a35980d 100644 --- a/packages/code-input/src/components/input/component.tsx +++ b/packages/code-input/src/components/input/component.tsx @@ -62,6 +62,7 @@ export const Input = forwardRef( return ( >; - /** Колбэк для установки фокуса */ - focusOnInput: (inputRef: RefObject) => void; -} - -interface UseFocusRestriction { - focusRestrictedInput: ( - /** Индекс, на который требуется перейти */ - requestedIndex: number, - /** Опции для управления поведением переключения фокуса */ - options?: FocusRestrictedInputOptions, - ) => boolean; -} - -type FocusRestrictedInputOptions = { - /** Не переключать фокус, если запрашиваемый индекс уже является допустимым */ - skipEqual?: boolean; -}; - -/** - * Управляет ограничением фокуса в наборе инпутов кода и предоставляет API - * для безопасного переключения на допустимый индекс. - */ -export const useFocusRestriction = ({ - restrictFocus, - values, - fields, - inputRefs, - focusOnInput, -}: UseFocusRestrictionPayload): UseFocusRestriction => { - const inputRefsLength = inputRefs.length; - - const restrictionMeta = useMemo(() => { - if (!restrictFocus || inputRefsLength === 0) { - return null; - } - - return getFocusRestrictionMeta({ values, fields }); - }, [restrictFocus, inputRefsLength, values, fields]); - - /** - * Переводит фокус на разрешённый инпут, учитывая текущее состояние. - * @returns true — переключение выполнено или запрещено логикой ограничения; false — хук не вмешался. - */ - const focusRestrictedInput = useCallback( - (requestedIndex: number, options?: FocusRestrictedInputOptions) => { - if (!restrictFocus || restrictionMeta === null) { - return false; - } - - const restrictedIdx = resolveRestrictedIndex({ - requestedIndex, - meta: restrictionMeta, - }); - - if (options?.skipEqual && restrictedIdx === requestedIndex) { - return false; - } - - const restrictedRef = inputRefs[restrictedIdx]; - - if (!restrictedRef) { - return false; - } - - focusOnInput(restrictedRef); - - return true; - }, - [restrictFocus, restrictionMeta, inputRefs, focusOnInput], - ); - - return { focusRestrictedInput }; -}; diff --git a/packages/code-input/src/utils.ts b/packages/code-input/src/utils.ts index 6a5b1f2c0e..f9f9049f0d 100644 --- a/packages/code-input/src/utils.ts +++ b/packages/code-input/src/utils.ts @@ -8,14 +8,12 @@ type NextEmptyIdxPayload = { * @returns индекс первой пустой ячейки, индекс следующей пустой (если ячейки заполнены частично), или -1 если все ячейки заполнены */ const getNextEmptyIdx = ({ values, fields }: NextEmptyIdxPayload): number => { - const firstEmptyIdx = values.indexOf(''); + for (let idx = 0; idx < fields; idx += 1) { + const value = values[idx]; - if (firstEmptyIdx !== -1) { - return firstEmptyIdx; - } - - if (values.length < fields) { - return values.length; + if (!value) { + return idx; + } } return -1; @@ -41,7 +39,7 @@ export const getFocusRestrictionMeta = ({ const nextEmptyIdx = getNextEmptyIdx({ values, fields }); if (nextEmptyIdx === -1) { - const lastIndex = fields - 1; + const lastIndex = Math.max(fields - 1, 0); return { focusIdx: lastIndex, diff --git a/packages/confirmation/src/__snapshots__/Component.test.tsx.snap b/packages/confirmation/src/__snapshots__/Component.test.tsx.snap index a2a438df6b..f8acb4256b 100644 --- a/packages/confirmation/src/__snapshots__/Component.test.tsx.snap +++ b/packages/confirmation/src/__snapshots__/Component.test.tsx.snap @@ -22,6 +22,7 @@ exports[`Confirmation Snapshot tests should match snapshot 1`] = ` Date: Tue, 30 Dec 2025 11:44:56 +0300 Subject: [PATCH 7/9] CodeReview --- packages/code-input/src/component.test.tsx | 11 ++ .../components/base-code-input/component.tsx | 103 ++++++++---------- .../src/components/base-code-input/utils.ts | 31 ++++++ .../src/components/input/component.tsx | 16 +-- packages/code-input/src/utils.ts | 74 ------------- 5 files changed, 89 insertions(+), 146 deletions(-) create mode 100644 packages/code-input/src/components/base-code-input/utils.ts delete mode 100644 packages/code-input/src/utils.ts diff --git a/packages/code-input/src/component.test.tsx b/packages/code-input/src/component.test.tsx index 20dc791f77..9ec14b41db 100644 --- a/packages/code-input/src/component.test.tsx +++ b/packages/code-input/src/component.test.tsx @@ -366,6 +366,17 @@ describe('CodeInput', () => { }, ); + it('ArrowRight does not move focus when restrictFocus is false and first value is empty', async () => { + ({ container } = render()); + inputs = getInputs(container); + + inputs[0].focus(); + await userEvent.type(inputs[0], '{arrowright}'); + + expect(inputs[0]).toHaveFocus(); + expect(inputs[1]).not.toHaveFocus(); + }); + describe('click navigation cases', () => { it.each([ { diff --git a/packages/code-input/src/components/base-code-input/component.tsx b/packages/code-input/src/components/base-code-input/component.tsx index 118d42ec99..ddfff0b887 100644 --- a/packages/code-input/src/components/base-code-input/component.tsx +++ b/packages/code-input/src/components/base-code-input/component.tsx @@ -2,6 +2,7 @@ import React, { createRef, type FocusEventHandler, forwardRef, + type MouseEventHandler, type RefObject, useEffect, useImperativeHandle, @@ -17,9 +18,10 @@ import { type CredentialRequestOtpOptions, type CustomInputRef, } from '../../typings'; -import { getFocusRestrictionMeta, resolveRestrictedIndex } from '../../utils'; import { Input, type InputProps } from '..'; +import { clampFocusIndex, syncSelection } from './utils'; + import styles from './index.module.css'; /** После истечения этого времени код очищается */ @@ -61,32 +63,36 @@ export const BaseCodeInput = forwardRef( inputRef?.current?.focus(); }; - const focusRestrictedInput = ( - requestedIndex: number, - options?: { - skipEqual?: boolean; - }, - ) => { - if (!restrictFocus) { - return false; + const handleClick: MouseEventHandler = (event) => { + const target = event.currentTarget; + + if (document.activeElement !== target) { + return; } - const restrictionMeta = getFocusRestrictionMeta({ values, fields }); - const targetIndex = resolveRestrictedIndex({ requestedIndex, meta: restrictionMeta }); + syncSelection(target); + }; - if (options?.skipEqual && targetIndex === requestedIndex) { - return false; - } + const handleMouseDown: MouseEventHandler = (event) => { + const requestedIndex = Number( + (event.target as Element | null)?.closest( + 'input[data-code-input-index]', + )?.dataset.codeInputIndex, + ); - const targetRef = inputRefs[targetIndex]; + if (Number.isNaN(requestedIndex)) return; - if (!targetRef) { - return false; - } + const shouldRedirectToFirst = requestedIndex > 0 && values.every((value) => !value); + let targetIndex = requestedIndex; + + if (restrictFocus) targetIndex = clampFocusIndex({ values, fields, requestedIndex }); - focusOnInput(targetRef); + if (shouldRedirectToFirst) targetIndex = 0; - return true; + if (targetIndex === requestedIndex) return; + + event.preventDefault(); + focusOnInput(inputRefs[targetIndex]); }; const focus = (index = 0) => { @@ -188,7 +194,6 @@ export const BaseCodeInput = forwardRef( const nextIndex = index + 1; const prevRef = inputRefs[prevIndex]; - const nextRef = inputRefs[nextIndex]; const curtRef = inputRefs[index]; const newValues = [...values]; @@ -232,12 +237,21 @@ export const BaseCodeInput = forwardRef( break; case 'ArrowRight': { event.preventDefault(); - const isRestrictedHandled = focusRestrictedInput(nextIndex); - if (!isRestrictedHandled && nextRef) { - focusOnInput(nextRef); + if (!restrictFocus && index === 0 && !values[0]) { + break; } + const targetIndex = restrictFocus + ? clampFocusIndex({ + values, + fields, + requestedIndex: nextIndex, + }) + : nextIndex; + + focusOnInput(inputRefs[targetIndex]); + break; } case 'ArrowUp': @@ -253,46 +267,15 @@ export const BaseCodeInput = forwardRef( event.persist(); const target = event.currentTarget; - /** - * В сафари выделение корректно работает только с асинхронным вызовом - */ - const scheduleSelect = () => { - requestAnimationFrame(() => { - target?.select(); - }); - }; - if (programmaticFocusRef.current) { programmaticFocusRef.current = false; - scheduleSelect(); - - return; - } - - const targetIndex = Number.parseInt(target?.dataset?.codeInputIndex ?? '', 10); - - if (Number.isNaN(targetIndex)) { - return; - } - - const allEmpty = Array.from({ length: fields }, (_, idx) => values[idx] ?? '').every( - (value) => !value, - ); - - if (allEmpty && targetIndex > 0) { - focusOnInput(inputRefs[0]); - - return; - } - - const isRestrictedHandled = focusRestrictedInput(targetIndex, { skipEqual: true }); + syncSelection(target); - if (isRestrictedHandled) { return; } - scheduleSelect(); + syncSelection(target); }; const handleErrorAnimationEnd = () => { @@ -353,7 +336,10 @@ export const BaseCodeInput = forwardRef( data-test-id={dataTestId} onAnimationEnd={handleErrorAnimationEnd} > -
+
{/* eslint-disable react/no-array-index-key */} {new Array(fields).fill('').map((_, index) => ( ( disabled={disabled} error={!!error} onChange={handleChangeFromEvent} + onClick={handleClick} onFocus={handleFocus} onKeyDown={handleKeyDown} stylesInput={stylesInput} diff --git a/packages/code-input/src/components/base-code-input/utils.ts b/packages/code-input/src/components/base-code-input/utils.ts new file mode 100644 index 0000000000..506dcf8a59 --- /dev/null +++ b/packages/code-input/src/components/base-code-input/utils.ts @@ -0,0 +1,31 @@ +type ClampFocusIndexPayload = { + values: string[]; + fields: number; + requestedIndex: number; +}; + +/** + * Возвращает индекс, на который разрешено поставить фокус при включенном `restrictFocus` + */ +export const clampFocusIndex = ({ + values, + fields, + requestedIndex, +}: ClampFocusIndexPayload): number => { + const emptyIdx = values.indexOf(''); + const focusIdx = emptyIdx >= 0 ? emptyIdx : Math.min(values.length, fields - 1); + + return Math.min(requestedIndex, focusIdx); +}; + +/** + * Синхронизирует выделение текста в инпуте после фокуса/клика. + * В Safari корректное выделение работает только при асинхронном вызове + */ +export const syncSelection = (target: HTMLInputElement) => { + requestAnimationFrame(() => { + if (document.activeElement === target) { + target.select(); + } + }); +}; diff --git a/packages/code-input/src/components/input/component.tsx b/packages/code-input/src/components/input/component.tsx index 3c4a35980d..8772ec2617 100644 --- a/packages/code-input/src/components/input/component.tsx +++ b/packages/code-input/src/components/input/component.tsx @@ -5,7 +5,6 @@ import React, { type InputHTMLAttributes, type KeyboardEvent, type KeyboardEventHandler, - type MouseEventHandler, } from 'react'; import cn from 'classnames'; @@ -34,6 +33,7 @@ export const Input = forwardRef( compact = false, onChange, onKeyDown, + onClick, onFocus, stylesInput = {}, }, @@ -47,18 +47,6 @@ export const Input = forwardRef( onKeyDown(event, { index }); }; - const handleClick: MouseEventHandler = (event) => { - event.persist(); - const target = event.target as HTMLInputElement; - - /** - * В сафари выделение корректно работает только с асинхронным вызовом - */ - requestAnimationFrame(() => { - target?.select(); - }); - }; - return ( ( onChange={handleChange} onKeyDown={handleKeyDown} onFocus={onFocus} - onClick={handleClick} + onClick={onClick} /> ); }, diff --git a/packages/code-input/src/utils.ts b/packages/code-input/src/utils.ts deleted file mode 100644 index f9f9049f0d..0000000000 --- a/packages/code-input/src/utils.ts +++ /dev/null @@ -1,74 +0,0 @@ -type NextEmptyIdxPayload = { - values: string[]; - fields: number; -}; - -/** - * Находит индекс следующей пустой ячейки - * @returns индекс первой пустой ячейки, индекс следующей пустой (если ячейки заполнены частично), или -1 если все ячейки заполнены - */ -const getNextEmptyIdx = ({ values, fields }: NextEmptyIdxPayload): number => { - for (let idx = 0; idx < fields; idx += 1) { - const value = values[idx]; - - if (!value) { - return idx; - } - } - - return -1; -}; - -type FocusRestrictionPayload = { - values: string[]; - fields: number; -}; - -type FocusRestrictionMeta = { - /** Индекс ячейки, на которую можно установить фокус */ - focusIdx: number; - /** Флаг, указывающий что все ячейки заполнены */ - isComplete: boolean; -}; - -/** Получает метаданные для ограничения фокуса на основе текущих значений */ -export const getFocusRestrictionMeta = ({ - values, - fields, -}: FocusRestrictionPayload): FocusRestrictionMeta => { - const nextEmptyIdx = getNextEmptyIdx({ values, fields }); - - if (nextEmptyIdx === -1) { - const lastIndex = Math.max(fields - 1, 0); - - return { - focusIdx: lastIndex, - isComplete: true, - }; - } - - return { - focusIdx: nextEmptyIdx, - isComplete: false, - }; -}; - -type ResolveFocusIndexPayload = { - requestedIndex: number; - meta: FocusRestrictionMeta; -}; - -/** Разрешает допустимый индекс для установки фокуса с учетом ограничений */ -export const resolveRestrictedIndex = ({ - requestedIndex, - meta, -}: ResolveFocusIndexPayload): number => { - const { focusIdx } = meta; - const normalizedIndex = Math.max(requestedIndex, 0); - - if (normalizedIndex > focusIdx) { - return focusIdx; - } - - return normalizedIndex; -}; From b96f70a8d91eeccb9d592ec87a24121a4a892183 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Thu, 15 Jan 2026 11:57:02 +0300 Subject: [PATCH 8/9] CodeReview --- .changeset/true-feet-argue.md | 6 +- .../src/__snapshots__/Component.test.tsx.snap | 13 -- .../code-input/src/Component.responsive.tsx | 2 +- .../src/__snapshots__/component.test.tsx.snap | 12 ++ packages/code-input/src/component.test.tsx | 28 +-- .../components/base-code-input/component.tsx | 198 ++++++------------ .../src/components/base-code-input/utils.ts | 19 +- .../src/components/input/component.tsx | 77 +++---- .../code-input/src/docs/Component.stories.tsx | 6 +- packages/code-input/src/typings.ts | 2 +- .../src/__snapshots__/Component.test.tsx.snap | 35 ++++ .../base-confirmation/component.tsx | 4 +- .../components/screens/initial/component.tsx | 4 +- packages/confirmation/src/context.ts | 2 +- packages/confirmation/src/types.ts | 4 +- 15 files changed, 179 insertions(+), 233 deletions(-) diff --git a/.changeset/true-feet-argue.md b/.changeset/true-feet-argue.md index 900786dcb0..6639762e0b 100644 --- a/.changeset/true-feet-argue.md +++ b/.changeset/true-feet-argue.md @@ -6,12 +6,14 @@ ##### BaseCodeInput -- Добавлен проп `restrictFocus` для включения последовательного ввода: +- Добавлен проп `strictFocus` для включения последовательного ввода: - при клике на ячейку правее первой пустой — фокус остается на первой - фокус разрешается только на уже заполненные ячейки и первую пустую ячейку - Добавлено поведение автоматического фокуса на первый инпут при клике на любое пустое поле +- Улучшена доступность компонента, добавлены `aria-label`. + ##### Confirmation -- Добавлена поддержка пропа `restrictFocus` для использования в `CodeInput` +- Добавлена поддержка пропа `strictFocus` для использования в `CodeInput` diff --git a/packages/calendar/src/__snapshots__/Component.test.tsx.snap b/packages/calendar/src/__snapshots__/Component.test.tsx.snap index 4a5132d141..6200347b26 100644 --- a/packages/calendar/src/__snapshots__/Component.test.tsx.snap +++ b/packages/calendar/src/__snapshots__/Component.test.tsx.snap @@ -2461,19 +2461,6 @@ exports[`Calendar Display tests should match defaultView="years" snapshot 1`] = 1926 -
diff --git a/packages/code-input/src/Component.responsive.tsx b/packages/code-input/src/Component.responsive.tsx index 8413443acf..579b79e0ba 100644 --- a/packages/code-input/src/Component.responsive.tsx +++ b/packages/code-input/src/Component.responsive.tsx @@ -6,7 +6,7 @@ import { CodeInputDesktop } from './desktop'; import { CodeInputMobile } from './mobile'; import { type BaseCodeInputProps, type CustomInputRef } from './typings'; -export type CodeInputProps = Omit & { +export interface CodeInputProps extends Omit'> { /** * Контрольная точка, с нее начинается desktop версия * @default 1024 diff --git a/packages/code-input/src/__snapshots__/component.test.tsx.snap b/packages/code-input/src/__snapshots__/component.test.tsx.snap index 0ea94962b6..a59b95f0cc 100644 --- a/packages/code-input/src/__snapshots__/component.test.tsx.snap +++ b/packages/code-input/src/__snapshots__/component.test.tsx.snap @@ -3,12 +3,15 @@ exports[`CodeInput Display tests should display correctly 1`] = `
{ let container: HTMLElement; let inputs: NodeListOf; - type TFocusCase = { + type FocusCase = { name: string; setup: () => Promise; clickIndex: number; expectedFocusIndex: number; }; - const focusCases: Array = [ + const focusCases: Array = [ { name: 'should focus first input when clicking on any empty input', setup: async () => {}, @@ -310,18 +310,18 @@ describe('CodeInput', () => { }); }); - describe('restrictFocus', () => { + describe('strictFocus', () => { let container: HTMLElement; let inputs: NodeListOf; - type TRestrictClickCase = { + type StrictClickCase = { name: string; setup: () => Promise; clickIndex: number; expectedFocusIndex: number; }; - const restrictClickCases: Array = [ + const strictClickCases: Array = [ { name: 'redirects focus to next empty when clicking on a later input (state: [1] [] [] [])', setup: async () => { @@ -352,11 +352,11 @@ describe('CodeInput', () => { ]; beforeEach(() => { - ({ container } = render()); + ({ container } = render()); inputs = getInputs(container); }); - it.each(restrictClickCases)( + it.each(strictClickCases)( '$name', async ({ setup, clickIndex, expectedFocusIndex }) => { await setup(); @@ -366,7 +366,7 @@ describe('CodeInput', () => { }, ); - it('ArrowRight does not move focus when restrictFocus is false and first value is empty', async () => { + it('ArrowRight does not move focus when strictFocus is false and first value is empty', async () => { ({ container } = render()); inputs = getInputs(container); @@ -415,7 +415,7 @@ describe('CodeInput', () => { expectedFocus: 0, }, ])('$name', async ({ fields, preset, clickIndex, expectedFocus }) => { - ({ container } = render()); + ({ container } = render()); inputs = getInputs(container); await fillByArray(container, preset); @@ -426,8 +426,8 @@ describe('CodeInput', () => { }); }); - it('ArrowRight focuses first empty when restricted', async () => { - ({ container } = render()); + it('ArrowRight focuses first empty when stricted', async () => { + ({ container } = render()); inputs = getInputs(container); await fillByArray(container, ['1', undefined, undefined, undefined]); @@ -438,9 +438,9 @@ describe('CodeInput', () => { expect(inputs[1]).toHaveFocus(); }); - describe('deletion restrictions', () => { + describe('deletion strictions', () => { it('click on any filled cell when fully filled keeps that cell focused', async () => { - ({ container } = render()); + ({ container } = render()); inputs = getInputs(container); await fillByArray(container, ['1', '2', '3', '4']); @@ -452,7 +452,7 @@ describe('CodeInput', () => { }); it('after deleting last, clicks on filled cells keep their focus and empty fields redirect', async () => { - ({ container } = render()); + ({ container } = render()); inputs = getInputs(container); await fillByArray(container, ['1', '2', '3', '4', '5']); diff --git a/packages/code-input/src/components/base-code-input/component.tsx b/packages/code-input/src/components/base-code-input/component.tsx index ddfff0b887..5541d63670 100644 --- a/packages/code-input/src/components/base-code-input/component.tsx +++ b/packages/code-input/src/components/base-code-input/component.tsx @@ -1,12 +1,8 @@ import React, { - createRef, type FocusEventHandler, forwardRef, - type MouseEventHandler, - type RefObject, useEffect, useImperativeHandle, - useMemo, useRef, useState, } from 'react'; @@ -20,7 +16,7 @@ import { } from '../../typings'; import { Input, type InputProps } from '..'; -import { clampFocusIndex, syncSelection } from './utils'; +import { parseInputIdx, syncSelection } from './utils'; import styles from './index.module.css'; @@ -42,76 +38,38 @@ export const BaseCodeInput = forwardRef( onChange, onComplete, stylesInput = {}, - restrictFocus = false, + strictFocus = false, }, ref, ) => { - const inputRefs = useMemo( - () => - Array(fields) - .fill({}) - .map(() => createRef()), - [fields], - ); + const inputRef = useRef(null); const [values, setValues] = useState(initialValues.split('')); const clearErrorTimerId = useRef>(); - const programmaticFocusRef = useRef(false); - - const focusOnInput = (inputRef: RefObject) => { - inputRef?.current?.focus(); - }; - - const handleClick: MouseEventHandler = (event) => { - const target = event.currentTarget; - - if (document.activeElement !== target) { - return; - } - - syncSelection(target); - }; - - const handleMouseDown: MouseEventHandler = (event) => { - const requestedIndex = Number( - (event.target as Element | null)?.closest( - 'input[data-code-input-index]', - )?.dataset.codeInputIndex, - ); - if (Number.isNaN(requestedIndex)) return; + const getInputs = () => + inputRef.current?.querySelectorAll('input[data-code-input-index]'); - const shouldRedirectToFirst = requestedIndex > 0 && values.every((value) => !value); - let targetIndex = requestedIndex; - - if (restrictFocus) targetIndex = clampFocusIndex({ values, fields, requestedIndex }); - - if (shouldRedirectToFirst) targetIndex = 0; - - if (targetIndex === requestedIndex) return; - - event.preventDefault(); - focusOnInput(inputRefs[targetIndex]); - }; + const inputAt = (index: number) => getInputs()?.[index]; const focus = (index = 0) => { - focusOnInput(inputRefs[index]); + inputAt(index)?.focus(); }; const blur = () => { - const input = document.activeElement; + const input = document.activeElement as HTMLInputElement; - if (input?.tagName === 'INPUT') { - (input as HTMLInputElement).blur(); + if (inputRef.current?.contains(input)) { + input.blur(); } }; const unselect = () => { - const input = document.activeElement; + const input = document.activeElement as HTMLInputElement; - if (input?.tagName === 'INPUT') { - (input as HTMLInputElement).setSelectionRange(0, 0); + if (inputRef.current?.contains(input)) { + input.setSelectionRange(0, 0); } }; @@ -122,11 +80,9 @@ export const BaseCodeInput = forwardRef( useImperativeHandle(ref, () => ({ focus, blur, reset, unselect })); const triggerChange = (argumentValues: string[]) => { - const newValue = (argumentValues || values).join(''); + const newValue = argumentValues.join(''); - if (onChange) { - onChange(newValue); - } + onChange?.(newValue); if (onComplete && newValue.length >= fields) { onComplete(newValue); @@ -140,45 +96,28 @@ export const BaseCodeInput = forwardRef( return; } - let nextRef; - const newValues = [...values]; - if (newValue.length > 1) { - let nextIndex = newValue.length + index - 1; + newValue.split('').forEach((item, i) => { + const cursor = index + i; - if (nextIndex >= fields) { - nextIndex = fields - 1; + if (cursor < fields) { + newValues[cursor] = item; } - - nextRef = inputRefs[nextIndex]; - - newValue.split('').forEach((item, i) => { - const cursor = index + i; - - if (cursor < fields) { - newValues[cursor] = item; - } - }); - } else { - nextRef = inputRefs[index + 1]; - - newValues[index] = newValue; - } + }); setValues(newValues); - if (nextRef?.current) { - programmaticFocusRef.current = true; - focusOnInput(nextRef); + const nextIndex = Math.min(index + newValue.length, fields - 1); - nextRef.current.select(); + if (nextIndex !== index) { + focus(nextIndex); } triggerChange(newValues); }; - const handleChangeFromEvent: InputProps['onChange'] = (event, { index }) => { + const handleChangeFromEvent: InputProps['onChange'] = (event) => { const { target: { value, @@ -186,74 +125,64 @@ export const BaseCodeInput = forwardRef( }, } = event; - handleChange(value, index, valid); + const index = parseInputIdx(event.currentTarget); + + if (index !== null) { + handleChange(value, index, valid); + } }; - const handleKeyDown: InputProps['onKeyDown'] = (event, { index }) => { + const handleKeyDown: InputProps['onKeyDown'] = (event) => { + const index = parseInputIdx(event.currentTarget); + + if (index === null) return; + const prevIndex = index - 1; const nextIndex = index + 1; - const prevRef = inputRefs[prevIndex]; - const curtRef = inputRefs[index]; - const newValues = [...values]; switch (event.key) { - case 'Backspace': + case 'Backspace': { event.preventDefault(); - if (values[index]) { - newValues[index] = ''; - focusOnInput(curtRef); - } else if (prevRef) { - newValues[prevIndex] = ''; - - focusOnInput(prevRef); - } + const targetIndex = newValues[index] ? index : Math.max(prevIndex, 0); + newValues[targetIndex] = ''; setValues(newValues); triggerChange(newValues); + focus(targetIndex); + unselect(); + break; + } case 'Delete': event.preventDefault(); newValues[index] = ''; - focusOnInput(curtRef); setValues(newValues); triggerChange(newValues); + unselect(); break; case 'ArrowLeft': event.preventDefault(); - if (prevRef) { - focusOnInput(prevRef); + if (prevIndex) { + focus(prevIndex); } break; - case 'ArrowRight': { + case 'ArrowRight': event.preventDefault(); - if (!restrictFocus && index === 0 && !values[0]) { - break; - } - - const targetIndex = restrictFocus - ? clampFocusIndex({ - values, - fields, - requestedIndex: nextIndex, - }) - : nextIndex; - - focusOnInput(inputRefs[targetIndex]); + focus(nextIndex); break; - } case 'ArrowUp': case 'ArrowDown': event.preventDefault(); @@ -263,19 +192,22 @@ export const BaseCodeInput = forwardRef( } }; - const handleFocus: FocusEventHandler = (event) => { - event.persist(); - const target = event.currentTarget; + const handleFocus: FocusEventHandler = (e) => { + const index = parseInputIdx(e.currentTarget); - if (programmaticFocusRef.current) { - programmaticFocusRef.current = false; + if (index === null || index >= fields) return; - syncSelection(target); + const inputs = getInputs(); - return; - } + const target = + inputs && + [...inputs].slice(0, index).find((input, i) => !input.value && (strictFocus || !i)); - syncSelection(target); + target?.focus(); + + if (e.currentTarget.value) { + syncSelection(e.currentTarget); + } }; const handleErrorAnimationEnd = () => { @@ -332,29 +264,27 @@ export const BaseCodeInput = forwardRef( return (
-
- {/* eslint-disable react/no-array-index-key */} - {new Array(fields).fill('').map((_, index) => ( +
+ {Array.from({ length: fields }, (_, index) => ( 6} + aria-label={`Код ${index + 1} из ${fields}`} /> ))}
diff --git a/packages/code-input/src/components/base-code-input/utils.ts b/packages/code-input/src/components/base-code-input/utils.ts index 506dcf8a59..6232831814 100644 --- a/packages/code-input/src/components/base-code-input/utils.ts +++ b/packages/code-input/src/components/base-code-input/utils.ts @@ -1,21 +1,10 @@ -type ClampFocusIndexPayload = { - values: string[]; - fields: number; - requestedIndex: number; -}; - /** - * Возвращает индекс, на который разрешено поставить фокус при включенном `restrictFocus` + * Парсит индекс из data-атрибута */ -export const clampFocusIndex = ({ - values, - fields, - requestedIndex, -}: ClampFocusIndexPayload): number => { - const emptyIdx = values.indexOf(''); - const focusIdx = emptyIdx >= 0 ? emptyIdx : Math.min(values.length, fields - 1); +export const parseInputIdx = (el: HTMLElement | null): number | null => { + const idx = Number(el?.dataset?.codeInputIndex); - return Math.min(requestedIndex, focusIdx); + return Number.isNaN(idx) ? null : idx; }; /** diff --git a/packages/code-input/src/components/input/component.tsx b/packages/code-input/src/components/input/component.tsx index 8772ec2617..25d02fca04 100644 --- a/packages/code-input/src/components/input/component.tsx +++ b/packages/code-input/src/components/input/component.tsx @@ -1,27 +1,26 @@ import React, { - type ChangeEvent, type ChangeEventHandler, forwardRef, type InputHTMLAttributes, - type KeyboardEvent, type KeyboardEventHandler, } from 'react'; import cn from 'classnames'; import styles from './index.module.css'; -export type InputProps = Omit< - InputHTMLAttributes, - 'value' | 'onChange' | 'onKeyDown' | 'enterKeyHint' -> & { +export interface InputProps + extends Omit< + InputHTMLAttributes, + 'value' | 'onChange' | 'onKeyDown' | 'enterKeyHint' + > { index: number; value: string; error: boolean; compact?: boolean; - onChange: (event: ChangeEvent, payload: { index: number }) => void; - onKeyDown: (event: KeyboardEvent, payload: { index: number }) => void; + onChange: ChangeEventHandler; + onKeyDown: KeyboardEventHandler; stylesInput?: { [key: string]: string }; -}; +} export const Input = forwardRef( ( @@ -33,44 +32,36 @@ export const Input = forwardRef( compact = false, onChange, onKeyDown, - onClick, onFocus, + onMouseDown, stylesInput = {}, + 'aria-label': ariaLabel, }, ref, - ) => { - const handleChange: ChangeEventHandler = (event) => { - onChange(event, { index }); - }; + ) => ( + = (event) => { - onKeyDown(event, { index }); - }; + [styles.disabled]: disabled, + [stylesInput.disabled]: disabled, - return ( - - ); - }, + [styles.compact]: compact, + [stylesInput.compact]: Boolean(stylesInput.compact) && compact, + })} + disabled={disabled} + value={value} + autoComplete={index === 0 ? 'one-time-code' : ''} + inputMode='numeric' + pattern='[0-9]*' + onChange={onChange} + onKeyDown={onKeyDown} + onFocus={onFocus} + onMouseDown={onMouseDown} + aria-label={ariaLabel} + /> + ), ); diff --git a/packages/code-input/src/docs/Component.stories.tsx b/packages/code-input/src/docs/Component.stories.tsx index df870a31d3..491dacdb9d 100644 --- a/packages/code-input/src/docs/Component.stories.tsx +++ b/packages/code-input/src/docs/Component.stories.tsx @@ -22,7 +22,7 @@ export const code_input: Story = { disabled={boolean('disabled', false)} error={text('error', '')} initialValues='1234' - restrictFocus={boolean('restrictFocus', false)} + strictFocus={boolean('strictFocus', false)} /> ); }, @@ -37,7 +37,7 @@ export const code_input_mobile: Story = { disabled={boolean('disabled', false)} error={text('error', '')} initialValues='1234' - restrictFocus={boolean('restrictFocus', false)} + strictFocus={boolean('strictFocus', false)} /> ); }, @@ -52,7 +52,7 @@ export const code_input_desktop: Story = { disabled={boolean('disabled', false)} error={text('error', '')} initialValues='1234' - restrictFocus={boolean('restrictFocus', false)} + strictFocus={boolean('strictFocus', false)} /> ); }, diff --git a/packages/code-input/src/typings.ts b/packages/code-input/src/typings.ts index feffc9507a..8f2299330f 100644 --- a/packages/code-input/src/typings.ts +++ b/packages/code-input/src/typings.ts @@ -15,7 +15,7 @@ export interface BaseCodeInputProps { * Ограничение навигации фокусом между ячейками * @default false */ - restrictFocus?: boolean; + strictFocus?: boolean; /** * Заблокированное состояние diff --git a/packages/confirmation/src/__snapshots__/Component.test.tsx.snap b/packages/confirmation/src/__snapshots__/Component.test.tsx.snap index f8acb4256b..5a1be64e90 100644 --- a/packages/confirmation/src/__snapshots__/Component.test.tsx.snap +++ b/packages/confirmation/src/__snapshots__/Component.test.tsx.snap @@ -14,12 +14,15 @@ exports[`Confirmation Snapshot tests should match snapshot 1`] = ` Введите код из уведомления
= ({ client, initialScreenHintSlot, errorVisibleDuration, - restrictFocus = false, + strictFocus = false, ...restProps }) => { const [timeLeft, startTimer, stopTimer] = useCountdown(countdownDuration); @@ -111,7 +111,7 @@ export const BaseConfirmation: FC = ({ blockSmsRetry, breakpoint, client, - restrictFocus, + strictFocus, onTempBlockFinished, onChangeState, onChangeScreen, diff --git a/packages/confirmation/src/components/screens/initial/component.tsx b/packages/confirmation/src/components/screens/initial/component.tsx index 568951347c..64f487bc9a 100644 --- a/packages/confirmation/src/components/screens/initial/component.tsx +++ b/packages/confirmation/src/components/screens/initial/component.tsx @@ -41,7 +41,7 @@ export const Initial: FC = ({ mobile }) => { hideCountdownSection, initialScreenHintSlot, errorVisibleDuration, - restrictFocus, + strictFocus, onChangeState, onInputFinished, onChangeScreen, @@ -195,7 +195,7 @@ export const Initial: FC = ({ mobile }) => { error={getCodeInputError()} ref={inputRef} fields={requiredCharAmount} - restrictFocus={restrictFocus} + strictFocus={strictFocus} className={cn(styles.containerInput, styles.codeInput)} onComplete={handleInputComplete} onChange={handleInputChange} diff --git a/packages/confirmation/src/context.ts b/packages/confirmation/src/context.ts index fc5b27e145..c19c638cf7 100644 --- a/packages/confirmation/src/context.ts +++ b/packages/confirmation/src/context.ts @@ -20,7 +20,7 @@ export const ConfirmationContext = createContext({ breakpoint: 1024, client: 'desktop', initialScreenHintSlot: null, - restrictFocus: false, + strictFocus: false, onTempBlockFinished: mockFn, onInputFinished: mockFn, onChangeState: mockFn, diff --git a/packages/confirmation/src/types.ts b/packages/confirmation/src/types.ts index c5a98e5d1a..9f3024d11b 100644 --- a/packages/confirmation/src/types.ts +++ b/packages/confirmation/src/types.ts @@ -144,7 +144,7 @@ export interface ConfirmationProps { * Ограничение навигации фокусом между ячейками * @default false */ - restrictFocus?: boolean; + strictFocus?: boolean; } export type TConfirmationContext = Required< @@ -164,7 +164,7 @@ export type TConfirmationContext = Required< | 'onFatalErrorOkButtonClick' | 'tempBlockDuration' | 'hideCountdownSection' - | 'restrictFocus' + | 'strictFocus' > > & Pick< From dde386850e8f868231c40b504086242174834e52 Mon Sep 17 00:00:00 2001 From: Vadim Kalushko Date: Thu, 15 Jan 2026 12:10:46 +0300 Subject: [PATCH 9/9] fix esLint --- packages/code-input/src/Component.responsive.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-input/src/Component.responsive.tsx b/packages/code-input/src/Component.responsive.tsx index 579b79e0ba..49f4cf091c 100644 --- a/packages/code-input/src/Component.responsive.tsx +++ b/packages/code-input/src/Component.responsive.tsx @@ -23,7 +23,7 @@ export interface CodeInputProps extends Omit'> * @deprecated Используйте client */ defaultMatchMediaValue?: boolean | (() => boolean); -}; +} export const CodeInput = forwardRef( (