From 16af677ba84303705bb709544101ed3fd8c90e97 Mon Sep 17 00:00:00 2001 From: VeronaPl Date: Wed, 26 Nov 2025 21:42:28 +0300 Subject: [PATCH 1/4] fix TextInput deep fragment icons count --- .../stories/InputFieldInput.template.tsx | 20 +++++-- src/components/input/InputEx/index.tsx | 51 ++++++++++++++++-- src/components/input/SuggestInput/index.tsx | 6 ++- src/components/input/TextInput/index.tsx | 53 ++++++++++++++++--- .../stories/TextInputWithIcon.template.tsx | 28 +++++++++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/components/form/InputField/stories/InputFieldInput.template.tsx b/src/components/form/InputField/stories/InputFieldInput.template.tsx index d650cb695..ccc1bef71 100644 --- a/src/components/form/InputField/stories/InputFieldInput.template.tsx +++ b/src/components/form/InputField/stories/InputFieldInput.template.tsx @@ -60,6 +60,7 @@ export const InputFieldInputTemplate = ({ const informerInputRef1 = useRef(null); const informerInputRef2 = useRef(null); + const informerInputRef3 = useRef(null); const handleChange = (e: ChangeEvent) => { const inputValue = e.target.value; @@ -138,11 +139,24 @@ export const InputFieldInputTemplate = ({ /> + + + + + } + /> + } + iconsBefore={} /> - + ); diff --git a/src/components/input/InputEx/index.tsx b/src/components/input/InputEx/index.tsx index 1982f93e7..c32894134 100644 --- a/src/components/input/InputEx/index.tsx +++ b/src/components/input/InputEx/index.tsx @@ -1,5 +1,15 @@ -import { Children, forwardRef, useRef, useState, useEffect, useLayoutEffect } from 'react'; -import type { ReactNode, ChangeEvent, MouseEvent, RefObject } from 'react'; +import { + Children, + Fragment, + cloneElement, + forwardRef, + isValidElement, + useRef, + useState, + useEffect, + useLayoutEffect, +} from 'react'; +import type { ReactNode, ChangeEvent, MouseEvent, RefObject, ReactElement } from 'react'; import styled, { css, type DataAttributes } from 'styled-components'; import { ReactComponent as CloseOutlineSvg } from '@admiral-ds/icons/build/service/CloseOutline.svg'; @@ -170,6 +180,37 @@ const IconPanelAfter = styled(IconPanel)` const preventDefault = (e: MouseEvent) => e.preventDefault(); +// Разворачиваем вложенные массивы и React.Fragment, чтобы корректно посчитать количество иконок +function flattenChildren(children: ReactNode): ReactElement[] { + const result: ReactElement[] = []; + let index = 0; + + Children.forEach(children, (child) => { + if (!child) return; + + if (Array.isArray(child)) { + result.push(...flattenChildren(child)); + return; + } + + if (isValidElement(child) && child.type === Fragment) { + if (child.props && child.props.children) { + result.push(...flattenChildren(child.props.children)); + } + return; + } + + if (isValidElement(child)) { + // Если у элемента нет key, добавляем его на основе индекса + // Это нужно для избежания предупреждений React о missing keys + const elementWithKey = child.key != null ? child : cloneElement(child, { key: `flattened-${index++}` }); + result.push(elementWithKey); + } + }); + + return result; +} + const Container = styled.div<{ disabled?: boolean; $dimension?: ComponentDimension; @@ -410,12 +451,11 @@ export const InputEx = forwardRef( props.onChange?.(e); }; - const iconAfterArray = Children.toArray(iconsAfter || icons); - const iconBeforeArray = Children.toArray(iconsBefore); + const iconAfterArray = flattenChildren(iconsAfter || icons); + const iconBeforeArray = flattenChildren(iconsBefore); if (!props.readOnly && displayClearIcon && !!innerValue) { const clearInputIconButtonProps = { - key: 'clear-icon', icon: CloseOutlineSvg, onClick: () => { if (inputRef.current) { @@ -427,6 +467,7 @@ export const InputEx = forwardRef( iconAfterArray.unshift( , diff --git a/src/components/input/SuggestInput/index.tsx b/src/components/input/SuggestInput/index.tsx index 712f3c8ed..34b90cdd7 100644 --- a/src/components/input/SuggestInput/index.tsx +++ b/src/components/input/SuggestInput/index.tsx @@ -197,7 +197,11 @@ export const SuggestInput = forwardRef( const inputIconButtonProps = { icon: icon, onClick: onSearchButtonClick, 'aria-hidden': true }; iconArray.push( - , + , ); } diff --git a/src/components/input/TextInput/index.tsx b/src/components/input/TextInput/index.tsx index 6c2a43f9c..5b6a2a1f7 100644 --- a/src/components/input/TextInput/index.tsx +++ b/src/components/input/TextInput/index.tsx @@ -1,5 +1,15 @@ -import type { ChangeEvent, ReactNode, RefObject, InputHTMLAttributes } from 'react'; -import { Children, forwardRef, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { ChangeEvent, ReactNode, RefObject, InputHTMLAttributes, ReactElement } from 'react'; +import { + Children, + Fragment, + cloneElement, + forwardRef, + isValidElement, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import styled, { css, type DataAttributes } from 'styled-components'; import type { CustomInputHandler, InputData } from '#src/components/common/dom/changeInputData'; @@ -320,6 +330,37 @@ export interface TextInputProps extends InputHTMLAttributes { const nothing = () => {}; +// Разворачиваем вложенные массивы и React.Fragment, чтобы корректно посчитать количество иконок +function flattenChildren(children: ReactNode): ReactElement[] { + const result: ReactElement[] = []; + let index = 0; + + Children.forEach(children, (child) => { + if (!child) return; + + if (Array.isArray(child)) { + result.push(...flattenChildren(child)); + return; + } + + if (isValidElement(child) && child.type === Fragment) { + if (child.props && child.props.children) { + result.push(...flattenChildren(child.props.children)); + } + return; + } + + if (isValidElement(child)) { + // Если у элемента нет key, добавляем его на основе индекса + // Это нужно для избежания предупреждений React о missing keys + const elementWithKey = child.key != null ? child : cloneElement(child, { key: `flattened-${index++}` }); + result.push(elementWithKey); + } + }); + + return result; +} + export const TextInput = forwardRef( ( { @@ -350,8 +391,8 @@ export const TextInput = forwardRef( const inputRef = useRef(null); const wrapperRef = containerRef || useRef(null); - const iconAfterArray = Children.toArray(iconsAfter || icons); - const iconBeforeArray = Children.toArray(iconsBefore); + const iconAfterArray = flattenChildren(iconsAfter || icons); + const iconBeforeArray = flattenChildren(iconsBefore); const [overflowActive, setOverflowActive] = useState(false); const [tooltipVisible, setTooltipVisible] = useState(false); @@ -396,7 +437,6 @@ export const TextInput = forwardRef( const Icon = isPasswordVisible ? EyeOutlineSvg : EyeCloseOutlineSvg; const visiblePasswordInputIconButtonProps = { - key: 'eye-icon', icon: Icon, onClick: () => { setPasswordVisible(!isPasswordVisible); @@ -406,6 +446,7 @@ export const TextInput = forwardRef( iconAfterArray.push( , @@ -414,7 +455,6 @@ export const TextInput = forwardRef( if (!props.readOnly && displayClearIcon && !!innerValue) { const clearInputIconButtonProps = { - key: 'clear-icon', icon: CloseOutlineSvg, onMouseDown: (e: React.MouseEvent) => { // запрет на перемещение фокуса при клике по иконке @@ -430,6 +470,7 @@ export const TextInput = forwardRef( iconAfterArray.unshift( , diff --git a/src/components/input/TextInput/stories/TextInputWithIcon.template.tsx b/src/components/input/TextInput/stories/TextInputWithIcon.template.tsx index eeae0d5f0..7a5fb6a3c 100644 --- a/src/components/input/TextInput/stories/TextInputWithIcon.template.tsx +++ b/src/components/input/TextInput/stories/TextInputWithIcon.template.tsx @@ -34,6 +34,7 @@ export const TextInputWithIconTemplate = ({ const [localValue1, setValue1] = useState(String(value) ?? ''); const [localValue2, setValue2] = useState(String(value) ?? ''); const [localValue3, setValue3] = useState(String(value) ?? ''); + const [localValue4, setValue4] = useState(String(value) ?? ''); const handleChange1 = (e: ChangeEvent) => { const inputValue = e.target.value; @@ -51,9 +52,16 @@ export const TextInputWithIconTemplate = ({ props.onChange?.(e); }; + const handleChange4 = (e: ChangeEvent) => { + const inputValue = e.target.value; + setValue4(inputValue); + props.onChange?.(e); + }; + const informerInput1Ref = useRef(null); const informerInput2Ref = useRef(null); const informerInput3Ref = useRef(null); + const informerInput4Ref = useRef(null); return ( @@ -86,7 +94,7 @@ export const TextInputWithIconTemplate = ({ } /> - Поле ввода с иконкой слева: + Поле ввода с тремя иконками и крестиком: + + + + + } + /> + + Поле ввода с иконкой слева: + + } /> From cda272c1a8274ad4744d6cd1fc8c89fc59d370d3 Mon Sep 17 00:00:00 2001 From: VeronaPl Date: Fri, 28 Nov 2025 10:12:58 +0300 Subject: [PATCH 2/4] add textInput playwright test --- src/components/input/InputEx/index.tsx | 41 ++++++++--------- src/components/input/TextInput/index.tsx | 45 ++++++++++--------- tests/TextInput/textInputIconsSpacing.spec.ts | 40 +++++++++++++++++ 3 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 tests/TextInput/textInputIconsSpacing.spec.ts diff --git a/src/components/input/InputEx/index.tsx b/src/components/input/InputEx/index.tsx index c32894134..e9e0911cc 100644 --- a/src/components/input/InputEx/index.tsx +++ b/src/components/input/InputEx/index.tsx @@ -180,34 +180,35 @@ const IconPanelAfter = styled(IconPanel)` const preventDefault = (e: MouseEvent) => e.preventDefault(); -// Разворачиваем вложенные массивы и React.Fragment, чтобы корректно посчитать количество иконок +// Разворачиваем вложенные массивы и React.Fragment, чтобы корректно посчитать количество иконок. +// Дополнительно гарантируем наличие уникальных key у элементов без собственного key. function flattenChildren(children: ReactNode): ReactElement[] { const result: ReactElement[] = []; - let index = 0; + let autoKey = 0; - Children.forEach(children, (child) => { - if (!child) return; + const traverse = (nodes: ReactNode) => { + Children.forEach(nodes, (child) => { + if (!child) return; - if (Array.isArray(child)) { - result.push(...flattenChildren(child)); - return; - } + if (Array.isArray(child)) { + traverse(child); + return; + } - if (isValidElement(child) && child.type === Fragment) { - if (child.props && child.props.children) { - result.push(...flattenChildren(child.props.children)); + if (isValidElement(child) && child.type === Fragment) { + if (child.props && child.props.children) { + traverse(child.props.children); + } + return; } - return; - } - if (isValidElement(child)) { - // Если у элемента нет key, добавляем его на основе индекса - // Это нужно для избежания предупреждений React о missing keys - const elementWithKey = child.key != null ? child : cloneElement(child, { key: `flattened-${index++}` }); - result.push(elementWithKey); - } - }); + if (isValidElement(child)) { + result.push(child.key != null ? child : cloneElement(child, { key: `flattened-${autoKey++}` })); + } + }); + }; + traverse(children); return result; } diff --git a/src/components/input/TextInput/index.tsx b/src/components/input/TextInput/index.tsx index 5b6a2a1f7..766ee280e 100644 --- a/src/components/input/TextInput/index.tsx +++ b/src/components/input/TextInput/index.tsx @@ -330,34 +330,35 @@ export interface TextInputProps extends InputHTMLAttributes { const nothing = () => {}; -// Разворачиваем вложенные массивы и React.Fragment, чтобы корректно посчитать количество иконок +// Разворачиваем вложенные массивы и React.Fragment, чтобы корректно посчитать количество иконок. +// Дополнительно гарантируем наличие уникальных key у элементов без собственного key. function flattenChildren(children: ReactNode): ReactElement[] { const result: ReactElement[] = []; - let index = 0; + let autoKey = 0; - Children.forEach(children, (child) => { - if (!child) return; + const traverse = (nodes: ReactNode) => { + Children.forEach(nodes, (child) => { + if (!child) return; - if (Array.isArray(child)) { - result.push(...flattenChildren(child)); - return; - } + if (Array.isArray(child)) { + traverse(child); + return; + } - if (isValidElement(child) && child.type === Fragment) { - if (child.props && child.props.children) { - result.push(...flattenChildren(child.props.children)); + if (isValidElement(child) && child.type === Fragment) { + if (child.props && child.props.children) { + traverse(child.props.children); + } + return; } - return; - } - if (isValidElement(child)) { - // Если у элемента нет key, добавляем его на основе индекса - // Это нужно для избежания предупреждений React о missing keys - const elementWithKey = child.key != null ? child : cloneElement(child, { key: `flattened-${index++}` }); - result.push(elementWithKey); - } - }); + if (isValidElement(child)) { + result.push(child.key != null ? child : cloneElement(child, { key: `flattened-${autoKey++}` })); + } + }); + }; + traverse(children); return result; } @@ -568,12 +569,12 @@ export const TextInput = forwardRef( /> {iconsBeforeCount > 0 && ( - + {iconBeforeArray} )} {iconsAfterCount > 0 && ( - + {iconAfterArray} )} diff --git a/tests/TextInput/textInputIconsSpacing.spec.ts b/tests/TextInput/textInputIconsSpacing.spec.ts new file mode 100644 index 000000000..d14c4f64b --- /dev/null +++ b/tests/TextInput/textInputIconsSpacing.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; + +import { getStorybookFrameLocator } from '../utils'; + +const STORY_PATH = '/?path=/story/admiral-2-1-input-textinput--text-input-with-icon'; + +test.describe('TextInput icons spacing', () => { + test('reserves space for nested icons and keeps them accessible', async ({ page }) => { + await page.goto(STORY_PATH); + const frame = getStorybookFrameLocator(page); + + // Третий инпут в истории — с тремя иконками + крестик + const targetInput = frame.locator('.text-input-native-input').nth(2); + await targetInput.click(); + await targetInput.fill( + 'Очень-очень-очень длинный текст для проверки того, что иконки точно-точно-точно не перекрывают содержимое инпута', + ); + + const container = targetInput.locator('xpath=..'); + const iconPanel = container.locator('[data-role="icon-pane-after"]'); + await expect(iconPanel).toHaveCount(1); + + // 3 пользовательские иконки + иконка очистки + const iconButtons = iconPanel.locator(':scope > *'); + await expect(iconButtons).toHaveCount(4); + + const paddingRight = await targetInput.evaluate((el) => + parseFloat(window.getComputedStyle(el).paddingRight || '0'), + ); + expect(paddingRight).toBeGreaterThanOrEqual(140); // 16 + (24 + 8) * 4 = 144px + + const [inputBox, panelBox] = await Promise.all([targetInput.boundingBox(), iconPanel.boundingBox()]); + expect(inputBox && panelBox).toBeTruthy(); + + if (inputBox && panelBox) { + const safeTextAreaEnd = inputBox.x + inputBox.width - paddingRight; + expect(panelBox.x).toBeGreaterThanOrEqual(safeTextAreaEnd - 1); + } + }); +}); From 16cafc9036390eddb4b33a39de2c2811bd500302 Mon Sep 17 00:00:00 2001 From: VeronaPl Date: Fri, 28 Nov 2025 10:19:00 +0300 Subject: [PATCH 3/4] update snapshots --- .../form/DateField/__snapshots__/DateField.test.tsx.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/form/DateField/__snapshots__/DateField.test.tsx.snap b/src/components/form/DateField/__snapshots__/DateField.test.tsx.snap index b17ada749..0ac37b909 100644 --- a/src/components/form/DateField/__snapshots__/DateField.test.tsx.snap +++ b/src/components/form/DateField/__snapshots__/DateField.test.tsx.snap @@ -201,6 +201,7 @@ exports[`DateField should render component 1`] = ` />
Date: Tue, 16 Dec 2025 10:17:01 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BD=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20let=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/input/InputEx/index.tsx | 5 ++--- src/components/input/TextInput/index.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/input/InputEx/index.tsx b/src/components/input/InputEx/index.tsx index e9e0911cc..39fbe13d0 100644 --- a/src/components/input/InputEx/index.tsx +++ b/src/components/input/InputEx/index.tsx @@ -184,10 +184,9 @@ const preventDefault = (e: MouseEvent) => e.preventDefault(); // Дополнительно гарантируем наличие уникальных key у элементов без собственного key. function flattenChildren(children: ReactNode): ReactElement[] { const result: ReactElement[] = []; - let autoKey = 0; const traverse = (nodes: ReactNode) => { - Children.forEach(nodes, (child) => { + Children.forEach(nodes, (child, index) => { if (!child) return; if (Array.isArray(child)) { @@ -203,7 +202,7 @@ function flattenChildren(children: ReactNode): ReactElement[] { } if (isValidElement(child)) { - result.push(child.key != null ? child : cloneElement(child, { key: `flattened-${autoKey++}` })); + result.push(child.key != null ? child : cloneElement(child, { key: `flattened-${index}` })); } }); }; diff --git a/src/components/input/TextInput/index.tsx b/src/components/input/TextInput/index.tsx index 766ee280e..2a3db53e9 100644 --- a/src/components/input/TextInput/index.tsx +++ b/src/components/input/TextInput/index.tsx @@ -334,10 +334,9 @@ const nothing = () => {}; // Дополнительно гарантируем наличие уникальных key у элементов без собственного key. function flattenChildren(children: ReactNode): ReactElement[] { const result: ReactElement[] = []; - let autoKey = 0; const traverse = (nodes: ReactNode) => { - Children.forEach(nodes, (child) => { + Children.forEach(nodes, (child, index) => { if (!child) return; if (Array.isArray(child)) { @@ -353,7 +352,7 @@ function flattenChildren(children: ReactNode): ReactElement[] { } if (isValidElement(child)) { - result.push(child.key != null ? child : cloneElement(child, { key: `flattened-${autoKey++}` })); + result.push(child.key != null ? child : cloneElement(child, { key: `flattened-${index}` })); } }); };